From b0d6bd436b964efff20cc9ec2fc39cd74b55aa0d Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 3 Jan 2026 12:27:17 +0100 Subject: [PATCH 01/48] first --- GPUcollecter.pm | 640 +++++++++++++++++++++++++++++++++++++++++ pve-mod-gui-sensors.sh | 74 ++++- 2 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 GPUcollecter.pm diff --git a/GPUcollecter.pm b/GPUcollecter.pm new file mode 100644 index 0000000..6bf23ae --- /dev/null +++ b/GPUcollecter.pm @@ -0,0 +1,640 @@ +package PVE::API2::GPUMonitor; + +use strict; +use warnings; +use JSON; +use POSIX qw(WNOHANG); +use Fcntl qw(:flock); +use Time::HiRes qw(time); +use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); + + +my $state_file = '/var/run/pve-gpu/stats.json'; +my $lock_file = '/var/run/pve-gpu/pve-gpu-collector.lock'; +my $last_snapshot = {}; +my $last_mtime = 0; +my $is_collector_parent = 0; # Flag to track if this process started collectors +my $last_get_stats_time = 0; # Track when get_stats was last called +my $COLLECTOR_TIMEOUT = 30; # Stop collectors 30 seconds after last get_stats call + +my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support +my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) +my $nvidia_gpu_enabled = 0; # Set to 1 to enable NVIDIA GPU support (not yet implemented) + + +# ============================================================================ +# Intel GPU Support +# ============================================================================ + +# Parse Intel GPU line output format +sub parse_intel_gpu_line { + my ($line) = @_; + + # Expected format (with aligned columns): + # Freq MHz IRQ RC6 Power W RCS BCS VCS VECS + # req act /s % gpu pkg % se wa % se wa % se wa % se wa + # 0 0 0 0 0.00 7.47 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0 + + # Remove leading/trailing whitespace + $line =~ s/^\s+|\s+$//g; + + # Split by whitespace and filter empty values + my @values = grep { $_ ne '' } split(/\s+/, $line); + + # Expected: req(0) act(1) irq(2) rc6(3) gpu(4) pkg(5) rcs%(6) rcs_se(7) rcs_wa(8) + # bcs%(9) bcs_se(10) bcs_wa(11) vcs%(12) vcs_se(13) vcs_wa(14) vecs%(15) vecs_se(16) vecs_wa(17) + + return unless @values >= 18; + + my $stats = { + frequency => { + requested => $values[0] + 0.0, + actual => $values[1] + 0.0, + unit => "MHz" + }, + interrupts => { + count => $values[2] + 0.0, + unit => "irq/s" + }, + rc6 => { + value => $values[3] + 0.0, + unit => "%" + }, + power => { + GPU => $values[4] + 0.0, + Package => $values[5] + 0.0, + unit => "W" + }, + engines => { + "Render/3D" => { + busy => $values[6] + 0.0, + sema => $values[7] + 0.0, + wait => $values[8] + 0.0, + unit => "%" + }, + Blitter => { + busy => $values[9] + 0.0, + sema => $values[10] + 0.0, + wait => $values[11] + 0.0, + unit => "%" + }, + Video => { + busy => $values[12] + 0.0, + sema => $values[13] + 0.0, + wait => $values[14] + 0.0, + unit => "%" + }, + VideoEnhance => { + busy => $values[15] + 0.0, + sema => $values[16] + 0.0, + wait => $values[17] + 0.0, + unit => "%" + } + }, + clients => {} + }; + + return $stats; +} + +# Get list of Intel GPU devices +sub get_intel_gpu_devices { + my @devices = (); + + return @devices unless -x '/usr/bin/intel_gpu_top'; + + if (open my $fh, '-|', 'intel_gpu_top -L') { + while (<$fh>) { + chomp; + # Parse: "card0 Intel Alderlake_n (Gen12) pci:vendor=8086,device=46D0,card=0" + # or: "card0 Intel Alderlake_n (Gen12) pci:0000:00:02.0" + if (/^(card\d+)\s+(.+?)\s+(pci:[^\s]+)/) { + my $card = $1; + my $name = $2; + my $path = $3; + push @devices, { + card => $card, + name => $name, + path => $path, + drm_path => "/dev/dri/$card" + }; + warn "DEBUG get_intel_gpu_devices: Found GPU device: $card -> $name ($path)"; + } + } + close $fh; + } else { + warn "DEBUG get_intel_gpu_devices: Failed to run intel_gpu_top -L: $!"; + } + + return @devices; +} + +sub collector_for_intel_device { + my ($device) = @_; + + my $drm_dev = "drm:/dev/dri/$device->{card}"; + my $intel_gpu_top_pid = undef; + + # Each device writes to its own file + my $device_state_file = "/var/run/pve-gpu/stats-$device->{card}.json"; + + warn "DEBUG collector_for_intel_device: Collector started for device: $drm_dev, writing to $device_state_file"; + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + $SIG{TERM} = sub { + warn "DEBUG collector_for_intel_device: Collector for $device->{card} received SIGTERM"; + $shutdown = 1; + kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; + }; + $SIG{INT} = sub { + warn "DEBUG collector_for_intel_device: Collector for $device->{card} received SIGINT"; + $shutdown = 1; + kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; + }; + + # Run intel_gpu_top once and keep reading from it + warn "DEBUG: About to open pipe to intel_gpu_top"; + $intel_gpu_top_pid = open(my $fh, '-|', "intel_gpu_top -d $drm_dev -s 1000 -l 2>&1"); + + unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { + warn "DEBUG collector_for_intel_device: Failed to run intel_gpu_top for $drm_dev: $!"; + exit 1; + } + + warn "DEBUG: Pipe opened successfully, PID=$intel_gpu_top_pid"; + + my $line_count = 0; + my $node_name = "node0"; # You may want to generate this based on device index + + while (my $line = <$fh>) { + last if $shutdown; + + $line_count++; + chomp $line; + + # Skip header lines + next if $line =~ /MHz|IRQ|RC6|Power|RCS|BCS|VCS|VECS|req\s+act|^\s*$/; + + # Check if this is a data line + if ($line =~ /^\s*[\d\s\.]+$/) { + my $stats = parse_intel_gpu_line($line); + + if ($stats) { + # Build device-specific structure (just the node, not the full Graphics/Intel hierarchy) + my $device_data = { + $node_name => { + gpu_name => $device->{name}, + device_path => $device->{path}, + drm_path => $device->{drm_path}, + stats => $stats + } + }; + + # Write to device-specific file + eval { + open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; + print $ofh JSON->new->pretty->encode($device_data); + close $ofh; + warn "DEBUG: Wrote stats to $device_state_file (line #$line_count)"; + }; + if ($@) { + warn "DEBUG: Error writing stats: $@"; + } + } + } + } + + close $fh; + warn "DEBUG collector_for_intel_device: Collector for $device->{card} shutting down"; + exit 0; +} + +# ============================================================================ +# AMD GPU Support (Placeholder) +# ============================================================================ + +sub get_amd_gpu_devices { + # TODO: Implement AMD GPU detection + # Use rocminfo or similar tools to detect AMD GPUs + warn "DEBUG: AMD GPU support not yet implemented"; + return (); +} + +sub parse_amd_gpu_line { + my ($line) = @_; + # TODO: Implement AMD GPU line parsing + # Parse rocm-smi or similar output + warn "DEBUG: AMD GPU line parsing not yet implemented"; + return undef; +} + +sub collector_for_amd_device { + my ($device) = @_; + # TODO: Implement AMD GPU collector + warn "DEBUG: AMD GPU collector not yet implemented"; + exit 0; +} + +# ============================================================================ +# NVIDIA GPU Support (Placeholder) +# ============================================================================ + +sub get_nvidia_gpu_devices { + # TODO: Implement NVIDIA GPU detection + # Use nvidia-smi to detect NVIDIA GPUs + warn "DEBUG: NVIDIA GPU support not yet implemented"; + return (); +} + +sub parse_nvidia_gpu_line { + my ($line) = @_; + # TODO: Implement NVIDIA GPU line parsing + # Parse nvidia-smi output + warn "DEBUG: NVIDIA GPU line parsing not yet implemented"; + return undef; +} + +sub collector_for_nvidia_device { + my ($device) = @_; + # TODO: Implement NVIDIA GPU collector + warn "DEBUG: NVIDIA GPU collector not yet implemented"; + exit 0; +} + +# ============================================================================ +# Supporting functions +# ============================================================================ + +# Check if a process is alive +sub is_process_alive { + my ($pid) = @_; + return -d "/proc/$pid"; +} + +# ============================================================================ +# Main Collector +# ============================================================================ + +sub start_collector { + warn "DEBUG start_collector: Checking if collector is already running"; + + # Ensure directory exists + my $run_dir = '/var/run/pve-gpu'; + unless (-d $run_dir) { + warn "DEBUG start_collector: Creating directory $run_dir"; + mkdir($run_dir, 0755) or warn "DEBUG start_collector: Failed to create $run_dir: $!"; + } + + # Try to acquire startup lock FIRST (prevents race conditions) + my $startup_lock = $lock_file . ".startup"; + my $startup_fh; + + warn "DEBUG start_collector: Trying to acquire startup lock: $startup_lock"; + + unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + # Startup lock exists - check if it's stale + warn "DEBUG start_collector: Startup lock exists, checking if stale"; + + if (open(my $check_fh, '<', $startup_lock)) { + my $lock_pid = <$check_fh>; + chomp $lock_pid if defined $lock_pid; + close($check_fh); + + if (defined $lock_pid && $lock_pid =~ /^\d+$/) { + warn "DEBUG start_collector: Startup lock held by PID $lock_pid"; + + if (is_process_alive($lock_pid)) { + warn "DEBUG start_collector: Lock holder PID $lock_pid is still alive, waiting"; + } else { + # Lock holder is dead, remove stale lock + warn "DEBUG start_collector: Lock holder PID $lock_pid is dead, removing stale startup lock"; + unlink($startup_lock); + + # Try to acquire lock again + unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + warn "DEBUG start_collector: Failed to acquire startup lock on retry: $!"; + return; + } + warn "DEBUG start_collector: Acquired startup lock after removing stale lock"; + } + } else { + # Invalid PID in lock file, remove it + warn "DEBUG start_collector: Invalid PID in startup lock, removing"; + unlink($startup_lock); + + # Try to acquire lock again + unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + warn "DEBUG start_collector: Failed to acquire startup lock on retry: $!"; + return; + } + warn "DEBUG start_collector: Acquired startup lock after removing invalid lock"; + } + } else { + warn "DEBUG start_collector: Could not read startup lock file: $!"; + return; + } + } else { + warn "DEBUG start_collector: Acquired startup lock on first try"; + } + + # We have the startup lock + print $startup_fh "$$\n"; + close($startup_fh); + warn "DEBUG start_collector: Wrote PID to startup lock"; + + # NOW check if collectors are already running (while holding startup lock) + if (-f $lock_file) { + warn "DEBUG start_collector: Lock file exists: $lock_file"; + if (open(my $lock_fh, '<', $lock_file)) { + warn "DEBUG start_collector: Opened lock file for reading"; + my @pids; + while (my $line = <$lock_fh>) { + chomp $line; + push @pids, $line if $line =~ /^\d+$/; + } + close($lock_fh); + + warn "DEBUG start_collector: Found " . scalar(@pids) . " PIDs in lock file: " . join(", ", @pids); + + # Check if ANY collector is alive + my $collector_running = 0; + foreach my $pid (@pids) { + warn "DEBUG start_collector: Checking PID $pid"; + + if (is_process_alive($pid)) { + warn "DEBUG start_collector: Collector already running with PID $pid"; + $collector_running = 1; + last; + } + } + + warn "DEBUG start_collector: Finished checking PIDs, collector_running = $collector_running"; + + if ($collector_running) { + warn "DEBUG start_collector: Releasing startup lock and returning - collector already running"; + unlink($startup_lock); + return; + } + + # All PIDs are dead, clean up stale lock + warn "DEBUG start_collector: All PIDs dead, removing stale lock file"; + unlink($lock_file); + } else { + warn "DEBUG start_collector: Failed to open lock file: $!"; + } + } else { + warn "DEBUG start_collector: Lock file does not exist"; + } + + # If we reach here, no collectors are running - start new ones + # Fork collector processes + my @child_pids; + + if ($intel_gpu_enabled) { + warn "DEBUG start_collector: Checking for intel_gpu_top"; + unless (-x '/usr/bin/intel_gpu_top') { + warn "DEBUG start_collector: intel_gpu_top not executable"; + unlink($startup_lock); + return; + } + warn "DEBUG start_collector: intel_gpu_top is executable"; + + warn "DEBUG start_collector: Getting Intel GPU devices"; + my @intel_devices = get_intel_gpu_devices(); + warn "DEBUG start_collector: Got " . scalar(@intel_devices) . " Intel devices"; + + unless (@intel_devices) { + warn "DEBUG start_collector: No Intel GPU devices found"; + unlink($startup_lock); + return; + } + warn "DEBUG start_collector: Found " . scalar(@intel_devices) . " Intel GPU device(s)"; + + + foreach my $device (@intel_devices) { + warn "DEBUG start_collector: About to fork collector for Intel $device->{card}"; + my $pid = fork(); + + unless (defined $pid) { + warn "DEBUG start_collector: fork failed: $!"; + unlink($startup_lock); + die "fork failed: $!"; + } + + if ($pid == 0) { + # Child process + warn "DEBUG start_collector: In child process for $device->{card}"; + collector_for_intel_device($device); + # collector_for_intel_device calls exit(0) + } else { + warn "DEBUG start_collector: Forked child PID $pid for $device->{card}"; + push @child_pids, $pid; + } + } + + warn "DEBUG start_collector: Forked " . scalar(@child_pids) . " children: " . join(", ", @child_pids); + } + + # Write child PIDs to lock file + if (open(my $lock_fh, '>', $lock_file)) { + warn "DEBUG start_collector: Opened lock file for writing"; + foreach my $pid (@child_pids) { + print $lock_fh "$pid\n"; + } + close($lock_fh); + warn "DEBUG start_collector: Wrote " . scalar(@child_pids) . " collector PID(s) to lock file"; + } else { + warn "DEBUG start_collector: Failed to open lock file for writing: $!"; + foreach my $pid (@child_pids) { + warn "DEBUG start_collector: Killing child $pid due to lock file write failure"; + kill 'TERM', $pid; + } + unlink($startup_lock); + return; + } + + # Wait briefly to ensure collector is actually running + sleep 0.1; + + # Verify at least one child is still alive + my $any_alive = 0; + foreach my $pid (@child_pids) { + if (kill(0, $pid)) { + $any_alive = 1; + warn "DEBUG start_collector: Verified child PID $pid is alive"; + } else { + warn "DEBUG start_collector: WARNING - Child PID $pid died immediately!"; + } + } + + unless ($any_alive) { + warn "DEBUG start_collector: ERROR - No children alive after fork!"; + } + + # Remove startup lock LAST + unlink($startup_lock); + warn "DEBUG start_collector: Released startup lock"; + + warn "DEBUG start_collector: Collectors started successfully, returning"; +} + +sub get_stats { + warn "DEBUG: get_stats() called"; + + start_collector(); + + my $stats_dir = '/var/run/pve-gpu'; + + # Find all device-specific stat files + my $dh; + unless (opendir($dh, $stats_dir)) { + warn "DEBUG: Failed to open stats directory: $stats_dir: $!"; + return $last_snapshot; + } + + my @stat_files = grep { /^stats-card\d+\.json$/ } readdir($dh); + closedir($dh); + + unless (@stat_files) { + warn "DEBUG: No device stat files found in $stats_dir"; + return $last_snapshot; + } + + warn "DEBUG: Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files); + + # Check if any files have been modified + my $newest_mtime = 0; + my $files_changed = 0; + + foreach my $file (@stat_files) { + my $filepath = "$stats_dir/$file"; + my @stat = stat($filepath); + if (@stat && $stat[9] > $newest_mtime) { + $newest_mtime = $stat[9]; + } + } + + if ($newest_mtime == $last_mtime) { + warn "DEBUG: No device files modified, returning cached snapshot"; + return $last_snapshot; + } + + warn "DEBUG: Device files modified ($last_mtime -> $newest_mtime), reading and merging files"; + + # Merge all device files + my $merged = { + Graphics => { + Intel => {} + } + }; + + foreach my $file (@stat_files) { + my $filepath = "$stats_dir/$file"; + + warn "DEBUG: Reading device file: $filepath"; + + eval { + my $fh; + unless (open($fh, '<', $filepath)) { + warn "DEBUG: Failed to open $filepath: $!"; + return; + } + + local $/; + my $json = <$fh>; + close($fh); + + warn "DEBUG: Read $file, JSON length: " . length($json) . " bytes"; + + my $device_data = decode_json($json); + + # Merge this device's data into the main structure + foreach my $node_name (keys %$device_data) { + $merged->{Graphics}->{Intel}->{$node_name} = $device_data->{$node_name}; + warn "DEBUG: Merged node '$node_name' from $file"; + } + }; + if ($@) { + warn "DEBUG: Failed to read/parse $filepath: $@"; + } + } + + # Update cache + $last_snapshot = $merged; + $last_mtime = $newest_mtime; + + warn "DEBUG: Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"; + + return $last_snapshot; +} + +sub cleanup { + unless ($is_collector_parent) { + warn "DEBUG cleanup: This process did not start collectors, skipping cleanup"; + return; + } + + warn "DEBUG cleanup: Starting cleanup (this should rarely happen)"; + + # DON'T cleanup automatically - collectors should keep running + # across worker process lifecycles + # Only cleanup if explicitly called +} + +# Remove the END block that automatically calls cleanup +# END { cleanup() } + +# Instead, add a manual cleanup function that can be called explicitly +sub stop_collectors { + warn "DEBUG stop_collectors: Stopping all collectors"; + + # Read current PIDs from lock file + my @pids; + if (open my $lock_fh, '<', $lock_file) { + while (my $line = <$lock_fh>) { + chomp $line; + push @pids, $line if $line =~ /^\d+$/; + } + close($lock_fh); + } + + if (@pids) { + # Send SIGTERM to all collectors + warn "DEBUG stop_collectors: Sending SIGTERM to " . scalar(@pids) . " process(es)"; + foreach my $pid (@pids) { + kill('TERM', $pid) if kill(0, $pid); + } + + # Wait up to 5 seconds for graceful shutdown + my $timeout = 5; + my $start = time(); + while (time() - $start < $timeout) { + my $any_alive = 0; + foreach my $pid (@pids) { + if (kill(0, $pid)) { + $any_alive = 1; + last; + } + } + last unless $any_alive; + select(undef, undef, undef, 0.1); # sleep 0.1s + } + + # Force kill any survivors + foreach my $pid (@pids) { + if (kill(0, $pid)) { + warn "DEBUG stop_collectors: Force killing process $pid"; + kill('KILL', $pid); + } + } + } + + unlink $state_file if -f $state_file; + unlink $lock_file if -f $lock_file; + warn "DEBUG stop_collectors: Cleanup complete"; +} + +END { cleanup() } + +1; diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index da7f9df..69d2824 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -146,7 +146,7 @@ function configure { #endregion sensors collection #### CPU #### - #region cpu setup + #region CPU setup msgb "\n=== Detecting CPU temperature sensors ===" ENABLE_CPU=false local cpuList="" @@ -195,6 +195,58 @@ function configure { warn "No CPU temperature sensors found." fi #endregion cpu setup + + #### Graphics #### + #region Graphics setup + msgb "\n=== Detecting Graphics information ===" + + #region intel GPU setup + # Check for Intel GPU - ensure intel_gpu_top is installed + if command -v intel_gpu_top &>/dev/null; then + # detect all intel cards using intel_gpu_top -L. Show them line by line + local intelCards + + # Get the output from intel_gpu_top -L, skip empty lines + intelCards=$(intel_gpu_top -L 2>/dev/null | grep -E '^card[0-9]+' || true) + + if [[ -n "$intelCards" ]]; then + local cardCount=$(echo "$intelCards" | wc -l) + echo "Intel GPU(s) detected ($cardCount):" + echo "$intelCards" | while IFS= read -r line; do + # Extract card name, GPU model, and PCI info + if [[ $line =~ ^(card[0-9]+)[[:space:]]+(.+)[[:space:]]+pci:(.+)$ ]]; then + local cardName="${BASH_REMATCH[1]}" + local gpuModel="${BASH_REMATCH[2]}" + local pciInfo="${BASH_REMATCH[3]}" + + echo " - Card: $cardName" + echo " Model: $gpuModel" + echo " PCI: $pciInfo" + else + # Fallback: just show the line as-is + echo " $line" + fi + done + ENABLE_INTEL_GPU_INFO=true + else + warn "No Intel GPUs detected by intel_gpu_top." + ENABLE_INTEL_GPU_INFO=false + fi + else + warn "intel_gpu_top command not found. Skipping Intel GPU information detection." + ENABLE_INTEL_GPU_INFO=false + fi + #endregion intel GPU setup + + #region NVIDIA GPU setup + # not implemented yet + #endregion NVIDIA GPU setup + + #region AMD GPU setup + # not implemented yet + #endregion AMD GPU setup + + #endregion Graphics setup #### RAM #### #region ram setup @@ -549,6 +601,26 @@ collect_ups_output() { info "UPS retriever added to \"$output_file\"." } +collect_graphics_intel_output() { + local output_file="$1" + local intelCmd + + +use threads; +use JSON; + +my $snapshot; + +threads->create(sub { + while (1) { + my $json = `intel_gpu_top -J -s 1`; + $snapshot = decode_json($json); + sleep 1; # optional + } +}); + + +} # Collect system information collect_system_info() { From 092d1555b16c526b0a89f2adacbc8e6e0549d4af Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 3 Jan 2026 14:02:13 +0100 Subject: [PATCH 02/48] add monitor to close collection, not fully working. --- GPUcollecter.pm | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 6bf23ae..2efb0ba 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -15,12 +15,13 @@ my $last_snapshot = {}; my $last_mtime = 0; my $is_collector_parent = 0; # Flag to track if this process started collectors my $last_get_stats_time = 0; # Track when get_stats was last called -my $COLLECTOR_TIMEOUT = 30; # Stop collectors 30 seconds after last get_stats call +my $COLLECTOR_TIMEOUT = 0; # Stop collectors x seconds after last get_stats call my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) my $nvidia_gpu_enabled = 0; # Set to 1 to enable NVIDIA GPU support (not yet implemented) - +my $monitor_pid; +my $monitor_running = 0; # ============================================================================ # Intel GPU Support @@ -131,7 +132,8 @@ sub get_intel_gpu_devices { sub collector_for_intel_device { my ($device) = @_; - + $0 = "pve-mod-gpu-intel-collector: $device->{card}"; + my $drm_dev = "drm:/dev/dri/$device->{card}"; my $intel_gpu_top_pid = undef; @@ -472,6 +474,10 @@ sub start_collector { warn "DEBUG start_collector: ERROR - No children alive after fork!"; } + # Start pve_mod_monitor process + pve_mod_monitor(); + + # Remove startup lock LAST unlink($startup_lock); warn "DEBUG start_collector: Released startup lock"; @@ -563,12 +569,45 @@ sub get_stats { # Update cache $last_snapshot = $merged; $last_mtime = $newest_mtime; + $last_get_stats_time = time(); warn "DEBUG: Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"; return $last_snapshot; } +sub pve_mod_monitor { + return if $monitor_running; + + $monitor_pid = fork(); + + if (!defined $monitor_pid) { + warn "ERROR: Failed to fork monitor process: $!"; + return; + } + + if ($monitor_pid == 0) { + # Child process + $0 = "pve_mod_monitor"; # Set process name for easier identification + + while (1) { + if(time() - $last_get_stats_time > $COLLECTOR_TIMEOUT) { + warn "DEBUG pve_mod_monitor: No get_stats call in the last $COLLECTOR_TIMEOUT seconds, stopping collectors"; + stop_collectors(); + exit(0); + } + sleep(1); + warn "DEBUG pve_mod_monitor: pve_mod_monitor still running"; + } + + exit(0); # This shouldn't be reached, but just in case + } + + # Parent process + $monitor_running = 1; + warn "DEBUG start_monitor: Monitor process started with PID $monitor_pid"; +} + sub cleanup { unless ($is_collector_parent) { warn "DEBUG cleanup: This process did not start collectors, skipping cleanup"; From 7b78e2a3aef25fedc3c73dc342efe2bdea0ae7bb Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 3 Jan 2026 20:48:49 +0100 Subject: [PATCH 03/48] Switchable debugger, consistent debug messages, refactor startup process --- GPUcollecter.pm | 499 +++++++++++++++++++++++++++--------------------- 1 file changed, 280 insertions(+), 219 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 2efb0ba..a8fc1af 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -8,14 +8,43 @@ use Fcntl qw(:flock); use Time::HiRes qw(time); use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); +# Debug configuration - set to 0 to disable all debug output +my $DEBUG_ENABLED = 1; +# Debug function showing line number and call chain +# Usage: debug(__LINE__, "message") +sub debug { + return unless $DEBUG_ENABLED; + + my ($line, $message) = @_; + + # Get function call chain + my @caller1 = caller(1); # who called debug() + my @caller2 = caller(2); # parent of caller + + my $sub1 = $caller1[3] || 'main'; + my $sub2 = $caller2[3]; + + $sub1 =~ s/.*:://; # Remove package prefix + + if (defined $sub2) { + $sub2 =~ s/.*:://; + warn "[$sub2 -> $sub1:$line] $message\n"; + } else { + # No parent caller (called from top level) + warn "[$sub1:$line] $message\n"; + } +} + +my $stats_dir = '/var/run/pve-gpu'; my $state_file = '/var/run/pve-gpu/stats.json'; my $lock_file = '/var/run/pve-gpu/pve-gpu-collector.lock'; +my $startup_lock = $lock_file . ".startup"; my $last_snapshot = {}; my $last_mtime = 0; my $is_collector_parent = 0; # Flag to track if this process started collectors -my $last_get_stats_time = 0; # Track when get_stats was last called -my $COLLECTOR_TIMEOUT = 0; # Stop collectors x seconds after last get_stats call +my $last_get_graphic_stats_time = 0; # Track when get_graphic_stats was last called +my $COLLECTOR_TIMEOUT = 0; # Stop collectors x seconds after last get_graphic_stats call my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) @@ -119,12 +148,12 @@ sub get_intel_gpu_devices { path => $path, drm_path => "/dev/dri/$card" }; - warn "DEBUG get_intel_gpu_devices: Found GPU device: $card -> $name ($path)"; + debug(__LINE__, "Found GPU device: $card -> $name ($path)"); } } close $fh; } else { - warn "DEBUG get_intel_gpu_devices: Failed to run intel_gpu_top -L: $!"; + debug(__LINE__, "Failed to run intel_gpu_top -L: $!"); } return @devices; @@ -140,31 +169,31 @@ sub collector_for_intel_device { # Each device writes to its own file my $device_state_file = "/var/run/pve-gpu/stats-$device->{card}.json"; - warn "DEBUG collector_for_intel_device: Collector started for device: $drm_dev, writing to $device_state_file"; + debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file"); # Set up signal handlers for graceful shutdown my $shutdown = 0; $SIG{TERM} = sub { - warn "DEBUG collector_for_intel_device: Collector for $device->{card} received SIGTERM"; + debug(__LINE__, "Collector for $device->{card} received SIGTERM"); $shutdown = 1; kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; }; $SIG{INT} = sub { - warn "DEBUG collector_for_intel_device: Collector for $device->{card} received SIGINT"; + debug(__LINE__, "Collector for $device->{card} received SIGINT"); $shutdown = 1; kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; }; # Run intel_gpu_top once and keep reading from it - warn "DEBUG: About to open pipe to intel_gpu_top"; + debug(__LINE__, "About to open pipe to intel_gpu_top"); $intel_gpu_top_pid = open(my $fh, '-|', "intel_gpu_top -d $drm_dev -s 1000 -l 2>&1"); unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { - warn "DEBUG collector_for_intel_device: Failed to run intel_gpu_top for $drm_dev: $!"; + debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!"); exit 1; } - warn "DEBUG: Pipe opened successfully, PID=$intel_gpu_top_pid"; + debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid"); my $line_count = 0; my $node_name = "node0"; # You may want to generate this based on device index @@ -198,17 +227,17 @@ sub collector_for_intel_device { open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; print $ofh JSON->new->pretty->encode($device_data); close $ofh; - warn "DEBUG: Wrote stats to $device_state_file (line #$line_count)"; + debug(__LINE__, "Wrote stats to $device_state_file (line #$line_count)"); }; if ($@) { - warn "DEBUG: Error writing stats: $@"; + debug(__LINE__, "Error writing stats: $@"); } } } } close $fh; - warn "DEBUG collector_for_intel_device: Collector for $device->{card} shutting down"; + debug(__LINE__, "Collector for $device->{card} shutting down"); exit 0; } @@ -219,7 +248,7 @@ sub collector_for_intel_device { sub get_amd_gpu_devices { # TODO: Implement AMD GPU detection # Use rocminfo or similar tools to detect AMD GPUs - warn "DEBUG: AMD GPU support not yet implemented"; + debug(__LINE__, "AMD GPU support not yet implemented"); return (); } @@ -227,14 +256,14 @@ sub parse_amd_gpu_line { my ($line) = @_; # TODO: Implement AMD GPU line parsing # Parse rocm-smi or similar output - warn "DEBUG: AMD GPU line parsing not yet implemented"; + debug(__LINE__, "AMD GPU line parsing not yet implemented"); return undef; } sub collector_for_amd_device { my ($device) = @_; # TODO: Implement AMD GPU collector - warn "DEBUG: AMD GPU collector not yet implemented"; + debug(__LINE__, "AMD GPU collector not yet implemented"); exit 0; } @@ -245,7 +274,7 @@ sub collector_for_amd_device { sub get_nvidia_gpu_devices { # TODO: Implement NVIDIA GPU detection # Use nvidia-smi to detect NVIDIA GPUs - warn "DEBUG: NVIDIA GPU support not yet implemented"; + debug(__LINE__, "NVIDIA GPU support not yet implemented"); return (); } @@ -253,14 +282,14 @@ sub parse_nvidia_gpu_line { my ($line) = @_; # TODO: Implement NVIDIA GPU line parsing # Parse nvidia-smi output - warn "DEBUG: NVIDIA GPU line parsing not yet implemented"; + debug(__LINE__, "NVIDIA GPU line parsing not yet implemented"); return undef; } sub collector_for_nvidia_device { my ($device) = @_; # TODO: Implement NVIDIA GPU collector - warn "DEBUG: NVIDIA GPU collector not yet implemented"; + debug(__LINE__, "NVIDIA GPU collector not yet implemented"); exit 0; } @@ -278,178 +307,144 @@ sub is_process_alive { # Main Collector # ============================================================================ -sub start_collector { - warn "DEBUG start_collector: Checking if collector is already running"; - - # Ensure directory exists - my $run_dir = '/var/run/pve-gpu'; - unless (-d $run_dir) { - warn "DEBUG start_collector: Creating directory $run_dir"; - mkdir($run_dir, 0755) or warn "DEBUG start_collector: Failed to create $run_dir: $!"; - } - - # Try to acquire startup lock FIRST (prevents race conditions) - my $startup_lock = $lock_file . ".startup"; - my $startup_fh; - - warn "DEBUG start_collector: Trying to acquire startup lock: $startup_lock"; - - unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - # Startup lock exists - check if it's stale - warn "DEBUG start_collector: Startup lock exists, checking if stale"; - - if (open(my $check_fh, '<', $startup_lock)) { - my $lock_pid = <$check_fh>; - chomp $lock_pid if defined $lock_pid; - close($check_fh); - - if (defined $lock_pid && $lock_pid =~ /^\d+$/) { - warn "DEBUG start_collector: Startup lock held by PID $lock_pid"; +sub start_graphics_collectors { - if (is_process_alive($lock_pid)) { - warn "DEBUG start_collector: Lock holder PID $lock_pid is still alive, waiting"; - } else { - # Lock holder is dead, remove stale lock - warn "DEBUG start_collector: Lock holder PID $lock_pid is dead, removing stale startup lock"; - unlink($startup_lock); - - # Try to acquire lock again - unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - warn "DEBUG start_collector: Failed to acquire startup lock on retry: $!"; - return; - } - warn "DEBUG start_collector: Acquired startup lock after removing stale lock"; - } - } else { - # Invalid PID in lock file, remove it - warn "DEBUG start_collector: Invalid PID in startup lock, removing"; - unlink($startup_lock); - - # Try to acquire lock again - unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - warn "DEBUG start_collector: Failed to acquire startup lock on retry: $!"; - return; - } - warn "DEBUG start_collector: Acquired startup lock after removing invalid lock"; - } - } else { - warn "DEBUG start_collector: Could not read startup lock file: $!"; - return; - } - } else { - warn "DEBUG start_collector: Acquired startup lock on first try"; + if ($intel_gpu_enabled == 0 && $amd_gpu_enabled == 0 && $nvidia_gpu_enabled == 0) { + debug(__LINE__, "No GPU types enabled, skipping collector startup"); + return; } - - # We have the startup lock - print $startup_fh "$$\n"; - close($startup_fh); - warn "DEBUG start_collector: Wrote PID to startup lock"; - + # NOW check if collectors are already running (while holding startup lock) + my %existing_collectors; if (-f $lock_file) { - warn "DEBUG start_collector: Lock file exists: $lock_file"; + debug(__LINE__, "Lock file exists: $lock_file"); if (open(my $lock_fh, '<', $lock_file)) { - warn "DEBUG start_collector: Opened lock file for reading"; - my @pids; + debug(__LINE__, "Opened lock file for reading"); while (my $line = <$lock_fh>) { chomp $line; - push @pids, $line if $line =~ /^\d+$/; - } - close($lock_fh); - - warn "DEBUG start_collector: Found " . scalar(@pids) . " PIDs in lock file: " . join(", ", @pids); - - # Check if ANY collector is alive - my $collector_running = 0; - foreach my $pid (@pids) { - warn "DEBUG start_collector: Checking PID $pid"; - - if (is_process_alive($pid)) { - warn "DEBUG start_collector: Collector already running with PID $pid"; - $collector_running = 1; - last; + if ($line =~ /^(\d+)\s+(\S+)/) { + $existing_collectors{$2} = $1; + } elsif ($line =~ /^(\d+)$/) { + # Backward compatibility: only PID, no card + $existing_collectors{"unknown"} = $1; } } - - warn "DEBUG start_collector: Finished checking PIDs, collector_running = $collector_running"; - - if ($collector_running) { - warn "DEBUG start_collector: Releasing startup lock and returning - collector already running"; - unlink($startup_lock); - return; - } - - # All PIDs are dead, clean up stale lock - warn "DEBUG start_collector: All PIDs dead, removing stale lock file"; - unlink($lock_file); + close($lock_fh); } else { - warn "DEBUG start_collector: Failed to open lock file: $!"; + debug(__LINE__, "Failed to open lock file: $!"); } } else { - warn "DEBUG start_collector: Lock file does not exist"; + debug(__LINE__, "Lock file does not exist. Clean start"); } - # If we reach here, no collectors are running - start new ones - # Fork collector processes - my @child_pids; + # Generalized device collector management for future AMD/NVIDIA support + my @all_devices; + my @all_types; + my @all_collectors; + # Intel if ($intel_gpu_enabled) { - warn "DEBUG start_collector: Checking for intel_gpu_top"; + debug(__LINE__, "Checking for intel_gpu_top"); unless (-x '/usr/bin/intel_gpu_top') { - warn "DEBUG start_collector: intel_gpu_top not executable"; + debug(__LINE__, "intel_gpu_top not executable"); unlink($startup_lock); return; } - warn "DEBUG start_collector: intel_gpu_top is executable"; - - warn "DEBUG start_collector: Getting Intel GPU devices"; + debug(__LINE__, "intel_gpu_top is executable"); + debug(__LINE__, "Getting Intel GPU devices"); my @intel_devices = get_intel_gpu_devices(); - warn "DEBUG start_collector: Got " . scalar(@intel_devices) . " Intel devices"; - unless (@intel_devices) { - warn "DEBUG start_collector: No Intel GPU devices found"; + debug(__LINE__, "No Intel GPU devices found"); unlink($startup_lock); return; } - warn "DEBUG start_collector: Found " . scalar(@intel_devices) . " Intel GPU device(s)"; - - + debug(__LINE__, "Found " . scalar(@intel_devices) . " Intel GPU device(s)"); foreach my $device (@intel_devices) { - warn "DEBUG start_collector: About to fork collector for Intel $device->{card}"; - my $pid = fork(); - - unless (defined $pid) { - warn "DEBUG start_collector: fork failed: $!"; - unlink($startup_lock); - die "fork failed: $!"; - } - - if ($pid == 0) { - # Child process - warn "DEBUG start_collector: In child process for $device->{card}"; + push @all_devices, $device; + push @all_types, 'intel'; + } + } + # AMD (future) + if ($amd_gpu_enabled) { + my @amd_devices = get_amd_gpu_devices(); + debug(__LINE__, "Got " . scalar(@amd_devices) . " AMD devices"); + foreach my $device (@amd_devices) { + push @all_devices, $device; + push @all_types, 'amd'; + } + } + # NVIDIA (future) + if ($nvidia_gpu_enabled) { + my @nvidia_devices = get_nvidia_gpu_devices(); + debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); + foreach my $device (@nvidia_devices) { + push @all_devices, $device; + push @all_types, 'nvidia'; + } + } + + my @child_pids; + my @child_devices; + my @child_types; + for (my $i = 0; $i < @all_devices; $i++) { + my $device = $all_devices[$i]; + my $type = $all_types[$i]; + my $card = $device->{card}; + my $existing_pid = $existing_collectors{$card}; + if ($existing_pid && is_process_alive($existing_pid)) { + debug(__LINE__, "Collector for $type $card already running with PID $existing_pid"); + push @child_pids, $existing_pid; + push @child_devices, $device; + push @child_types, $type; + next; + } + debug(__LINE__, "About to fork collector for $type $card"); + my $pid = fork(); + unless (defined $pid) { + debug(__LINE__, "fork failed: $!"); + unlink($startup_lock); + die "fork failed: $!"; + } + if ($pid == 0) { + # Child process + debug(__LINE__, "In child process for $type $card"); + if ($type eq 'intel') { collector_for_intel_device($device); - # collector_for_intel_device calls exit(0) + } elsif ($type eq 'amd') { + collector_for_amd_device($device); + } elsif ($type eq 'nvidia') { + collector_for_nvidia_device($device); } else { - warn "DEBUG start_collector: Forked child PID $pid for $device->{card}"; - push @child_pids, $pid; + debug(__LINE__, "Unknown GPU type $type for $card"); + exit(1); } + # Should not reach here + exit(0); + } else { + debug(__LINE__, "Forked child PID $pid for $type $card"); + push @child_pids, $pid; + push @child_devices, $device; + push @child_types, $type; } + } + debug(__LINE__, "Active children: " . join(", ", @child_pids)); - warn "DEBUG start_collector: Forked " . scalar(@child_pids) . " children: " . join(", ", @child_pids); - } - - # Write child PIDs to lock file + # Write child PIDs and device cards/types to lock file if (open(my $lock_fh, '>', $lock_file)) { - warn "DEBUG start_collector: Opened lock file for writing"; - foreach my $pid (@child_pids) { - print $lock_fh "$pid\n"; + debug(__LINE__, "Opened lock file for writing"); + for (my $i = 0; $i < @child_pids; $i++) { + my $pid = $child_pids[$i]; + my $device = $child_devices[$i]; + my $type = $child_types[$i]; + my $card = $device->{card} // ''; + print $lock_fh "$pid $card $type\n"; } close($lock_fh); - warn "DEBUG start_collector: Wrote " . scalar(@child_pids) . " collector PID(s) to lock file"; + debug(__LINE__, "Wrote " . scalar(@child_pids) . " collector PID(s) to lock file"); } else { - warn "DEBUG start_collector: Failed to open lock file for writing: $!"; + debug(__LINE__, "Failed to open lock file for writing: $!"); foreach my $pid (@child_pids) { - warn "DEBUG start_collector: Killing child $pid due to lock file write failure"; + debug(__LINE__, "Killing child $pid due to lock file write failure"); kill 'TERM', $pid; } unlink($startup_lock); @@ -458,44 +453,36 @@ sub start_collector { # Wait briefly to ensure collector is actually running sleep 0.1; - - # Verify at least one child is still alive + + # Verify at least one child is still alive (by PID only) my $any_alive = 0; - foreach my $pid (@child_pids) { - if (kill(0, $pid)) { + for (my $i = 0; $i < @child_pids; $i++) { + my $pid = $child_pids[$i]; + my $device = $child_devices[$i]; + my $card = $device->{card} // ''; + my $alive = kill(0, $pid); + if ($alive) { $any_alive = 1; - warn "DEBUG start_collector: Verified child PID $pid is alive"; + debug(__LINE__, "Verified child PID $pid for $card is alive"); } else { - warn "DEBUG start_collector: WARNING - Child PID $pid died immediately!"; + debug(__LINE__, "WARNING - Child PID $pid for $card died immediately!"); } } - unless ($any_alive) { - warn "DEBUG start_collector: ERROR - No children alive after fork!"; + debug(__LINE__, "ERROR - No children alive after fork!"); } - - # Start pve_mod_monitor process - pve_mod_monitor(); - - - # Remove startup lock LAST - unlink($startup_lock); - warn "DEBUG start_collector: Released startup lock"; - - warn "DEBUG start_collector: Collectors started successfully, returning"; } -sub get_stats { - warn "DEBUG: get_stats() called"; +sub get_graphic_stats { + debug(__LINE__, "get_graphic_stats called"); - start_collector(); - - my $stats_dir = '/var/run/pve-gpu'; + # Start PVE Mod worker, if not already running + pve_mod_worker(); # Find all device-specific stat files my $dh; unless (opendir($dh, $stats_dir)) { - warn "DEBUG: Failed to open stats directory: $stats_dir: $!"; + debug(__LINE__, "Failed to open stats directory: $stats_dir: $!"); return $last_snapshot; } @@ -503,11 +490,11 @@ sub get_stats { closedir($dh); unless (@stat_files) { - warn "DEBUG: No device stat files found in $stats_dir"; + debug(__LINE__, "No device stat files found in $stats_dir"); return $last_snapshot; } - warn "DEBUG: Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files); + debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files)); # Check if any files have been modified my $newest_mtime = 0; @@ -522,11 +509,11 @@ sub get_stats { } if ($newest_mtime == $last_mtime) { - warn "DEBUG: No device files modified, returning cached snapshot"; + debug(__LINE__, "No device files modified, returning cached snapshot"); return $last_snapshot; } - warn "DEBUG: Device files modified ($last_mtime -> $newest_mtime), reading and merging files"; + debug(__LINE__, "Device files modified ($last_mtime -> $newest_mtime), reading and merging files"); # Merge all device files my $merged = { @@ -538,12 +525,12 @@ sub get_stats { foreach my $file (@stat_files) { my $filepath = "$stats_dir/$file"; - warn "DEBUG: Reading device file: $filepath"; + debug(__LINE__, "Reading device file: $filepath"); eval { my $fh; unless (open($fh, '<', $filepath)) { - warn "DEBUG: Failed to open $filepath: $!"; + debug(__LINE__, "Failed to open $filepath: $!"); return; } @@ -551,71 +538,145 @@ sub get_stats { my $json = <$fh>; close($fh); - warn "DEBUG: Read $file, JSON length: " . length($json) . " bytes"; + debug(__LINE__, "Read $file, JSON length: " . length($json) . " bytes"); my $device_data = decode_json($json); # Merge this device's data into the main structure foreach my $node_name (keys %$device_data) { $merged->{Graphics}->{Intel}->{$node_name} = $device_data->{$node_name}; - warn "DEBUG: Merged node '$node_name' from $file"; + debug(__LINE__, "Merged node '$node_name' from $file"); } }; if ($@) { - warn "DEBUG: Failed to read/parse $filepath: $@"; + debug(__LINE__, "Failed to read/parse $filepath: $@"); } } # Update cache $last_snapshot = $merged; $last_mtime = $newest_mtime; - $last_get_stats_time = time(); + $last_get_graphic_stats_time = time(); - warn "DEBUG: Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"; + debug(__LINE__, "Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"); return $last_snapshot; } -sub pve_mod_monitor { - return if $monitor_running; +sub pve_mod_starter { + # Try to acquire startup lock FIRST (prevents race conditions) + my $startup_fh; - $monitor_pid = fork(); - - if (!defined $monitor_pid) { - warn "ERROR: Failed to fork monitor process: $!"; - return; - } + debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); - if ($monitor_pid == 0) { - # Child process - $0 = "pve_mod_monitor"; # Set process name for easier identification + unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + # Startup lock exists - check if it's stale + debug(__LINE__, "Startup lock exists, checking if stale"); - while (1) { - if(time() - $last_get_stats_time > $COLLECTOR_TIMEOUT) { - warn "DEBUG pve_mod_monitor: No get_stats call in the last $COLLECTOR_TIMEOUT seconds, stopping collectors"; - stop_collectors(); - exit(0); + if (open(my $check_fh, '<', $startup_lock)) { + my $lock_pid = <$check_fh>; + chomp $lock_pid if defined $lock_pid; + close($check_fh); + + if (defined $lock_pid && $lock_pid =~ /^\d+$/) { + debug(__LINE__, "Startup lock held by PID $lock_pid"); + + if (is_process_alive($lock_pid)) { + debug(__LINE__, "Lock holder PID $lock_pid is still alive, waiting"); + } else { + # Lock holder is dead, remove stale lock + debug(__LINE__, "Lock holder PID $lock_pid is dead, removing stale startup lock"); + unlink($startup_lock); + + # Try to acquire lock again + unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + debug(__LINE__, "Failed to acquire startup lock on retry: $!"); + return; + } + debug(__LINE__, "Acquired startup lock after removing stale lock"); + } + } else { + # Invalid PID in lock file, remove it + debug(__LINE__, "Invalid PID in startup lock, removing"); + unlink($startup_lock); + + # Try to acquire lock again + unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + debug(__LINE__, "Failed to acquire startup lock on retry: $!"); + return; + } + debug(__LINE__, "Acquired startup lock after removing invalid lock"); } - sleep(1); - warn "DEBUG pve_mod_monitor: pve_mod_monitor still running"; + } else { + debug(__LINE__, "Could not read startup lock file: $!"); + return; } - - exit(0); # This shouldn't be reached, but just in case + } else { + debug(__LINE__, "Acquired startup lock on first try"); } - # Parent process - $monitor_running = 1; - warn "DEBUG start_monitor: Monitor process started with PID $monitor_pid"; + # We have the startup lock + print $startup_fh "$$\n"; + close($startup_fh); + debug(__LINE__, "Wrote PID, $$, to startup lock"); +} + +sub pve_mod_worker { + # Give the pid a unique name for easier identification + $0 = "pve_mod_worker"; + + # Ensure directory exists + my $run_dir = '/var/run/pve-gpu'; + unless (-d $run_dir) { + debug(__LINE__, "Creating directory $run_dir"); + mkdir($run_dir, 0755) or debug(__LINE__, "Failed to create $run_dir: $!"); + } + + # Acquire startup lock and start application + pve_mod_starter(); + + # Start graphics collectors + start_graphics_collectors(); + + # Start sensor collector + # TBD + + # Start UPS collector + # TBD + + # Start gui activity monitor process + pve_mod_keep_alive(); + + # Remove startup lock LAST + unlink($startup_lock); + debug(__LINE__, "Released startup lock"); + + debug(__LINE__, "Collectors started successfully, returning"); +} + +sub pve_mod_keep_alive { + debug(__LINE__, "Monitor process started with PID $$"); + while (1) { + if(time() - $last_get_graphic_stats_time > $COLLECTOR_TIMEOUT) { + debug(__LINE__, "No get_graphic_stats call in the last $COLLECTOR_TIMEOUT seconds, stopping collectors"); + stop_collectors(); + exit(0); + } + sleep(1); + debug(__LINE__, "pve_mod_keep_alive still running"); + } } sub cleanup { unless ($is_collector_parent) { - warn "DEBUG cleanup: This process did not start collectors, skipping cleanup"; + debug(__LINE__, "This process did not start collectors, skipping cleanup"); return; } - warn "DEBUG cleanup: Starting cleanup (this should rarely happen)"; + debug(__LINE__, "Starting cleanup (this should rarely happen)"); + # todo add remove of stat files + # DON'T cleanup automatically - collectors should keep running # across worker process lifecycles # Only cleanup if explicitly called @@ -626,7 +687,7 @@ sub cleanup { # Instead, add a manual cleanup function that can be called explicitly sub stop_collectors { - warn "DEBUG stop_collectors: Stopping all collectors"; + debug(__LINE__, "Stopping all collectors"); # Read current PIDs from lock file my @pids; @@ -640,7 +701,7 @@ sub stop_collectors { if (@pids) { # Send SIGTERM to all collectors - warn "DEBUG stop_collectors: Sending SIGTERM to " . scalar(@pids) . " process(es)"; + debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " process(es)"); foreach my $pid (@pids) { kill('TERM', $pid) if kill(0, $pid); } @@ -663,7 +724,7 @@ sub stop_collectors { # Force kill any survivors foreach my $pid (@pids) { if (kill(0, $pid)) { - warn "DEBUG stop_collectors: Force killing process $pid"; + debug(__LINE__, "Force killing process $pid"); kill('KILL', $pid); } } @@ -671,7 +732,7 @@ sub stop_collectors { unlink $state_file if -f $state_file; unlink $lock_file if -f $lock_file; - warn "DEBUG stop_collectors: Cleanup complete"; + debug(__LINE__, "Cleanup complete"); } END { cleanup() } From ea76402433b7cb61efce3acb2c929f6cae0050fa Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 3 Jan 2026 21:38:46 +0100 Subject: [PATCH 04/48] next snapshot --- GPUcollecter.pm | 497 ++++++++++++++++++++++++++++++------------------ 1 file changed, 307 insertions(+), 190 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index a8fc1af..4dcc12a 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -8,18 +8,18 @@ use Fcntl qw(:flock); use Time::HiRes qw(time); use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); -# Debug configuration - set to 0 to disable all debug output -my $DEBUG_ENABLED = 1; +# debug configuration - set to 0 to disable all _debug output +my $debug_ENABLED = 1; -# Debug function showing line number and call chain -# Usage: debug(__LINE__, "message") -sub debug { - return unless $DEBUG_ENABLED; +# debug function showing line number and call chain +# Usage: _debug(__LINE__, "message") +sub _debug { + return unless $debug_ENABLED; my ($line, $message) = @_; # Get function call chain - my @caller1 = caller(1); # who called debug() + my @caller1 = caller(1); # who called _debug() my @caller2 = caller(2); # parent of caller my $sub1 = $caller1[3] || 'main'; @@ -39,12 +39,13 @@ sub debug { my $stats_dir = '/var/run/pve-gpu'; my $state_file = '/var/run/pve-gpu/stats.json'; my $lock_file = '/var/run/pve-gpu/pve-gpu-collector.lock'; +my $monitor_lock = '/var/run/pve-gpu/pve-gpu-monitor.lock'; my $startup_lock = $lock_file . ".startup"; my $last_snapshot = {}; my $last_mtime = 0; my $is_collector_parent = 0; # Flag to track if this process started collectors my $last_get_graphic_stats_time = 0; # Track when get_graphic_stats was last called -my $COLLECTOR_TIMEOUT = 0; # Stop collectors x seconds after last get_graphic_stats call +my $COLLECTOR_TIMEOUT = 10; # Stop collectors x seconds after last get_graphic_stats call my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) @@ -57,7 +58,7 @@ my $monitor_running = 0; # ============================================================================ # Parse Intel GPU line output format -sub parse_intel_gpu_line { +sub _parse_intel_gpu_line { my ($line) = @_; # Expected format (with aligned columns): @@ -128,7 +129,7 @@ sub parse_intel_gpu_line { } # Get list of Intel GPU devices -sub get_intel_gpu_devices { +sub _get_intel_gpu_devices { my @devices = (); return @devices unless -x '/usr/bin/intel_gpu_top'; @@ -148,18 +149,18 @@ sub get_intel_gpu_devices { path => $path, drm_path => "/dev/dri/$card" }; - debug(__LINE__, "Found GPU device: $card -> $name ($path)"); + _debug(__LINE__, "Found GPU device: $card -> $name ($path)"); } } close $fh; } else { - debug(__LINE__, "Failed to run intel_gpu_top -L: $!"); + _debug(__LINE__, "Failed to run intel_gpu_top -L: $!"); } return @devices; } -sub collector_for_intel_device { +sub _collector_for_intel_device { my ($device) = @_; $0 = "pve-mod-gpu-intel-collector: $device->{card}"; @@ -169,31 +170,31 @@ sub collector_for_intel_device { # Each device writes to its own file my $device_state_file = "/var/run/pve-gpu/stats-$device->{card}.json"; - debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file"); + _debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file"); # Set up signal handlers for graceful shutdown my $shutdown = 0; $SIG{TERM} = sub { - debug(__LINE__, "Collector for $device->{card} received SIGTERM"); + _debug(__LINE__, "Collector for $device->{card} received SIGTERM"); $shutdown = 1; kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; }; $SIG{INT} = sub { - debug(__LINE__, "Collector for $device->{card} received SIGINT"); + _debug(__LINE__, "Collector for $device->{card} received SIGINT"); $shutdown = 1; kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; }; # Run intel_gpu_top once and keep reading from it - debug(__LINE__, "About to open pipe to intel_gpu_top"); + _debug(__LINE__, "About to open pipe to intel_gpu_top"); $intel_gpu_top_pid = open(my $fh, '-|', "intel_gpu_top -d $drm_dev -s 1000 -l 2>&1"); unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { - debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!"); + _debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!"); exit 1; } - debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid"); + _debug(__LINE__, "Pipe opened successfully, PID=$intel_gpu_top_pid"); my $line_count = 0; my $node_name = "node0"; # You may want to generate this based on device index @@ -209,7 +210,7 @@ sub collector_for_intel_device { # Check if this is a data line if ($line =~ /^\s*[\d\s\.]+$/) { - my $stats = parse_intel_gpu_line($line); + my $stats = _parse_intel_gpu_line($line); if ($stats) { # Build device-specific structure (just the node, not the full Graphics/Intel hierarchy) @@ -227,17 +228,17 @@ sub collector_for_intel_device { open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; print $ofh JSON->new->pretty->encode($device_data); close $ofh; - debug(__LINE__, "Wrote stats to $device_state_file (line #$line_count)"); + _debug(__LINE__, "Wrote stats to $device_state_file (line #$line_count)"); }; if ($@) { - debug(__LINE__, "Error writing stats: $@"); + _debug(__LINE__, "Error writing stats: $@"); } } } } close $fh; - debug(__LINE__, "Collector for $device->{card} shutting down"); + _debug(__LINE__, "Collector for $device->{card} shutting down"); exit 0; } @@ -245,25 +246,25 @@ sub collector_for_intel_device { # AMD GPU Support (Placeholder) # ============================================================================ -sub get_amd_gpu_devices { +sub _get_amd_gpu_devices { # TODO: Implement AMD GPU detection # Use rocminfo or similar tools to detect AMD GPUs - debug(__LINE__, "AMD GPU support not yet implemented"); + _debug(__LINE__, "AMD GPU support not yet implemented"); return (); } -sub parse_amd_gpu_line { +sub _parse_amd_gpu_line { my ($line) = @_; # TODO: Implement AMD GPU line parsing # Parse rocm-smi or similar output - debug(__LINE__, "AMD GPU line parsing not yet implemented"); + _debug(__LINE__, "AMD GPU line parsing not yet implemented"); return undef; } -sub collector_for_amd_device { +sub _collector_for_amd_device { my ($device) = @_; # TODO: Implement AMD GPU collector - debug(__LINE__, "AMD GPU collector not yet implemented"); + _debug(__LINE__, "AMD GPU collector not yet implemented"); exit 0; } @@ -274,7 +275,7 @@ sub collector_for_amd_device { sub get_nvidia_gpu_devices { # TODO: Implement NVIDIA GPU detection # Use nvidia-smi to detect NVIDIA GPUs - debug(__LINE__, "NVIDIA GPU support not yet implemented"); + _debug(__LINE__, "NVIDIA GPU support not yet implemented"); return (); } @@ -282,14 +283,14 @@ sub parse_nvidia_gpu_line { my ($line) = @_; # TODO: Implement NVIDIA GPU line parsing # Parse nvidia-smi output - debug(__LINE__, "NVIDIA GPU line parsing not yet implemented"); + _debug(__LINE__, "NVIDIA GPU line parsing not yet implemented"); return undef; } sub collector_for_nvidia_device { my ($device) = @_; # TODO: Implement NVIDIA GPU collector - debug(__LINE__, "NVIDIA GPU collector not yet implemented"); + _debug(__LINE__, "NVIDIA GPU collector not yet implemented"); exit 0; } @@ -298,28 +299,128 @@ sub collector_for_nvidia_device { # ============================================================================ # Check if a process is alive -sub is_process_alive { +sub _is_process_alive { my ($pid) = @_; return -d "/proc/$pid"; } +# ============================================================================ +# API calls +# ============================================================================ + +sub get_graphic_stats { + _debug(__LINE__, "get_graphic_stats called"); + + # Start PVE Mod worker, if not already running + _pve_mod_worker(); + + # Find all device-specific stat files + my $dh; + unless (opendir($dh, $stats_dir)) { + _debug(__LINE__, "Failed to open stats directory: $stats_dir: $!"); + return $last_snapshot; + } + + my @stat_files = grep { /^stats-card\d+\.json$/ } readdir($dh); + closedir($dh); + + unless (@stat_files) { + _debug(__LINE__, "No device stat files found in $stats_dir"); + return $last_snapshot; + } + + _debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files)); + + # Check if any files have been modified + my $newest_mtime = 0; + my $files_changed = 0; + + foreach my $file (@stat_files) { + my $filepath = "$stats_dir/$file"; + my @stat = stat($filepath); + if (@stat && $stat[9] > $newest_mtime) { + $newest_mtime = $stat[9]; + } + } + + if ($newest_mtime == $last_mtime) { + _debug(__LINE__, "No device files modified, returning cached snapshot"); + return $last_snapshot; + } + + _debug(__LINE__, "Device files modified ($last_mtime -> $newest_mtime), reading and merging files"); + + # Merge all device files + my $merged = { + Graphics => { + Intel => {} + } + }; + + foreach my $file (@stat_files) { + my $filepath = "$stats_dir/$file"; + + _debug(__LINE__, "Reading device file: $filepath"); + + eval { + my $fh; + unless (open($fh, '<', $filepath)) { + _debug(__LINE__, "Failed to open $filepath: $!"); + return; + } + + local $/; + my $json = <$fh>; + close($fh); + + _debug(__LINE__, "Read $file, JSON length: " . length($json) . " bytes"); + + my $device_data = decode_json($json); + + # Merge this device's data into the main structure + foreach my $node_name (keys %$device_data) { + $merged->{Graphics}->{Intel}->{$node_name} = $device_data->{$node_name}; + _debug(__LINE__, "Merged node '$node_name' from $file"); + } + }; + if ($@) { + _debug(__LINE__, "Failed to read/parse $filepath: $@"); + } + } + + # Update cache + $last_snapshot = $merged; + $last_mtime = $newest_mtime; + $last_get_graphic_stats_time = time(); + + _debug(__LINE__, "Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"); + + # Notify monitor of activity + _notify_monitor(); + + return $last_snapshot; +} + # ============================================================================ # Main Collector # ============================================================================ -sub start_graphics_collectors { +sub _start_graphics_collectors { if ($intel_gpu_enabled == 0 && $amd_gpu_enabled == 0 && $nvidia_gpu_enabled == 0) { - debug(__LINE__, "No GPU types enabled, skipping collector startup"); + _debug(__LINE__, "No GPU types enabled, skipping collector startup"); return; } + else { + _debug(__LINE__, "Starting graphics collectors"); + } # NOW check if collectors are already running (while holding startup lock) my %existing_collectors; if (-f $lock_file) { - debug(__LINE__, "Lock file exists: $lock_file"); + _debug(__LINE__, "Lock file exists: $lock_file"); if (open(my $lock_fh, '<', $lock_file)) { - debug(__LINE__, "Opened lock file for reading"); + _debug(__LINE__, "Opened lock file for reading"); while (my $line = <$lock_fh>) { chomp $line; if ($line =~ /^(\d+)\s+(\S+)/) { @@ -331,10 +432,10 @@ sub start_graphics_collectors { } close($lock_fh); } else { - debug(__LINE__, "Failed to open lock file: $!"); + _debug(__LINE__, "Failed to open lock file: $!"); } } else { - debug(__LINE__, "Lock file does not exist. Clean start"); + _debug(__LINE__, "Lock file does not exist. Clean start"); } # Generalized device collector management for future AMD/NVIDIA support @@ -344,21 +445,22 @@ sub start_graphics_collectors { # Intel if ($intel_gpu_enabled) { - debug(__LINE__, "Checking for intel_gpu_top"); + _debug(__LINE__, "Intel GPU support enabled"); + _debug(__LINE__, "Checking for intel_gpu_top"); unless (-x '/usr/bin/intel_gpu_top') { - debug(__LINE__, "intel_gpu_top not executable"); + _debug(__LINE__, "intel_gpu_top not executable"); unlink($startup_lock); return; } - debug(__LINE__, "intel_gpu_top is executable"); - debug(__LINE__, "Getting Intel GPU devices"); - my @intel_devices = get_intel_gpu_devices(); + _debug(__LINE__, "intel_gpu_top is executable"); + _debug(__LINE__, "Getting Intel GPU devices"); + my @intel_devices = _get_intel_gpu_devices(); unless (@intel_devices) { - debug(__LINE__, "No Intel GPU devices found"); + _debug(__LINE__, "No Intel GPU devices found"); unlink($startup_lock); return; } - debug(__LINE__, "Found " . scalar(@intel_devices) . " Intel GPU device(s)"); + _debug(__LINE__, "Found " . scalar(@intel_devices) . " Intel GPU device(s)"); foreach my $device (@intel_devices) { push @all_devices, $device; push @all_types, 'intel'; @@ -366,8 +468,8 @@ sub start_graphics_collectors { } # AMD (future) if ($amd_gpu_enabled) { - my @amd_devices = get_amd_gpu_devices(); - debug(__LINE__, "Got " . scalar(@amd_devices) . " AMD devices"); + my @amd_devices = _get_amd_gpu_devices(); + _debug(__LINE__, "Got " . scalar(@amd_devices) . " AMD devices"); foreach my $device (@amd_devices) { push @all_devices, $device; push @all_types, 'amd'; @@ -376,7 +478,7 @@ sub start_graphics_collectors { # NVIDIA (future) if ($nvidia_gpu_enabled) { my @nvidia_devices = get_nvidia_gpu_devices(); - debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); + _debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); foreach my $device (@nvidia_devices) { push @all_devices, $device; push @all_types, 'nvidia'; @@ -391,47 +493,47 @@ sub start_graphics_collectors { my $type = $all_types[$i]; my $card = $device->{card}; my $existing_pid = $existing_collectors{$card}; - if ($existing_pid && is_process_alive($existing_pid)) { - debug(__LINE__, "Collector for $type $card already running with PID $existing_pid"); + if ($existing_pid && _is_process_alive($existing_pid)) { + _debug(__LINE__, "Collector for $type $card already running with PID $existing_pid"); push @child_pids, $existing_pid; push @child_devices, $device; push @child_types, $type; next; } - debug(__LINE__, "About to fork collector for $type $card"); + _debug(__LINE__, "About to fork collector for $type $card"); my $pid = fork(); unless (defined $pid) { - debug(__LINE__, "fork failed: $!"); + _debug(__LINE__, "fork failed: $!"); unlink($startup_lock); die "fork failed: $!"; } if ($pid == 0) { # Child process - debug(__LINE__, "In child process for $type $card"); + _debug(__LINE__, "In child process for $type $card"); if ($type eq 'intel') { - collector_for_intel_device($device); + _collector_for_intel_device($device); } elsif ($type eq 'amd') { - collector_for_amd_device($device); + _collector_for_amd_device($device); } elsif ($type eq 'nvidia') { collector_for_nvidia_device($device); } else { - debug(__LINE__, "Unknown GPU type $type for $card"); + _debug(__LINE__, "Unknown GPU type $type for $card"); exit(1); } # Should not reach here exit(0); } else { - debug(__LINE__, "Forked child PID $pid for $type $card"); + _debug(__LINE__, "Forked child PID $pid for $type $card"); push @child_pids, $pid; push @child_devices, $device; push @child_types, $type; } } - debug(__LINE__, "Active children: " . join(", ", @child_pids)); + _debug(__LINE__, "Active children: " . join(", ", @child_pids)); # Write child PIDs and device cards/types to lock file if (open(my $lock_fh, '>', $lock_file)) { - debug(__LINE__, "Opened lock file for writing"); + _debug(__LINE__, "Opened lock file for writing"); for (my $i = 0; $i < @child_pids; $i++) { my $pid = $child_pids[$i]; my $device = $child_devices[$i]; @@ -440,11 +542,11 @@ sub start_graphics_collectors { print $lock_fh "$pid $card $type\n"; } close($lock_fh); - debug(__LINE__, "Wrote " . scalar(@child_pids) . " collector PID(s) to lock file"); + _debug(__LINE__, "Wrote " . scalar(@child_pids) . " collector PID(s) to lock file"); } else { - debug(__LINE__, "Failed to open lock file for writing: $!"); + _debug(__LINE__, "Failed to open lock file for writing: $!"); foreach my $pid (@child_pids) { - debug(__LINE__, "Killing child $pid due to lock file write failure"); + _debug(__LINE__, "Killing child $pid due to lock file write failure"); kill 'TERM', $pid; } unlink($startup_lock); @@ -463,115 +565,27 @@ sub start_graphics_collectors { my $alive = kill(0, $pid); if ($alive) { $any_alive = 1; - debug(__LINE__, "Verified child PID $pid for $card is alive"); + _debug(__LINE__, "Verified child PID $pid for $card is alive"); } else { - debug(__LINE__, "WARNING - Child PID $pid for $card died immediately!"); + _debug(__LINE__, "WARNING - Child PID $pid for $card died immediately!"); } } unless ($any_alive) { - debug(__LINE__, "ERROR - No children alive after fork!"); + _debug(__LINE__, "ERROR - No children alive after fork!"); } -} -sub get_graphic_stats { - debug(__LINE__, "get_graphic_stats called"); - - # Start PVE Mod worker, if not already running - pve_mod_worker(); - - # Find all device-specific stat files - my $dh; - unless (opendir($dh, $stats_dir)) { - debug(__LINE__, "Failed to open stats directory: $stats_dir: $!"); - return $last_snapshot; - } - - my @stat_files = grep { /^stats-card\d+\.json$/ } readdir($dh); - closedir($dh); - - unless (@stat_files) { - debug(__LINE__, "No device stat files found in $stats_dir"); - return $last_snapshot; - } - - debug(__LINE__, "Found " . scalar(@stat_files) . " device stat file(s): " . join(', ', @stat_files)); - - # Check if any files have been modified - my $newest_mtime = 0; - my $files_changed = 0; - - foreach my $file (@stat_files) { - my $filepath = "$stats_dir/$file"; - my @stat = stat($filepath); - if (@stat && $stat[9] > $newest_mtime) { - $newest_mtime = $stat[9]; - } - } - - if ($newest_mtime == $last_mtime) { - debug(__LINE__, "No device files modified, returning cached snapshot"); - return $last_snapshot; - } - - debug(__LINE__, "Device files modified ($last_mtime -> $newest_mtime), reading and merging files"); - - # Merge all device files - my $merged = { - Graphics => { - Intel => {} - } - }; - - foreach my $file (@stat_files) { - my $filepath = "$stats_dir/$file"; - - debug(__LINE__, "Reading device file: $filepath"); - - eval { - my $fh; - unless (open($fh, '<', $filepath)) { - debug(__LINE__, "Failed to open $filepath: $!"); - return; - } - - local $/; - my $json = <$fh>; - close($fh); - - debug(__LINE__, "Read $file, JSON length: " . length($json) . " bytes"); - - my $device_data = decode_json($json); - - # Merge this device's data into the main structure - foreach my $node_name (keys %$device_data) { - $merged->{Graphics}->{Intel}->{$node_name} = $device_data->{$node_name}; - debug(__LINE__, "Merged node '$node_name' from $file"); - } - }; - if ($@) { - debug(__LINE__, "Failed to read/parse $filepath: $@"); - } - } - - # Update cache - $last_snapshot = $merged; - $last_mtime = $newest_mtime; - $last_get_graphic_stats_time = time(); - - debug(__LINE__, "Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"); - - return $last_snapshot; + _debug(__LINE__, "Graphics collectors started"); } -sub pve_mod_starter { +sub _pve_mod_starter { # Try to acquire startup lock FIRST (prevents race conditions) my $startup_fh; - debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); + _debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { # Startup lock exists - check if it's stale - debug(__LINE__, "Startup lock exists, checking if stale"); + _debug(__LINE__, "Startup lock exists, checking if stale"); if (open(my $check_fh, '<', $startup_lock)) { my $lock_pid = <$check_fh>; @@ -579,64 +593,64 @@ sub pve_mod_starter { close($check_fh); if (defined $lock_pid && $lock_pid =~ /^\d+$/) { - debug(__LINE__, "Startup lock held by PID $lock_pid"); + _debug(__LINE__, "Startup lock held by PID $lock_pid"); - if (is_process_alive($lock_pid)) { - debug(__LINE__, "Lock holder PID $lock_pid is still alive, waiting"); + if (_is_process_alive($lock_pid)) { + _debug(__LINE__, "Lock holder PID $lock_pid is still alive, waiting"); } else { # Lock holder is dead, remove stale lock - debug(__LINE__, "Lock holder PID $lock_pid is dead, removing stale startup lock"); + _debug(__LINE__, "Lock holder PID $lock_pid is dead, removing stale startup lock"); unlink($startup_lock); # Try to acquire lock again unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - debug(__LINE__, "Failed to acquire startup lock on retry: $!"); + _debug(__LINE__, "Failed to acquire startup lock on retry: $!"); return; } - debug(__LINE__, "Acquired startup lock after removing stale lock"); + _debug(__LINE__, "Acquired startup lock after removing stale lock"); } } else { # Invalid PID in lock file, remove it - debug(__LINE__, "Invalid PID in startup lock, removing"); + _debug(__LINE__, "Invalid PID in startup lock, removing"); unlink($startup_lock); # Try to acquire lock again unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - debug(__LINE__, "Failed to acquire startup lock on retry: $!"); + _debug(__LINE__, "Failed to acquire startup lock on retry: $!"); return; } - debug(__LINE__, "Acquired startup lock after removing invalid lock"); + _debug(__LINE__, "Acquired startup lock after removing invalid lock"); } } else { - debug(__LINE__, "Could not read startup lock file: $!"); + _debug(__LINE__, "Could not read startup lock file: $!"); return; } } else { - debug(__LINE__, "Acquired startup lock on first try"); + _debug(__LINE__, "Acquired startup lock on first try"); } # We have the startup lock print $startup_fh "$$\n"; close($startup_fh); - debug(__LINE__, "Wrote PID, $$, to startup lock"); + _debug(__LINE__, "Wrote PID, $$, to startup lock"); } -sub pve_mod_worker { +sub _pve_mod_worker { # Give the pid a unique name for easier identification $0 = "pve_mod_worker"; # Ensure directory exists my $run_dir = '/var/run/pve-gpu'; unless (-d $run_dir) { - debug(__LINE__, "Creating directory $run_dir"); - mkdir($run_dir, 0755) or debug(__LINE__, "Failed to create $run_dir: $!"); + _debug(__LINE__, "Creating directory $run_dir"); + mkdir($run_dir, 0755) or _debug(__LINE__, "Failed to create $run_dir: $!"); } # Acquire startup lock and start application - pve_mod_starter(); + _pve_mod_starter(); # Start graphics collectors - start_graphics_collectors(); + _start_graphics_collectors(); # Start sensor collector # TBD @@ -644,36 +658,139 @@ sub pve_mod_worker { # Start UPS collector # TBD + _debug(__LINE__, "All collectors started"); + # Start gui activity monitor process - pve_mod_keep_alive(); + _start_monitor_process(); # Remove startup lock LAST unlink($startup_lock); - debug(__LINE__, "Released startup lock"); + _debug(__LINE__, "Released startup lock"); - debug(__LINE__, "Collectors started successfully, returning"); + _debug(__LINE__, "pve_mod_worker started successfully, returning"); } -sub pve_mod_keep_alive { - debug(__LINE__, "Monitor process started with PID $$"); +sub _start_monitor_process { + _debug(__LINE__, "_start_monitor_process called"); + + # Check if monitor is already running + if (-f $monitor_lock) { + _debug(__LINE__, "Monitor lock file exists, checking if monitor is alive"); + if (open my $fh, '<', $monitor_lock) { + my $pid = <$fh>; + close $fh; + chomp $pid if defined $pid; + + if ($pid && $pid =~ /^\d+$/ && _is_process_alive($pid)) { + _debug(__LINE__, "Monitor process already running with PID $pid"); + return; + } else { + _debug(__LINE__, "Stale monitor lock found (PID: " . ($pid // 'undefined') . "), removing"); + unlink($monitor_lock); + } + } + } + + _debug(__LINE__, "Forking new monitor process"); + my $monitor_pid = fork(); + # give the process a unique name + $0 = "pve-mod-monitor"; + + + unless (defined $monitor_pid) { + _debug(__LINE__, "Failed to fork monitor process: $!"); + return; + } + + if ($monitor_pid == 0) { + # Child process - run the monitor + _debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive"); + _pve_mod_keep_alive(); + exit(0); # Should never reach here + } else { + # Parent process - write PID to lock file + _debug(__LINE__, "Forked monitor process with PID $monitor_pid"); + + if (open my $fh, '>', $monitor_lock) { + print $fh "$monitor_pid\n"; + close $fh; + _debug(__LINE__, "Wrote monitor PID to lock file: $monitor_lock"); + } else { + _debug(__LINE__, "Failed to write monitor lock file: $!"); + kill('TERM', $monitor_pid); + } + } +} + +sub _notify_monitor { + _debug(__LINE__, "_notify_monitor called"); + unless (-f $monitor_lock) { + _debug(__LINE__, "Monitor lock file does not exist"); + return; + } + + _debug(__LINE__, "Monitor lock file exists, reading PID"); + if (open my $fh, '<', $monitor_lock) { + my $pid = <$fh>; + close $fh; + chomp $pid if defined $pid; + if ($pid && $pid =~ /^\d+$/ && _is_process_alive($pid)) { + _debug(__LINE__, "Sending USR1 signal to monitor PID $pid"); + kill('USR1', $pid); + } else { + # Stale lock, remove it + _debug(__LINE__, "Monitor lock is stale (PID: " . ($pid // 'undefined') . "), removing"); + unlink($monitor_lock); + } + } else { + _debug(__LINE__, "Failed to open monitor lock file: $!"); + } +} + +# In monitor process: +sub _pve_mod_keep_alive { + $0 = "pve-mod-gpu-monitor"; + _debug(__LINE__, "Monitor process started with PID $$"); + + my $last_activity = time(); + + # Set up signal handlers + $SIG{USR1} = sub { + $last_activity = time(); + _debug(__LINE__, "Activity ping received"); + }; + $SIG{TERM} = sub { + _debug(__LINE__, "Monitor received SIGTERM, shutting down"); + unlink($monitor_lock); + exit(0); + }; + $SIG{INT} = sub { + _debug(__LINE__, "Monitor received SIGINT, shutting down"); + unlink($monitor_lock); + exit(0); + }; + while (1) { - if(time() - $last_get_graphic_stats_time > $COLLECTOR_TIMEOUT) { - debug(__LINE__, "No get_graphic_stats call in the last $COLLECTOR_TIMEOUT seconds, stopping collectors"); + my $idle_time = time() - $last_activity; + + if ($idle_time > $COLLECTOR_TIMEOUT) { + _debug(__LINE__, "No activity for ${idle_time}s (timeout: ${COLLECTOR_TIMEOUT}s), stopping collectors"); stop_collectors(); + unlink($monitor_lock); exit(0); } - sleep(1); - debug(__LINE__, "pve_mod_keep_alive still running"); + + sleep(10); } } sub cleanup { unless ($is_collector_parent) { - debug(__LINE__, "This process did not start collectors, skipping cleanup"); + _debug(__LINE__, "This process did not start collectors, skipping cleanup"); return; } - debug(__LINE__, "Starting cleanup (this should rarely happen)"); + _debug(__LINE__, "Starting cleanup (this should rarely happen)"); # todo add remove of stat files @@ -687,7 +804,7 @@ sub cleanup { # Instead, add a manual cleanup function that can be called explicitly sub stop_collectors { - debug(__LINE__, "Stopping all collectors"); + _debug(__LINE__, "Stopping all collectors"); # Read current PIDs from lock file my @pids; @@ -701,7 +818,7 @@ sub stop_collectors { if (@pids) { # Send SIGTERM to all collectors - debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " process(es)"); + _debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " process(es)"); foreach my $pid (@pids) { kill('TERM', $pid) if kill(0, $pid); } @@ -724,7 +841,7 @@ sub stop_collectors { # Force kill any survivors foreach my $pid (@pids) { if (kill(0, $pid)) { - debug(__LINE__, "Force killing process $pid"); + _debug(__LINE__, "Force killing process $pid"); kill('KILL', $pid); } } @@ -732,7 +849,7 @@ sub stop_collectors { unlink $state_file if -f $state_file; unlink $lock_file if -f $lock_file; - debug(__LINE__, "Cleanup complete"); + _debug(__LINE__, "Cleanup complete"); } END { cleanup() } From e698160473ddd66bee17fb70c4efba50e7fa7a8c Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 3 Jan 2026 22:58:13 +0100 Subject: [PATCH 05/48] first working copy collecting script --- GPUcollecter.pm | 116 +++++++++++++++++++++++++++++------------------- 1 file changed, 70 insertions(+), 46 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 4dcc12a..3a77540 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -39,7 +39,7 @@ sub _debug { my $stats_dir = '/var/run/pve-gpu'; my $state_file = '/var/run/pve-gpu/stats.json'; my $lock_file = '/var/run/pve-gpu/pve-gpu-collector.lock'; -my $monitor_lock = '/var/run/pve-gpu/pve-gpu-monitor.lock'; +my $monitor_lock = '/var/run/pve-gpu/pve-mod-monitor.lock'; my $startup_lock = $lock_file . ".startup"; my $last_snapshot = {}; my $last_mtime = 0; @@ -309,6 +309,8 @@ sub _is_process_alive { # ============================================================================ sub get_graphic_stats { + # todo name the process without overruling other processes + _debug(__LINE__, "get_graphic_stats called"); # Start PVE Mod worker, if not already running @@ -636,9 +638,6 @@ sub _pve_mod_starter { } sub _pve_mod_worker { - # Give the pid a unique name for easier identification - $0 = "pve_mod_worker"; - # Ensure directory exists my $run_dir = '/var/run/pve-gpu'; unless (-d $run_dir) { @@ -646,6 +645,27 @@ sub _pve_mod_worker { mkdir($run_dir, 0755) or _debug(__LINE__, "Failed to create $run_dir: $!"); } + # Check if monitor is already running + if (-f $monitor_lock) { + _debug(__LINE__, "Monitor lock file exists, checking if monitor is alive"); + if (open my $fh, '<', $monitor_lock) { + my $pid = <$fh>; + close $fh; + chomp $pid if defined $pid; + + if ($pid && $pid =~ /^\d+$/ && _is_process_alive($pid)) { + _debug(__LINE__, "Monitor process already running with PID $pid, don't start another pve_mod_worker"); + return; + } else { + _debug(__LINE__, "Stale monitor lock found (PID: " . ($pid // 'undefined') . "), removing"); + unlink $monitor_lock; + } + } + + } else { + _debug(__LINE__, "PVE mod worker is not running. It can be started."); + } + # Acquire startup lock and start application _pve_mod_starter(); @@ -661,7 +681,7 @@ sub _pve_mod_worker { _debug(__LINE__, "All collectors started"); # Start gui activity monitor process - _start_monitor_process(); + _pve_mod_monitor(); # Remove startup lock LAST unlink($startup_lock); @@ -670,8 +690,12 @@ sub _pve_mod_worker { _debug(__LINE__, "pve_mod_worker started successfully, returning"); } -sub _start_monitor_process { - _debug(__LINE__, "_start_monitor_process called"); +# ============================================================================ +# Monitor Process +# ============================================================================ + +sub _pve_mod_monitor { + _debug(__LINE__, "_pve_mod_monitor called"); # Check if monitor is already running if (-f $monitor_lock) { @@ -693,9 +717,6 @@ sub _start_monitor_process { _debug(__LINE__, "Forking new monitor process"); my $monitor_pid = fork(); - # give the process a unique name - $0 = "pve-mod-monitor"; - unless (defined $monitor_pid) { _debug(__LINE__, "Failed to fork monitor process: $!"); @@ -720,6 +741,7 @@ sub _start_monitor_process { kill('TERM', $monitor_pid); } } + _debug(__LINE__, "Monitor process started successfully"); } sub _notify_monitor { @@ -734,9 +756,18 @@ sub _notify_monitor { my $pid = <$fh>; close $fh; chomp $pid if defined $pid; - if ($pid && $pid =~ /^\d+$/ && _is_process_alive($pid)) { - _debug(__LINE__, "Sending USR1 signal to monitor PID $pid"); - kill('USR1', $pid); + if ($pid && $pid =~ /^(\d+)$/) { + # Untaint by capturing in regex - $1 is now untainted + my $clean_pid = $1; + + if (_is_process_alive($clean_pid)) { + _debug(__LINE__, "Sending USR1 signal to monitor PID $clean_pid"); + my $result = kill('USR1', $clean_pid); + _debug(__LINE__, "Signal result: $result"); + } else { + _debug(__LINE__, "Monitor process $clean_pid is not alive, removing stale lock"); + unlink($monitor_lock); + } } else { # Stale lock, remove it _debug(__LINE__, "Monitor lock is stale (PID: " . ($pid // 'undefined') . "), removing"); @@ -749,7 +780,6 @@ sub _notify_monitor { # In monitor process: sub _pve_mod_keep_alive { - $0 = "pve-mod-gpu-monitor"; _debug(__LINE__, "Monitor process started with PID $$"); my $last_activity = time(); @@ -761,49 +791,39 @@ sub _pve_mod_keep_alive { }; $SIG{TERM} = sub { _debug(__LINE__, "Monitor received SIGTERM, shutting down"); - unlink($monitor_lock); + unlink($monitor_lock) if -f $monitor_lock; exit(0); }; $SIG{INT} = sub { _debug(__LINE__, "Monitor received SIGINT, shutting down"); - unlink($monitor_lock); + unlink($monitor_lock) if -f $monitor_lock; exit(0); }; + _debug(__LINE__, "Entering monitor loop, timeout=${COLLECTOR_TIMEOUT}s"); + while (1) { + _debug(__LINE__, "Monitor loop start: checking activity"); + my $idle_time = time() - $last_activity; + _debug(__LINE__, "Monitor loop: idle_time=${idle_time}s, timeout=${COLLECTOR_TIMEOUT}s"); + if ($idle_time > $COLLECTOR_TIMEOUT) { - _debug(__LINE__, "No activity for ${idle_time}s (timeout: ${COLLECTOR_TIMEOUT}s), stopping collectors"); - stop_collectors(); - unlink($monitor_lock); + _debug(__LINE__, "Timeout reached, stopping collectors"); + _stop_collectors(); + _debug(__LINE__, "Collectors stopped, exiting monitor"); + unlink($monitor_lock) if -f $monitor_lock; exit(0); } - - sleep(10); - } -} - -sub cleanup { - unless ($is_collector_parent) { - _debug(__LINE__, "This process did not start collectors, skipping cleanup"); - return; + sleep(1); } - _debug(__LINE__, "Starting cleanup (this should rarely happen)"); - - # todo add remove of stat files - - # DON'T cleanup automatically - collectors should keep running - # across worker process lifecycles - # Only cleanup if explicitly called + # Should never reach here + _debug(__LINE__, "Monitor loop exited unexpectedly!"); } -# Remove the END block that automatically calls cleanup -# END { cleanup() } - -# Instead, add a manual cleanup function that can be called explicitly -sub stop_collectors { +sub _stop_collectors { _debug(__LINE__, "Stopping all collectors"); # Read current PIDs from lock file @@ -811,14 +831,16 @@ sub stop_collectors { if (open my $lock_fh, '<', $lock_file) { while (my $line = <$lock_fh>) { chomp $line; - push @pids, $line if $line =~ /^\d+$/; + # Extract PID from "PID card type" format + if ($line =~ /^(\d+)/) { + push @pids, $1; + } } close($lock_fh); } if (@pids) { - # Send SIGTERM to all collectors - _debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " process(es)"); + _debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)"); foreach my $pid (@pids) { kill('TERM', $pid) if kill(0, $pid); } @@ -835,23 +857,25 @@ sub stop_collectors { } } last unless $any_alive; - select(undef, undef, undef, 0.1); # sleep 0.1s + select(undef, undef, undef, 0.1); } # Force kill any survivors foreach my $pid (@pids) { if (kill(0, $pid)) { - _debug(__LINE__, "Force killing process $pid"); + _debug(__LINE__, "Force killing collector process $pid"); kill('KILL', $pid); } } } + # Clean up files unlink $state_file if -f $state_file; unlink $lock_file if -f $lock_file; + _debug(__LINE__, "Cleanup complete"); } -END { cleanup() } +END { _stop_collectors() } 1; From d6a6257003cb5dfa3df730efc17f8b71b43e6fda Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 4 Jan 2026 14:31:52 +0100 Subject: [PATCH 06/48] Another udpate --- GPUcollecter.pm | 356 ++++++++++++++++++++++++++---------------------- 1 file changed, 196 insertions(+), 160 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 3a77540..c8bebd4 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -7,9 +7,11 @@ use POSIX qw(WNOHANG); use Fcntl qw(:flock); use Time::HiRes qw(time); use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); +use File::Path qw(remove_tree); # debug configuration - set to 0 to disable all _debug output my $debug_ENABLED = 1; +my $VERSION = '1.0.0'; # debug function showing line number and call chain # Usage: _debug(__LINE__, "message") @@ -36,10 +38,11 @@ sub _debug { } } -my $stats_dir = '/var/run/pve-gpu'; -my $state_file = '/var/run/pve-gpu/stats.json'; -my $lock_file = '/var/run/pve-gpu/pve-gpu-collector.lock'; -my $monitor_lock = '/var/run/pve-gpu/pve-mod-monitor.lock'; +my $pve_mod_working_dir = '/run/pveproxy/pve-mod'; +my $stats_dir = $pve_mod_working_dir; +my $state_file = "$pve_mod_working_dir/stats.json"; +my $lock_file = "$pve_mod_working_dir/pve-mod-worker.lock"; +my $pve_mod_worker_lock = "$pve_mod_working_dir/pve-mod-pve_mod_worker.lock"; my $startup_lock = $lock_file . ".startup"; my $last_snapshot = {}; my $last_mtime = 0; @@ -50,8 +53,8 @@ my $COLLECTOR_TIMEOUT = 10; # Stop collectors x seconds after last get_graphic my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) my $nvidia_gpu_enabled = 0; # Set to 1 to enable NVIDIA GPU support (not yet implemented) -my $monitor_pid; -my $monitor_running = 0; +my $pve_mod_worker_pid; +my $pve_mod_worker_running = 0; # ============================================================================ # Intel GPU Support @@ -168,7 +171,7 @@ sub _collector_for_intel_device { my $intel_gpu_top_pid = undef; # Each device writes to its own file - my $device_state_file = "/var/run/pve-gpu/stats-$device->{card}.json"; + my $device_state_file = "$pve_mod_working_dir/stats-$device->{card}.json"; _debug(__LINE__, "Collector started for device: $drm_dev, writing to $device_state_file"); @@ -304,17 +307,105 @@ sub _is_process_alive { return -d "/proc/$pid"; } +sub _read_lock_pid { + my ($lock_path) = @_; + + return undef unless open(my $fh, '<', $lock_path); + + my $pid = <$fh>; + close($fh); + chomp $pid if defined $pid; + + return $pid; +} + +sub _acquire_exclusive_lock { + my ($lock_path, $purpose) = @_; + $purpose //= 'lock'; + + my $fh; + + # Try to create lock file exclusively + if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + _debug(__LINE__, "Acquired $purpose on first try"); + return $fh; + } + + # Lock file creation failed - check if it's stale or held by another process + _debug(__LINE__, ucfirst($purpose) . " exists, checking if stale"); + + my $lock_pid = _read_lock_pid($lock_path); + + if (!defined $lock_pid) { + _debug(__LINE__, "Could not read $purpose file: $!"); + return undef; + } + + if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) { + _debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing"); + unlink($lock_path); + } elsif (_is_process_alive($lock_pid)) { + _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive"); + return undef; + } else { + _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock"); + unlink($lock_path); + } + + # Try to acquire lock again after cleanup + unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + _debug(__LINE__, "Failed to acquire $purpose on retry: $!"); + return undef; + } + + _debug(__LINE__, "Acquired $purpose after removing stale lock"); + return $fh; +} + +sub _is_lock_stale { + my ($lock_path) = @_; + + return 0 unless open(my $fh, '<', $lock_path); + + my $lock_pid = <$fh>; + chomp $lock_pid if defined $lock_pid; + close($fh); + + # Invalid or missing PID + return 1 unless defined $lock_pid && $lock_pid =~ /^\d+$/; + + # Valid PID but process is dead + return !_is_process_alive($lock_pid); +} + +sub _ensure_pve_mod_directory_exists { + unless (-d $pve_mod_working_dir) { + _debug(__LINE__, "Creating directory $pve_mod_working_dir"); + unless (mkdir($pve_mod_working_dir, 0755)) { + _debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start."); + die "Failed to create $pve_mod_working_dir: $!"; + } + _debug(__LINE__, "Directory $pve_mod_working_dir created"); + } else { + _debug(__LINE__, "Directory $pve_mod_working_dir already exists"); + } +} + +sub _pve_mod_hello { + _debug(__LINE__, "PVE Mod is being started. Version $VERSION"); +} + # ============================================================================ # API calls # ============================================================================ sub get_graphic_stats { # todo name the process without overruling other processes - _debug(__LINE__, "get_graphic_stats called"); + _pve_mod_hello(); - # Start PVE Mod worker, if not already running - _pve_mod_worker(); + # Start PVE Mod + _pve_mod_starter(); # Find all device-specific stat files my $dh; @@ -397,8 +488,8 @@ sub get_graphic_stats { _debug(__LINE__, "Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"); - # Notify monitor of activity - _notify_monitor(); + # Notify pve_mod_worker of activity + _notify_pve_mod_worker(); return $last_snapshot; } @@ -423,6 +514,7 @@ sub _start_graphics_collectors { _debug(__LINE__, "Lock file exists: $lock_file"); if (open(my $lock_fh, '<', $lock_file)) { _debug(__LINE__, "Opened lock file for reading"); + flock($lock_fh, LOCK_EX) or _debug(__LINE__, "Failed to lock $lock_file: $!"); while (my $line = <$lock_fh>) { chomp $line; if ($line =~ /^(\d+)\s+(\S+)/) { @@ -543,6 +635,7 @@ sub _start_graphics_collectors { my $card = $device->{card} // ''; print $lock_fh "$pid $card $type\n"; } + flock($lock_fh, LOCK_UN); close($lock_fh); _debug(__LINE__, "Wrote " . scalar(@child_pids) . " collector PID(s) to lock file"); } else { @@ -579,95 +672,30 @@ sub _start_graphics_collectors { _debug(__LINE__, "Graphics collectors started"); } +# ============================================================================ +# PVE Mod Worker +# ============================================================================ sub _pve_mod_starter { + # Check if pve_mod_worker is already running - if so, entire system is already up + _debug(__LINE__, "Checking if pve_mod_worker is already running"); + if (_is_pve_mod_worker_running()) { + _debug(__LINE__, "pve_mod_worker process already running, system is already started"); + return "pve_mod_worker process already running, system is already started"; + } + _debug(__LINE__, "PVE mod worker is not running. PVE Mod will be started."); + + # Ensure directory exists + _ensure_pve_mod_directory_exists(); + # Try to acquire startup lock FIRST (prevents race conditions) - my $startup_fh; - _debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); - - unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - # Startup lock exists - check if it's stale - _debug(__LINE__, "Startup lock exists, checking if stale"); - - if (open(my $check_fh, '<', $startup_lock)) { - my $lock_pid = <$check_fh>; - chomp $lock_pid if defined $lock_pid; - close($check_fh); - - if (defined $lock_pid && $lock_pid =~ /^\d+$/) { - _debug(__LINE__, "Startup lock held by PID $lock_pid"); - - if (_is_process_alive($lock_pid)) { - _debug(__LINE__, "Lock holder PID $lock_pid is still alive, waiting"); - } else { - # Lock holder is dead, remove stale lock - _debug(__LINE__, "Lock holder PID $lock_pid is dead, removing stale startup lock"); - unlink($startup_lock); - - # Try to acquire lock again - unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - _debug(__LINE__, "Failed to acquire startup lock on retry: $!"); - return; - } - _debug(__LINE__, "Acquired startup lock after removing stale lock"); - } - } else { - # Invalid PID in lock file, remove it - _debug(__LINE__, "Invalid PID in startup lock, removing"); - unlink($startup_lock); - - # Try to acquire lock again - unless (sysopen($startup_fh, $startup_lock, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - _debug(__LINE__, "Failed to acquire startup lock on retry: $!"); - return; - } - _debug(__LINE__, "Acquired startup lock after removing invalid lock"); - } - } else { - _debug(__LINE__, "Could not read startup lock file: $!"); - return; - } - } else { - _debug(__LINE__, "Acquired startup lock on first try"); - } - - # We have the startup lock + my $startup_fh = _acquire_exclusive_lock($startup_lock, 'startup lock'); + return unless $startup_fh; print $startup_fh "$$\n"; close($startup_fh); _debug(__LINE__, "Wrote PID, $$, to startup lock"); -} - -sub _pve_mod_worker { - # Ensure directory exists - my $run_dir = '/var/run/pve-gpu'; - unless (-d $run_dir) { - _debug(__LINE__, "Creating directory $run_dir"); - mkdir($run_dir, 0755) or _debug(__LINE__, "Failed to create $run_dir: $!"); - } - # Check if monitor is already running - if (-f $monitor_lock) { - _debug(__LINE__, "Monitor lock file exists, checking if monitor is alive"); - if (open my $fh, '<', $monitor_lock) { - my $pid = <$fh>; - close $fh; - chomp $pid if defined $pid; - - if ($pid && $pid =~ /^\d+$/ && _is_process_alive($pid)) { - _debug(__LINE__, "Monitor process already running with PID $pid, don't start another pve_mod_worker"); - return; - } else { - _debug(__LINE__, "Stale monitor lock found (PID: " . ($pid // 'undefined') . "), removing"); - unlink $monitor_lock; - } - } - - } else { - _debug(__LINE__, "PVE mod worker is not running. It can be started."); - } - - # Acquire startup lock and start application - _pve_mod_starter(); + # Start graphics collectors _start_graphics_collectors(); @@ -680,8 +708,8 @@ sub _pve_mod_worker { _debug(__LINE__, "All collectors started"); - # Start gui activity monitor process - _pve_mod_monitor(); + # Start pve mod workers + _pve_mod_worker(); # Remove startup lock LAST unlink($startup_lock); @@ -691,96 +719,83 @@ sub _pve_mod_worker { } # ============================================================================ -# Monitor Process +# PVE Mod Worker # ============================================================================ -sub _pve_mod_monitor { - _debug(__LINE__, "_pve_mod_monitor called"); +sub _pve_mod_worker { + _debug(__LINE__, "_pve_mod_worker called"); - # Check if monitor is already running - if (-f $monitor_lock) { - _debug(__LINE__, "Monitor lock file exists, checking if monitor is alive"); - if (open my $fh, '<', $monitor_lock) { - my $pid = <$fh>; - close $fh; - chomp $pid if defined $pid; - - if ($pid && $pid =~ /^\d+$/ && _is_process_alive($pid)) { - _debug(__LINE__, "Monitor process already running with PID $pid"); - return; - } else { - _debug(__LINE__, "Stale monitor lock found (PID: " . ($pid // 'undefined') . "), removing"); - unlink($monitor_lock); - } - } - } + # Check if worker is already running + my $pve_mod_worker_fh = _acquire_exclusive_lock($pve_mod_worker_lock, 'pve_mod_worker lock'); + return unless $pve_mod_worker_fh; + print $pve_mod_worker_fh "$$\n"; + close($pve_mod_worker_fh); - _debug(__LINE__, "Forking new monitor process"); - my $monitor_pid = fork(); + _debug(__LINE__, "Forking new pve_mod_worker process"); + my $pve_mod_worker_pid = fork(); - unless (defined $monitor_pid) { - _debug(__LINE__, "Failed to fork monitor process: $!"); + unless (defined $pve_mod_worker_pid) { + _debug(__LINE__, "Failed to fork pve_mod_worker process: $!"); return; } - if ($monitor_pid == 0) { - # Child process - run the monitor + if ($pve_mod_worker_pid == 0) { + # Child process - run the pve_mod_worker _debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive"); _pve_mod_keep_alive(); exit(0); # Should never reach here } else { # Parent process - write PID to lock file - _debug(__LINE__, "Forked monitor process with PID $monitor_pid"); + _debug(__LINE__, "Forked pve_mod_worker process with PID $pve_mod_worker_pid"); - if (open my $fh, '>', $monitor_lock) { - print $fh "$monitor_pid\n"; + if (open my $fh, '>', $pve_mod_worker_lock) { + print $fh "$pve_mod_worker_pid\n"; close $fh; - _debug(__LINE__, "Wrote monitor PID to lock file: $monitor_lock"); + _debug(__LINE__, "Wrote pve_mod_worker PID to lock file: $pve_mod_worker_lock"); } else { - _debug(__LINE__, "Failed to write monitor lock file: $!"); - kill('TERM', $monitor_pid); + _debug(__LINE__, "Failed to write pve_mod_worker lock file: $!"); + kill('TERM', $pve_mod_worker_pid); } } - _debug(__LINE__, "Monitor process started successfully"); + _debug(__LINE__, "pve_mod_worker process started successfully"); } -sub _notify_monitor { - _debug(__LINE__, "_notify_monitor called"); - unless (-f $monitor_lock) { - _debug(__LINE__, "Monitor lock file does not exist"); +sub _notify_pve_mod_worker { + _debug(__LINE__, "_notify_pve_mod_worker called"); + unless (-f $pve_mod_worker_lock) { + _debug(__LINE__, "pve_mod_worker lock file does not exist"); return; } - _debug(__LINE__, "Monitor lock file exists, reading PID"); - if (open my $fh, '<', $monitor_lock) { + _debug(__LINE__, "pve_mod_worker lock file exists, reading PID"); + if (open my $fh, '<', $pve_mod_worker_lock) { my $pid = <$fh>; close $fh; chomp $pid if defined $pid; - if ($pid && $pid =~ /^(\d+)$/) { + if (defined $pid && $pid =~ /^(\d+)$/) { # Untaint by capturing in regex - $1 is now untainted my $clean_pid = $1; if (_is_process_alive($clean_pid)) { - _debug(__LINE__, "Sending USR1 signal to monitor PID $clean_pid"); + _debug(__LINE__, "Sending USR1 signal to pve_mod_worker PID $clean_pid"); my $result = kill('USR1', $clean_pid); _debug(__LINE__, "Signal result: $result"); } else { - _debug(__LINE__, "Monitor process $clean_pid is not alive, removing stale lock"); - unlink($monitor_lock); + _debug(__LINE__, "pve_mod_worker process $clean_pid is not alive, removing stale lock"); + unlink($pve_mod_worker_lock); } } else { # Stale lock, remove it - _debug(__LINE__, "Monitor lock is stale (PID: " . ($pid // 'undefined') . "), removing"); - unlink($monitor_lock); + _debug(__LINE__, "pve_mod_worker lock is stale (PID: " . ($pid // 'undefined') . "), removing"); + unlink($pve_mod_worker_lock); } } else { - _debug(__LINE__, "Failed to open monitor lock file: $!"); + _debug(__LINE__, "Failed to open pve_mod_worker lock file: $!"); } } -# In monitor process: sub _pve_mod_keep_alive { - _debug(__LINE__, "Monitor process started with PID $$"); + _debug(__LINE__, "pve_mod_worker process started with PID $$"); my $last_activity = time(); @@ -790,39 +805,47 @@ sub _pve_mod_keep_alive { _debug(__LINE__, "Activity ping received"); }; $SIG{TERM} = sub { - _debug(__LINE__, "Monitor received SIGTERM, shutting down"); - unlink($monitor_lock) if -f $monitor_lock; + _debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down"); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); }; $SIG{INT} = sub { - _debug(__LINE__, "Monitor received SIGINT, shutting down"); - unlink($monitor_lock) if -f $monitor_lock; + _debug(__LINE__, "pve_mod_worker received SIGINT, shutting down"); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); }; - _debug(__LINE__, "Entering monitor loop, timeout=${COLLECTOR_TIMEOUT}s"); + _debug(__LINE__, "Entering pve_mod_worker loop, timeout=${COLLECTOR_TIMEOUT}s"); while (1) { - _debug(__LINE__, "Monitor loop start: checking activity"); + _debug(__LINE__, "pve_mod_worker loop start: checking activity"); my $idle_time = time() - $last_activity; - _debug(__LINE__, "Monitor loop: idle_time=${idle_time}s, timeout=${COLLECTOR_TIMEOUT}s"); + _debug(__LINE__, "pve_mod_worker loop: idle_time=${idle_time}s, timeout=${COLLECTOR_TIMEOUT}s"); if ($idle_time > $COLLECTOR_TIMEOUT) { _debug(__LINE__, "Timeout reached, stopping collectors"); _stop_collectors(); - _debug(__LINE__, "Collectors stopped, exiting monitor"); - unlink($monitor_lock) if -f $monitor_lock; + _debug(__LINE__, "Collectors stopped, exiting pve_mod_worker"); + unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); } sleep(1); } # Should never reach here - _debug(__LINE__, "Monitor loop exited unexpectedly!"); + _debug(__LINE__, "pve_mod_worker loop exited unexpectedly!"); +} + +sub _is_pve_mod_worker_running { + return -f $pve_mod_worker_lock; } +# ============================================================================ +# Other +# ============================================================================ + sub _stop_collectors { _debug(__LINE__, "Stopping all collectors"); @@ -838,13 +861,16 @@ sub _stop_collectors { } close($lock_fh); } - + _debug(__LINE__, "Cleanup complete_1"); + if (@pids) { _debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)"); foreach my $pid (@pids) { kill('TERM', $pid) if kill(0, $pid); } + _debug(__LINE__, "Cleanup complete_2"); + # Wait up to 5 seconds for graceful shutdown my $timeout = 5; my $start = time(); @@ -859,7 +885,7 @@ sub _stop_collectors { last unless $any_alive; select(undef, undef, undef, 0.1); } - + _debug(__LINE__, "Cleanup complete_3"); # Force kill any survivors foreach my $pid (@pids) { if (kill(0, $pid)) { @@ -867,13 +893,23 @@ sub _stop_collectors { kill('KILL', $pid); } } + _debug(__LINE__, "Cleanup complete_4"); } - # Clean up files - unlink $state_file if -f $state_file; - unlink $lock_file if -f $lock_file; + if (-f $state_file) { + unlink $state_file or _debug(__LINE__, "Failed to remove $state_file: $!"); + } + if (-f $lock_file) { + unlink $lock_file or _debug(__LINE__, "Failed to remove $lock_file: $!"); + } - _debug(__LINE__, "Cleanup complete"); + # Remove pve mod worker directory and all files if it exists + if (-d $pve_mod_working_dir) { + remove_tree($pve_mod_working_dir, { error => \my $err }); + _debug(__LINE__, "Cleanup errors: @$err") if @$err; + } + + _debug(__LINE__, "Cleanup complete_5"); } END { _stop_collectors() } From 4020402e7babfd4f94b05d2f8e9c2d09b981a946 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 4 Jan 2026 15:25:06 +0100 Subject: [PATCH 07/48] change gpu name --- GPUcollecter.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index c8bebd4..9c8cc1e 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -219,7 +219,7 @@ sub _collector_for_intel_device { # Build device-specific structure (just the node, not the full Graphics/Intel hierarchy) my $device_data = { $node_name => { - gpu_name => $device->{name}, + name => $device->{name}, device_path => $device->{path}, drm_path => $device->{drm_path}, stats => $stats From e66125954a8f84b99090bec811bc7d632dd59cc2 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 4 Jan 2026 15:37:59 +0100 Subject: [PATCH 08/48] first step to update bash script --- pve-mod-gui-sensors.sh | 97 +++++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 69d2824..0852290 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -228,6 +228,7 @@ function configure { fi done ENABLE_INTEL_GPU_INFO=true + ENABLE_GPU_INFO=true else warn "No Intel GPUs detected by intel_gpu_top." ENABLE_INTEL_GPU_INFO=false @@ -459,6 +460,7 @@ function install_mod { generate_and_insert_widget "$ENABLE_SYSTEM_INFO" "generate_system_info" "system_info" generate_and_insert_widget "$ENABLE_UPS" "generate_ups_widget" "ups" + generate_and_insert_widget "$ENABLE_GPU_INFO" "generate_gpu_widget" "gpu" generate_and_insert_widget "$ENABLE_HDD_TEMP" "generate_hdd_widget" "hdd" generate_and_insert_widget "$ENABLE_NVME_TEMP" "generate_nvme_widget" "nvme" @@ -602,22 +604,6 @@ collect_ups_output() { } collect_graphics_intel_output() { - local output_file="$1" - local intelCmd - - -use threads; -use JSON; - -my $snapshot; - -threads->create(sub { - while (1) { - my $json = `intel_gpu_top -J -s 1`; - $snapshot = decode_json($json); - sleep 1; # optional - } -}); } @@ -772,6 +758,8 @@ add_visual_separator() { if [ "$ENABLE_UPS" = true ]; then lastItemId="upsc" + elif [ "$ENABLE_GPU" = true ]; then + lastItemId="gpuInfo" elif [ "$ENABLE_HDD_TEMP" = true ]; then lastItemId="thermalHdd" elif [ "$ENABLE_NVME_TEMP" = true ]; then @@ -1629,6 +1617,83 @@ EOF fi } +# Function to generate GPU widget +generate_gpu_widget() { + #region gpu widget heredoc + # use subshell to allow variable expansion + ( + export CPU_ITEMS_PER_ROW + export CPU_TEMP_TARGET + export HELPERCTORPARAMS + + cat <<'EOF' | envsubst '$CPU_ITEMS_PER_ROW $CPU_TEMP_TARGET $HELPERCTORPARAMS' > "$1" + { + itemId: 'gpu', + colspan: 2, + iconCls: 'fa fa-desktop', + title: gettext('GPU(s)'), + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + console.log(gpuStats); + if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.Intel) { + return 'N/A'; + } + + let html = ''; + + Object.keys(gpuStats.Graphics.Intel).forEach(key => { + const gpuData = gpuStats.Graphics.Intel[key]; + console.log("here1"); + html += `
`; + html += `
${gpuData.name}
`; + html += `
`; + + if (gpuData.stats.engines) { + console.log("here2"); + // Render/3D + if (gpuData.stats.engines['Render/3D']) { + html += `Render/3D: ${gpuData.stats.engines['Render/3D'].busy}% | `; + } + + // Video + if (gpuData.stats.engines['Video']) { + html += `Video: ${gpuData.stats.engines['Video'].busy}% | `; + } + + // Blitter + if (gpuData.stats.engines['Blitter']) { + html += `Blitter: ${gpuData.stats.engines['Blitter'].busy}% | `; + } + + // VideoEnhance + if (gpuData.stats.engines['VideoEnhance']) { + html += `VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}% | `; + } + } + + // Power and Frequency info + html += `Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`; + html += ` | Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.frequency?.unit || 'MHz'}`; + + html += `
`; + }); + + // todo add NVIDIA + + // todo add NVIDIA + + return html; + }, + }, +EOF + ) + #endregion cpu widget heredoc + if [[ $? -ne 0 ]]; then + echo "Error: Failed to generate cpu widget code" >&2 + exit 1 + fi +} #endregion widget generation functions # Function to uninstall the modification From 5893aaec656f3a1148869124874925ed22a95f64 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 5 Jan 2026 21:00:39 +0100 Subject: [PATCH 09/48] Implement: get_nvidia_gpu_devices, parse_nvidia_gpu_line --- GPUcollecter.pm | 138 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 122 insertions(+), 16 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 9c8cc1e..6865f66 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -52,7 +52,7 @@ my $COLLECTOR_TIMEOUT = 10; # Stop collectors x seconds after last get_graphic my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) -my $nvidia_gpu_enabled = 0; # Set to 1 to enable NVIDIA GPU support (not yet implemented) +my $nvidia_gpu_enabled = 1; # Set to 1 to enable NVIDIA GPU support (not yet implemented) my $pve_mod_worker_pid; my $pve_mod_worker_running = 0; @@ -137,6 +137,7 @@ sub _get_intel_gpu_devices { return @devices unless -x '/usr/bin/intel_gpu_top'; + _debug(__LINE__, "Getting Intel GPU devices"); if (open my $fh, '-|', 'intel_gpu_top -L') { while (<$fh>) { chomp; @@ -152,7 +153,7 @@ sub _get_intel_gpu_devices { path => $path, drm_path => "/dev/dri/$card" }; - _debug(__LINE__, "Found GPU device: $card -> $name ($path)"); + _debug(__LINE__, "Found Intel device: $card -> $name ($path)"); } } close $fh; @@ -276,25 +277,123 @@ sub _collector_for_amd_device { # ============================================================================ sub get_nvidia_gpu_devices { - # TODO: Implement NVIDIA GPU detection - # Use nvidia-smi to detect NVIDIA GPUs - _debug(__LINE__, "NVIDIA GPU support not yet implemented"); - return (); + my @devices = (); + + # Expected format (CSV with header): + # index, name + # 0, NVIDIA GeForce RTX 3080 + # 1, NVIDIA RTX A4000 + + # add debug mode where Expected format is loaded from a file instead or calling nvidia-smi + my $debug_file = '/tmp/nvidia-smi-debug.csv'; + my $use_debug_file = 1; # Set to 1 to enable debug mode + + # todo - delete this block later + if ($use_debug_file && -f $debug_file) { + _debug(__LINE__, "Debug mode: reading NVIDIA GPU data from $debug_file"); + if (open my $fh, '<', $debug_file) { + while (<$fh>) { + chomp; + # Skip empty lines + next if /^\s*$/; + + # Parse CSV: "0, NVIDIA GeForce RTX 3080" + if (/^\s*(\d+)\s*,\s*(.+?)\s*$/) { + my $index = $1; + my $name = $2; + push @devices, { + name => $name, + index => $index, + }; + _debug(__LINE__, "Found NVIDIA GPU device (debug): $name -> (index: $index)"); + } + } + close $fh; + } else { + _debug(__LINE__, "Failed to open debug file $debug_file: $!"); + } + } + return @devices; + + + # if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') { + # while (<$fh>) { + # chomp; + # # Skip empty lines + # next if /^\s*$/; + + # # Parse CSV: "0, NVIDIA GeForce RTX 3080" + # if (/^\s*(\d+)\s*,\s*(.+?)\s*$/) { + # my $index = $1; + # my $name = $2; + # push @devices, { + # name => $name, + # index => $index, + # }; + # _debug(__LINE__, "Found NVIDIA GPU device: $name -> (index: $index)"); + # } + # } + # close $fh; + # } else { + # _debug(__LINE__, "Failed to run nvidia-smi: $!"); + # } + + # return @devices; } sub parse_nvidia_gpu_line { my ($line) = @_; - # TODO: Implement NVIDIA GPU line parsing - # Parse nvidia-smi output - _debug(__LINE__, "NVIDIA GPU line parsing not yet implemented"); - return undef; + + # Expected format (CSV) for multiple GPUs: + # index, name, temperature.gpu, utilization.gpu, utilization.memory, memory.used, memory.total, power.draw, power.limit, fan.speed + #0, NVIDIA GeForce RTX 3080, 62, 79, 44, 8260, 10240, 268.12, 320.00, 67 + + # Remove leading/trailing whitespace + $line =~ s/^\s+|\s+$//g; + + # Skip empty lines + return unless $line; + + # Split by comma and trim whitespace from each field + my @values = map { s/^\s+|\s+$//gr } split(/,/, $line); + + # Expected: index(0), name(1), temp(2), util_gpu(3), util_mem(4), mem_used(5), mem_total(6), power_draw(7), power_limit(8), fan_speed(9) + return unless @values >= 10; + + my $stats = { + index => $values[0] + 0, + name => $values[1], + temperature => { + gpu => $values[2] + 0.0, + unit => "°C" + }, + utilization => { + gpu => $values[3] + 0.0, + memory => $values[4] + 0.0, + unit => "%" + }, + memory => { + used => $values[5] + 0.0, + total => $values[6] + 0.0, + unit => "MiB" + }, + power => { + draw => $values[7] + 0.0, + limit => $values[8] + 0.0, + unit => "W" + }, + fan => { + speed => $values[9] + 0.0, + unit => "%" + } + }; + + return $stats; } sub collector_for_nvidia_device { my ($device) = @_; - # TODO: Implement NVIDIA GPU collector - _debug(__LINE__, "NVIDIA GPU collector not yet implemented"); - exit 0; + # nvidia-smi --query-gpu=index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed --format=csv,nounits,noheader --loop=1 } # ============================================================================ @@ -543,15 +642,13 @@ sub _start_graphics_collectors { _debug(__LINE__, "Checking for intel_gpu_top"); unless (-x '/usr/bin/intel_gpu_top') { _debug(__LINE__, "intel_gpu_top not executable"); - unlink($startup_lock); return; } _debug(__LINE__, "intel_gpu_top is executable"); - _debug(__LINE__, "Getting Intel GPU devices"); + my @intel_devices = _get_intel_gpu_devices(); unless (@intel_devices) { _debug(__LINE__, "No Intel GPU devices found"); - unlink($startup_lock); return; } _debug(__LINE__, "Found " . scalar(@intel_devices) . " Intel GPU device(s)"); @@ -571,6 +668,15 @@ sub _start_graphics_collectors { } # NVIDIA (future) if ($nvidia_gpu_enabled) { + _debug(__LINE__, "NVIDIA GPU support enabled"); + + # _debug(__LINE__, "Checking for nvidia-smi"); + # unless (-x '/usr/bin/nvidia-smi') { + # _debug(__LINE__, "nvidia-smi not executable"); + # return; + # } + _debug(__LINE__, "nvidia-smi is executable"); + my @nvidia_devices = get_nvidia_gpu_devices(); _debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); foreach my $device (@nvidia_devices) { From f65d9d7a3bd9692de7fb5d5daabf8cfe20a1b430 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 5 Jan 2026 21:05:42 +0100 Subject: [PATCH 10/48] refactor _check_executable --- GPUcollecter.pm | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 6865f66..cb11d5d 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -490,6 +490,18 @@ sub _ensure_pve_mod_directory_exists { } } +# Generic function to check if required executable exists +sub _check_executable { + my ($exec_path, $type) = @_; + + unless (-x $exec_path) { + _debug(__LINE__, "$exec_path not executable for $type"); + return 0; + } + _debug(__LINE__, "$exec_path is executable"); + return 1; +} + sub _pve_mod_hello { _debug(__LINE__, "PVE Mod is being started. Version $VERSION"); } @@ -640,11 +652,8 @@ sub _start_graphics_collectors { if ($intel_gpu_enabled) { _debug(__LINE__, "Intel GPU support enabled"); _debug(__LINE__, "Checking for intel_gpu_top"); - unless (-x '/usr/bin/intel_gpu_top') { - _debug(__LINE__, "intel_gpu_top not executable"); - return; - } - _debug(__LINE__, "intel_gpu_top is executable"); + + return unless _check_executable('/usr/bin/intel_gpu_top', 'Intel'); my @intel_devices = _get_intel_gpu_devices(); unless (@intel_devices) { @@ -659,6 +668,10 @@ sub _start_graphics_collectors { } # AMD (future) if ($amd_gpu_enabled) { + _debug(__LINE__, "AMD GPU support enabled"); + + return unless _check_executable('/usr/bin/rocm-smi', 'AMD'); + my @amd_devices = _get_amd_gpu_devices(); _debug(__LINE__, "Got " . scalar(@amd_devices) . " AMD devices"); foreach my $device (@amd_devices) { @@ -670,12 +683,7 @@ sub _start_graphics_collectors { if ($nvidia_gpu_enabled) { _debug(__LINE__, "NVIDIA GPU support enabled"); - # _debug(__LINE__, "Checking for nvidia-smi"); - # unless (-x '/usr/bin/nvidia-smi') { - # _debug(__LINE__, "nvidia-smi not executable"); - # return; - # } - _debug(__LINE__, "nvidia-smi is executable"); + # return unless _check_executable('/usr/bin/nvidia-smi', 'NVIDIA'); my @nvidia_devices = get_nvidia_gpu_devices(); _debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); From 5d7be3613bb097204c74f5d9e8d71a16ded41389 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 17:23:36 +0100 Subject: [PATCH 11/48] implement sensors, incl. pretty names for cpu, hdd, nvme, ssd. Make unified child process manager --- GPUcollecter.pm | 838 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 725 insertions(+), 113 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index cb11d5d..119ccdc 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -41,6 +41,7 @@ sub _debug { my $pve_mod_working_dir = '/run/pveproxy/pve-mod'; my $stats_dir = $pve_mod_working_dir; my $state_file = "$pve_mod_working_dir/stats.json"; +my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; my $lock_file = "$pve_mod_working_dir/pve-mod-worker.lock"; my $pve_mod_worker_lock = "$pve_mod_working_dir/pve-mod-pve_mod_worker.lock"; my $startup_lock = $lock_file . ".startup"; @@ -246,6 +247,16 @@ sub _collector_for_intel_device { exit 0; } +# Parse information for graphical presentation. +sub _parse_graphic_info { + my ($line) = @_; + + # Create an intel file with + # Timestamp Device index, name, Render/3D, Blitter, Video, VideoEnhance, power consumption + + return undef; +} + # ============================================================================ # AMD GPU Support (Placeholder) # ============================================================================ @@ -393,7 +404,585 @@ sub parse_nvidia_gpu_line { sub collector_for_nvidia_device { my ($device) = @_; - # nvidia-smi --query-gpu=index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed --format=csv,nounits,noheader --loop=1 + + $0 = "pve-mod-gpu-nvidia-collector"; + _debug(__LINE__, "NVIDIA collector started (stub implementation)"); + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + $SIG{TERM} = sub { + _debug(__LINE__, "NVIDIA collector received SIGTERM"); + $shutdown = 1; + }; + $SIG{INT} = sub { + _debug(__LINE__, "NVIDIA collector received SIGINT"); + $shutdown = 1; + }; + + # TODO: Implement actual NVIDIA monitoring + while (!$shutdown) { + _debug(__LINE__, "NVIDIA collector running (stub)"); + sleep 1; + } + + _debug(__LINE__, "NVIDIA collector shutting down"); + exit 0; +} + +# ============================================================================ +# Unified Child Process Management +# ============================================================================ + +sub _start_child_collector { + my ($collector_name, $collector_sub, $device) = @_; + + _debug(__LINE__, "Starting child collector: $collector_name"); + + my $pid = fork(); + unless (defined $pid) { + _debug(__LINE__, "fork failed for $collector_name: $!"); + return undef; + } + + if ($pid == 0) { + # Child process + _debug(__LINE__, "In child process for $collector_name"); + $0 = "pve-mod-$collector_name"; + $collector_sub->($device); + exit(0); + } + + # Parent process + _debug(__LINE__, "Forked child PID $pid for $collector_name"); + return $pid; +} + +sub _write_collector_lock { + my ($lock_path, @collector_entries) = @_; + + my $lock_fh; + unless (open $lock_fh, '>', $lock_path) { + _debug(__LINE__, "Failed to open lock file for writing: $!"); + return 0; + } + + foreach my $entry (@collector_entries) { + my ($pid, $name, $type) = @$entry; + print $lock_fh "$pid $name $type\n"; + } + + close($lock_fh); + _debug(__LINE__, "Wrote " . scalar(@collector_entries) . " collector entries to lock file"); + return 1; +} + +sub _read_collector_lock { + my ($lock_path) = @_; + + my %collectors; + return %collectors unless -f $lock_path; + + if (open my $lock_fh, '<', $lock_path) { + while (my $line = <$lock_fh>) { + chomp $line; + if ($line =~ /^(\d+)\s+(\S+)\s+(\S+)/) { + $collectors{$2} = { pid => $1, type => $3 }; + } + } + close($lock_fh); + } + + return %collectors; +} + +sub _is_collector_running { + my ($collector_name, $lock_path) = @_; + + my %collectors = _read_collector_lock($lock_path); + + if (exists $collectors{$collector_name}) { + my $pid = $collectors{$collector_name}->{pid}; + if (_is_process_alive($pid)) { + _debug(__LINE__, "Collector '$collector_name' already running with PID $pid"); + return $pid; + } else { + _debug(__LINE__, "Collector '$collector_name' PID $pid is stale"); + } + } + + return undef; +} + +# ============================================================================ +# Temperature Sensors +# ============================================================================ + +sub _collector_for_temperature_sensors { + my ($device) = @_; + + $0 = "pve-mod-sensors-collector"; + _debug(__LINE__, "Temperature sensor collector started"); + + # return if lm-sensors is not installed + unless (-x '/usr/bin/sensors') { + _debug(__LINE__, "sensors not available, exiting"); + exit(1); + } + + # Cache for drive and CPU names + my %cache_ref; + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + $SIG{TERM} = sub { + _debug(__LINE__, "Temperature sensor collector received SIGTERM"); + $shutdown = 1; + }; + $SIG{INT} = sub { + _debug(__LINE__, "Temperature sensor collector received SIGINT"); + $shutdown = 1; + }; + + while (!$shutdown) { + my $sensorsData = _get_temperature_sensors(\%cache_ref); + + # Write to sensors state file + eval { + open my $ofh, '>', $sensors_state_file or die "Failed to open $sensors_state_file: $!"; + print $ofh $sensorsData; + close $ofh; + _debug(__LINE__, "Wrote temperature sensor data to $sensors_state_file"); + }; + if ($@) { + _debug(__LINE__, "Error writing temperature sensor data: $@"); + } + + sleep 1 unless $shutdown; + } + + _debug(__LINE__, "Temperature sensor collector shutting down"); + exit 0; +} + +sub _get_temperature_sensors { + my ($cache_ref) = @_; + + my $sensorsOutput; + + # Collect sensor data from lm-sensors + $sensorsOutput = `sensors -j 2>/dev/null | python3 -m json.tool`; + + _debug(__LINE__, "Raw sensors output collected"); + + # sanitize output + my $sensorsData = _sanitize_sensors($sensorsOutput); + + _debug(__LINE__, "Sanitized sensors output"); + + # translate drive names (pass cache reference) + $sensorsData = _get_drive_names($sensorsData, $cache_ref); + + _debug(__LINE__, "Translated drive names in sensors output"); + + # translate CPU names (pass cache reference) + $sensorsData = _get_cpu_name($sensorsData, $cache_ref); + + _debug(__LINE__, "Translated CPU names in sensors output"); + + # Good, now add a master node called lm sensors exhanced by PVE MOD + my $sensors_json; + eval { + $sensors_json = decode_json($sensorsData); + }; + if ($@) { + _debug(__LINE__, "Failed to parse final sensors JSON: $@"); + return $sensorsData; # Return original output on parse error + } + my $enhanced_data = { + "PVE MOD lm-sensors Enhanced" => $sensors_json + }; + $sensorsData = JSON->new->pretty->encode($enhanced_data); + + + + return $sensorsData; +} + +sub _sanitize_sensors { + my ($sensorsOutput) = @_; + + # Sanitize JSON output to handle common lm-sensors parsing issues + # Replace ERROR lines with placeholder values + $sensorsOutput =~ s/ERROR:.+\s(\w+):\s(.+)/\"$1\": 0.000,/g; + $sensorsOutput =~ s/ERROR:.+\s(\w+)!/\"$1\": 0.000,/g; + + # Remove trailing commas before closing braces + $sensorsOutput =~ s/,\s*(})/$1/g; + + # Replace NaN values with null for valid JSON + $sensorsOutput =~ s/\bNaN\b/null/g; + + # Fix duplicate SODIMM keys by appending temperature sensor number + # This prevents JSON key overwrites when multiple SODIMM sensors exist + # Example: "SODIMM":{"temp3_input":34.0} becomes "SODIMM3":{"temp3_input":34.0} + $sensorsOutput =~ s/\"SODIMM\":\{\"temp(\d+)_input\"/\"SODIMM$1\":\{\"temp$1_input\"/g; + + return $sensorsOutput; +} + +sub _get_drive_names { + my ($sensorsOutput, $cache_ref) = @_; + + # Use empty hash if no cache reference provided (shouldn't happen) + $cache_ref //= {}; + + my @drive_names; + + # Parse sensors output to extract drive entries + my $sensors_data; + eval { + $sensors_data = decode_json($sensorsOutput); + }; + if ($@) { + _debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensorsOutput; # Return original output on parse error + } + + # Extract drive entries from sensors data + my @entries = grep { + /^drivetemp-scsi-/ || /^drivetemp-nvme-/ || /^nvme-pci-/ + } keys %{$sensors_data}; + + _debug(__LINE__, "Found " . scalar(@entries) . " drive entries in sensors output"); + + foreach my $entry (@entries) { + my ($dev_path, $model, $serial) = ("unknown", "unknown", "unknown"); + + # Check cache first + if (exists $cache_ref->{$entry}) { + my $cached = $cache_ref->{$entry}; + $dev_path = $cached->{device_path}; + $model = $cached->{model}; + $serial = $cached->{serial}; + _debug(__LINE__, "Using cached drive info for $entry"); + } else { + # Lookup drive information + + # ----- SCSI/SATA ----- + if ($entry =~ /^drivetemp-scsi-(\d+)-(\d+)/) { + my ($host, $id) = ($1, $2); + my $scsi_path = "/sys/class/scsi_disk/$host:$id:0:0/device/block"; + + if (opendir(my $sdh, $scsi_path)) { + my @devs = grep { /^sd/ } readdir($sdh); + closedir($sdh); + if (@devs) { + $dev_path = "/dev/$devs[0]"; + $model = read_sysfs("/sys/class/block/$devs[0]/device/model"); + $serial = read_sysfs("/sys/class/block/$devs[0]/device/serial"); + } + } + + # ----- Numeric NVMe ----- + } elsif ($entry =~ /^drivetemp-nvme-(\d+)/) { + my $nvme_index = $1; + $dev_path = "/dev/nvme${nvme_index}n1"; + if (-e $dev_path) { + $model = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/model"); + $serial = read_sysfs("/sys/class/block/nvme${nvme_index}n1/device/serial"); + } + + # ----- PCI-style NVMe ----- + } elsif ($entry =~ /^nvme-pci-(\w+)/) { + my $pci_addr = $1; + + # Convert short PCI address to pattern + # nvme-pci-0600 -> 0000:06:00 + # Format: domain:bus:device (function is usually .0) + my $pci_pattern; + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + # Short format like "0600" -> "06:00" + my ($bus, $dev) = ($1, $2); + $pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev)); + _debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern"); + } else { + # Already in some other format, use as-is + $pci_pattern = $pci_addr; + } + + # Try multiple approaches to find the NVMe device + my $found = 0; + + # Approach 1: Check /sys/class/nvme/ + my $nvme_dir = "/sys/class/nvme"; + _debug(__LINE__, "Searching for NVMe devices in $nvme_dir matching PCI pattern $pci_pattern"); + if (opendir(my $ndh, $nvme_dir)) { + my @nvme_devs = grep { /^nvme\d+$/ && -d "$nvme_dir/$_" } readdir($ndh); + closedir($ndh); + + _debug(__LINE__, "Found NVMe devices: " . join(", ", @nvme_devs)); + + foreach my $nvme_dev (@nvme_devs) { + # Check if this nvme device matches our PCI address + my $device_link = readlink("$nvme_dir/$nvme_dev/device"); + if ($device_link && $device_link =~ /$pci_pattern/) { + _debug(__LINE__, "NVMe device $nvme_dev matches PCI pattern $pci_pattern"); + # Found matching device + $dev_path = "/dev/$nvme_dev" . "n1"; + $model = read_sysfs("$nvme_dir/$nvme_dev/model"); + $serial = read_sysfs("$nvme_dir/$nvme_dev/serial"); + $found = 1; + _debug(__LINE__, "Found NVMe device via /sys/class/nvme: $dev_path (matched $pci_pattern)"); + last; + } + _debug(__LINE__, "NVMe device $nvme_dev did not match PCI pattern $pci_pattern"); + } + } + + # Approach 2: Try direct block device lookup if not found + if (!$found && opendir(my $bdh, "/sys/class/block")) { + my @block_devs = grep { /^nvme\d+n\d+$/ } readdir($bdh); + closedir($bdh); + + foreach my $block_dev (@block_devs) { + my $device_link = readlink("/sys/class/block/$block_dev/device"); + if ($device_link && $device_link =~ /$pci_pattern/) { + $dev_path = "/dev/$block_dev"; + # For block devices, go up to the nvme controller for model/serial + my $nvme_ctrl = $block_dev; + $nvme_ctrl =~ s/n\d+$//; # nvme0n1 -> nvme0 + $model = read_sysfs("/sys/class/nvme/$nvme_ctrl/model"); + $serial = read_sysfs("/sys/class/nvme/$nvme_ctrl/serial"); + $found = 1; + _debug(__LINE__, "Found NVMe device via /sys/class/block: $dev_path (matched $pci_pattern)"); + last; + } + } + } + + unless ($found) { + _debug(__LINE__, "Could not find device for nvme-pci-$pci_addr (pattern: $pci_pattern)"); + } + } else { + next; # unknown device type + } + + # Cache the lookup result + $cache_ref->{$entry} = { + device_path => $dev_path, + model => $model, + serial => $serial + }; + + _debug(__LINE__, "Drive: $entry -> $dev_path (Model: $model, Serial: $serial)"); + } + + # Add to result array + push @drive_names, [$entry, $dev_path, $model, $serial]; + } + + # Now enhance the sensors_data structure directly (not as string manipulation) + foreach my $drive_entry (@drive_names) { + my ($original_name, $dev_path, $model, $serial) = @$drive_entry; + + # Add metadata directly to the data structure + if (exists $sensors_data->{$original_name}) { + $sensors_data->{$original_name}->{device_path} = $dev_path; + $sensors_data->{$original_name}->{model} = $model; + $sensors_data->{$original_name}->{serial} = $serial; + _debug(__LINE__, "Enhanced $original_name with drive info"); + } + } + + # Re-encode as pretty JSON + my $enhanced_json = JSON->new->pretty->canonical->encode($sensors_data); + + return $enhanced_json; +} + +sub _get_cpu_name { + my ($sensorsOutput, $cache_ref) = @_; + + # Use empty hash if no cache reference provided + $cache_ref //= {}; + + # Parse sensors output to extract CPU entries + my $sensors_data; + eval { + $sensors_data = decode_json($sensorsOutput); + }; + if ($@) { + _debug(__LINE__, "Failed to parse sensors JSON: $@"); + return $sensorsOutput; # Return original output on parse error + } + + # Extract CPU entries from sensors data + my @entries = grep { /^coretemp-isa-/ || /^k10temp-pci-/ } keys %{$sensors_data}; + + _debug(__LINE__, "Found " . scalar(@entries) . " CPU entries in sensors output"); + + foreach my $entry (@entries) { + my ($cpu_model, $pkg) = ("unknown", "unknown"); + + # Check cache first + if (exists $cache_ref->{$entry}) { + my $cached = $cache_ref->{$entry}; + $cpu_model = $cached->{model}; + $pkg = $cached->{package}; + _debug(__LINE__, "Using cached CPU info for $entry"); + } else { + # Lookup CPU information + + # ----- Intel coretemp ----- + if ($entry =~ /^coretemp-isa-(\d+)/) { + my $isa_id = $1; + + # Find matching hwmon device + for my $hwmon (glob "/sys/class/hwmon/hwmon*") { + my $name = read_sysfs("$hwmon/name"); + next unless $name eq 'coretemp'; + + my $dev = readlink("$hwmon/device"); + next unless $dev; + + # coretemp.0 → package 0 + if ($dev =~ /\.([0-9]+)$/) { + $pkg = $1; + $cpu_model = _cpu_model_by_package($pkg); + _debug(__LINE__, "Found Intel CPU: $entry -> Package $pkg, Model: $cpu_model"); + last; + } + } + } + + # ----- AMD k10temp ----- + elsif ($entry =~ /^k10temp-pci-(\w+)/) { + my $pci_addr = $1; + + # Convert short PCI address to pattern + # k10temp-pci-00c3 -> 0000:00:18.3 + my $pci_pattern; + if ($pci_addr =~ /^([0-9a-f]{2})([0-9a-f]{2})$/i) { + # Short format like "00c3" -> "00:18" (bus:device) + my ($bus, $dev_func) = ($1, $2); + $pci_pattern = sprintf("%04x:%02x:%02x", 0, hex($bus), hex($dev_func)); + _debug(__LINE__, "Converted PCI address $pci_addr to pattern $pci_pattern"); + } + + # Find matching hwmon device + for my $hwmon (glob "/sys/class/hwmon/hwmon*") { + my $name = read_sysfs("$hwmon/name"); + next unless $name eq 'k10temp'; + + my $dev = readlink("$hwmon/device"); + next unless $dev; + + if ($dev =~ /$pci_pattern/ || $dev =~ /$pci_addr/) { + # For AMD, package/node info might be in different location + # Try to determine from PCI device or use 0 as default + $pkg = 0; + + # Attempt to find package from CPU topology + if (opendir(my $dh, "/sys/devices/system/cpu")) { + my @cpus = grep { /^cpu\d+$/ } readdir($dh); + closedir($dh); + + foreach my $cpu (@cpus) { + my $cpu_pkg = read_sysfs("/sys/devices/system/cpu/$cpu/topology/physical_package_id"); + if ($cpu_pkg ne "unknown" && $cpu_pkg =~ /^\d+$/) { + $pkg = $cpu_pkg; + last; + } + } + } + + $cpu_model = _cpu_model_by_package($pkg); + _debug(__LINE__, "Found AMD CPU: $entry -> Package $pkg, Model: $cpu_model"); + last; + } + } + } + + # Cache the lookup result + $cache_ref->{$entry} = { + model => $cpu_model, + package => $pkg + }; + + _debug(__LINE__, "CPU: $entry -> Package $pkg (Model: $cpu_model)"); + } + + # Add metadata directly to the data structure + if (exists $sensors_data->{$entry}) { + $sensors_data->{$entry}->{cpu_model} = $cpu_model; + $sensors_data->{$entry}->{cpu_package} = $pkg; + _debug(__LINE__, "Enhanced $entry with CPU info"); + } + } + + # Re-encode as pretty JSON + my $enhanced_json = JSON->new->pretty->canonical->encode($sensors_data); + + return $enhanced_json; +} + +sub read_sysfs { + my ($path) = @_; + + return "unknown" unless defined $path && -f $path; + + if (open my $fh, '<', $path) { + my $value = <$fh>; + close $fh; + + if (defined $value) { + chomp $value; + # Remove leading/trailing whitespace + $value =~ s/^\s+|\s+$//g; + return $value ne '' ? $value : "unknown"; + } + } + + return "unknown"; +} + +# Helper function to get CPU model by package ID +sub _cpu_model_by_package { + my ($pkg) = @_; + + # Try to read from /proc/cpuinfo + if (open my $fh, '<', '/proc/cpuinfo') { + my $current_pkg = -1; + my $model_name = "unknown"; + + while (my $line = <$fh>) { + chomp $line; + + # Extract physical id + if ($line =~ /^physical id\s+:\s+(\d+)/) { + $current_pkg = $1; + } + + # Extract model name + if ($line =~ /^model name\s+:\s+(.+)$/) { + $model_name = $1; + $model_name =~ s/^\s+|\s+$//g; # Trim whitespace + + # If this is the package we're looking for, return it + if ($current_pkg == $pkg) { + close($fh); + return $model_name; + } + } + } + close($fh); + + # If we didn't find the specific package, return the last model found + # (single socket systems won't have physical id) + return $model_name if $model_name ne "unknown"; + } + + return "unknown"; } # ============================================================================ @@ -513,7 +1102,6 @@ sub _pve_mod_hello { sub get_graphic_stats { # todo name the process without overruling other processes _debug(__LINE__, "get_graphic_stats called"); - _pve_mod_hello(); # Start PVE Mod _pve_mod_starter(); @@ -605,6 +1193,39 @@ sub get_graphic_stats { return $last_snapshot; } +sub get_sensors_stats { + _debug(__LINE__, "get_sensors_stats called"); + + # Start PVE Mod + _pve_mod_starter(); + + unless (-f $sensors_state_file) { + _debug(__LINE__, "Sensors state file does not exist: $sensors_state_file"); + return {}; + } + + my $sensors_data; + eval { + open my $fh, '<', $sensors_state_file or die "Failed to open $sensors_state_file: $!"; + local $/; + my $json = <$fh>; + close($fh); + $sensors_data = $json; + _debug(__LINE__, "Read sensors data, JSON length: " . length($json) . " bytes"); + _debug(__LINE__, "Read sensors data from $sensors_state_file"); + }; + if ($@) { + _debug(__LINE__, "Failed to read/parse sensors data: $@"); + return {}; + } + + + # Notify pve_mod_worker of activity + _notify_pve_mod_worker(); + + return $sensors_data; +} + # ============================================================================ # Main Collector # ============================================================================ @@ -619,34 +1240,13 @@ sub _start_graphics_collectors { _debug(__LINE__, "Starting graphics collectors"); } - # NOW check if collectors are already running (while holding startup lock) - my %existing_collectors; - if (-f $lock_file) { - _debug(__LINE__, "Lock file exists: $lock_file"); - if (open(my $lock_fh, '<', $lock_file)) { - _debug(__LINE__, "Opened lock file for reading"); - flock($lock_fh, LOCK_EX) or _debug(__LINE__, "Failed to lock $lock_file: $!"); - while (my $line = <$lock_fh>) { - chomp $line; - if ($line =~ /^(\d+)\s+(\S+)/) { - $existing_collectors{$2} = $1; - } elsif ($line =~ /^(\d+)$/) { - # Backward compatibility: only PID, no card - $existing_collectors{"unknown"} = $1; - } - } - close($lock_fh); - } else { - _debug(__LINE__, "Failed to open lock file: $!"); - } - } else { - _debug(__LINE__, "Lock file does not exist. Clean start"); - } + # Read existing collectors from lock file + my %existing_collectors = _read_collector_lock($lock_file); # Generalized device collector management for future AMD/NVIDIA support my @all_devices; my @all_types; - my @all_collectors; + my @all_collector_subs; # Intel if ($intel_gpu_enabled) { @@ -658,14 +1258,16 @@ sub _start_graphics_collectors { my @intel_devices = _get_intel_gpu_devices(); unless (@intel_devices) { _debug(__LINE__, "No Intel GPU devices found"); - return; - } - _debug(__LINE__, "Found " . scalar(@intel_devices) . " Intel GPU device(s)"); - foreach my $device (@intel_devices) { - push @all_devices, $device; - push @all_types, 'intel'; + } else { + _debug(__LINE__, "Found " . scalar(@intel_devices) . " Intel GPU device(s)"); + foreach my $device (@intel_devices) { + push @all_devices, $device; + push @all_types, 'intel'; + push @all_collector_subs, \&_collector_for_intel_device; + } } } + # AMD (future) if ($amd_gpu_enabled) { _debug(__LINE__, "AMD GPU support enabled"); @@ -677,118 +1279,131 @@ sub _start_graphics_collectors { foreach my $device (@amd_devices) { push @all_devices, $device; push @all_types, 'amd'; + push @all_collector_subs, \&_collector_for_amd_device; } } + # NVIDIA (future) if ($nvidia_gpu_enabled) { _debug(__LINE__, "NVIDIA GPU support enabled"); - # return unless _check_executable('/usr/bin/nvidia-smi', 'NVIDIA'); - my @nvidia_devices = get_nvidia_gpu_devices(); _debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); foreach my $device (@nvidia_devices) { push @all_devices, $device; push @all_types, 'nvidia'; + push @all_collector_subs, \&collector_for_nvidia_device; } } - my @child_pids; - my @child_devices; - my @child_types; + _debug(__LINE__, "Finished detecting devices. Total collectors to manage: " . scalar(@all_devices)); + + my @collector_entries; + for (my $i = 0; $i < @all_devices; $i++) { my $device = $all_devices[$i]; my $type = $all_types[$i]; - my $card = $device->{card}; - my $existing_pid = $existing_collectors{$card}; - if ($existing_pid && _is_process_alive($existing_pid)) { - _debug(__LINE__, "Collector for $type $card already running with PID $existing_pid"); - push @child_pids, $existing_pid; - push @child_devices, $device; - push @child_types, $type; + my $collector_sub = $all_collector_subs[$i]; + my $device_name = $device->{card} // $device->{name} // "device$i"; + + # Check if collector already running + my $existing_pid = _is_collector_running($device_name, $lock_file); + if ($existing_pid) { + _debug(__LINE__, "Collector for $type $device_name already running with PID $existing_pid"); + push @collector_entries, [$existing_pid, $device_name, $type]; next; } - _debug(__LINE__, "About to fork collector for $type $card"); - my $pid = fork(); - unless (defined $pid) { - _debug(__LINE__, "fork failed: $!"); - unlink($startup_lock); - die "fork failed: $!"; - } - if ($pid == 0) { - # Child process - _debug(__LINE__, "In child process for $type $card"); - if ($type eq 'intel') { - _collector_for_intel_device($device); - } elsif ($type eq 'amd') { - _collector_for_amd_device($device); - } elsif ($type eq 'nvidia') { - collector_for_nvidia_device($device); - } else { - _debug(__LINE__, "Unknown GPU type $type for $card"); - exit(1); - } - # Should not reach here - exit(0); - } else { - _debug(__LINE__, "Forked child PID $pid for $type $card"); - push @child_pids, $pid; - push @child_devices, $device; - push @child_types, $type; + + # Start new collector + my $pid = _start_child_collector($device_name, $collector_sub, $device); + if ($pid) { + push @collector_entries, [$pid, $device_name, $type]; } } - _debug(__LINE__, "Active children: " . join(", ", @child_pids)); - - # Write child PIDs and device cards/types to lock file - if (open(my $lock_fh, '>', $lock_file)) { - _debug(__LINE__, "Opened lock file for writing"); - for (my $i = 0; $i < @child_pids; $i++) { - my $pid = $child_pids[$i]; - my $device = $child_devices[$i]; - my $type = $child_types[$i]; - my $card = $device->{card} // ''; - print $lock_fh "$pid $card $type\n"; - } - flock($lock_fh, LOCK_UN); - close($lock_fh); - _debug(__LINE__, "Wrote " . scalar(@child_pids) . " collector PID(s) to lock file"); - } else { - _debug(__LINE__, "Failed to open lock file for writing: $!"); - foreach my $pid (@child_pids) { - _debug(__LINE__, "Killing child $pid due to lock file write failure"); - kill 'TERM', $pid; + + # Write all collector PIDs to lock file + unless (_write_collector_lock($lock_file, @collector_entries)) { + _debug(__LINE__, "Failed to write lock file, terminating collectors"); + foreach my $entry (@collector_entries) { + kill 'TERM', $entry->[0]; } - unlink($startup_lock); return; } - # Wait briefly to ensure collector is actually running + # Wait briefly to ensure collectors are running sleep 0.1; - # Verify at least one child is still alive (by PID only) + # Verify collectors are alive my $any_alive = 0; - for (my $i = 0; $i < @child_pids; $i++) { - my $pid = $child_pids[$i]; - my $device = $child_devices[$i]; - my $card = $device->{card} // ''; - my $alive = kill(0, $pid); - if ($alive) { + foreach my $entry (@collector_entries) { + my ($pid, $name, $type) = @$entry; + if (kill(0, $pid)) { $any_alive = 1; - _debug(__LINE__, "Verified child PID $pid for $card is alive"); + _debug(__LINE__, "Verified $type collector $name (PID $pid) is alive"); } else { - _debug(__LINE__, "WARNING - Child PID $pid for $card died immediately!"); + _debug(__LINE__, "WARNING - $type collector $name (PID $pid) died immediately!"); } } + unless ($any_alive) { - _debug(__LINE__, "ERROR - No children alive after fork!"); + _debug(__LINE__, "ERROR - No collectors alive after fork!"); + return; } - _debug(__LINE__, "Graphics collectors started"); + _debug(__LINE__, "All graphics collectors started successfully"); +} + +sub _start_sensors_collector { + _debug(__LINE__, "Starting temperature sensor collector"); + + # Check if sensors is available + unless (-x '/usr/bin/sensors') { + _debug(__LINE__, "sensors not available, skipping"); + return; + } + + # Check if already running + my $existing_pid = _is_collector_running('sensors', $lock_file); + if ($existing_pid) { + _debug(__LINE__, "Sensors collector already running with PID $existing_pid"); + return; + } + + # Start the collector + my $pid = _start_child_collector('sensors', \&_collector_for_temperature_sensors, { name => 'sensors' }); + + if ($pid) { + # Read existing entries + my @collector_entries; + my %existing = _read_collector_lock($lock_file); + foreach my $name (keys %existing) { + push @collector_entries, [$existing{$name}->{pid}, $name, $existing{$name}->{type}]; + } + + # Add sensors entry + push @collector_entries, [$pid, 'sensors', 'sensors']; + + # Write updated lock file + unless (_write_collector_lock($lock_file, @collector_entries)) { + _debug(__LINE__, "Failed to update lock file, terminating sensors collector"); + kill 'TERM', $pid; + return; + } + + # Verify it's alive + sleep 0.1; + if (kill(0, $pid)) { + _debug(__LINE__, "Verified sensors collector (PID $pid) is alive"); + } else { + _debug(__LINE__, "WARNING - Sensors collector (PID $pid) died immediately!"); + } + } } # ============================================================================ # PVE Mod Worker # ============================================================================ + sub _pve_mod_starter { # Check if pve_mod_worker is already running - if so, entire system is already up _debug(__LINE__, "Checking if pve_mod_worker is already running"); @@ -798,6 +1413,8 @@ sub _pve_mod_starter { } _debug(__LINE__, "PVE mod worker is not running. PVE Mod will be started."); + _pve_mod_hello(); + # Ensure directory exists _ensure_pve_mod_directory_exists(); @@ -808,21 +1425,16 @@ sub _pve_mod_starter { print $startup_fh "$$\n"; close($startup_fh); _debug(__LINE__, "Wrote PID, $$, to startup lock"); - - + + # Start sensors collector first + _start_sensors_collector(); # Start graphics collectors _start_graphics_collectors(); - # Start sensor collector - # TBD - - # Start UPS collector - # TBD - _debug(__LINE__, "All collectors started"); - # Start pve mod workers + # Start pve mod worker _pve_mod_worker(); # Remove startup lock LAST From 0d608abf6f1966a222d91fa0d844c3ec9f5bcce9 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 17:33:17 +0100 Subject: [PATCH 12/48] add pvemanagerlib.js for tracking changes --- pvemanagerlib.js | 68528 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68528 insertions(+) create mode 100644 pvemanagerlib.js diff --git a/pvemanagerlib.js b/pvemanagerlib.js new file mode 100644 index 0000000..c35b83b --- /dev/null +++ b/pvemanagerlib.js @@ -0,0 +1,68528 @@ +const pveOnlineHelpInfo = { + "ceph_rados_block_devices" : { + "link" : "/pve-docs/chapter-pvesm.html#ceph_rados_block_devices", + "title" : "Ceph RADOS Block Devices (RBD)" + }, + "chapter_ha_manager" : { + "link" : "/pve-docs/chapter-ha-manager.html#chapter_ha_manager", + "title" : "High Availability" + }, + "chapter_lvm" : { + "link" : "/pve-docs/chapter-sysadmin.html#chapter_lvm", + "title" : "Logical Volume Manager (LVM)" + }, + "chapter_notifications" : { + "link" : "/pve-docs/chapter-notifications.html#chapter_notifications", + "title" : "Notifications" + }, + "chapter_pct" : { + "link" : "/pve-docs/chapter-pct.html#chapter_pct", + "title" : "Proxmox Container Toolkit" + }, + "chapter_pve_firewall" : { + "link" : "/pve-docs/chapter-pve-firewall.html#chapter_pve_firewall", + "title" : "Proxmox VE Firewall" + }, + "chapter_pveceph" : { + "link" : "/pve-docs/chapter-pveceph.html#chapter_pveceph", + "title" : "Deploy Hyper-Converged Ceph Cluster" + }, + "chapter_pvecm" : { + "link" : "/pve-docs/chapter-pvecm.html#chapter_pvecm", + "title" : "Cluster Manager" + }, + "chapter_pvesdn" : { + "link" : "/pve-docs/chapter-pvesdn.html#chapter_pvesdn", + "title" : "Software-Defined Network" + }, + "chapter_pvesr" : { + "link" : "/pve-docs/chapter-pvesr.html#chapter_pvesr", + "title" : "Storage Replication" + }, + "chapter_storage" : { + "link" : "/pve-docs/chapter-pvesm.html#chapter_storage", + "title" : "Proxmox VE Storage" + }, + "chapter_system_administration" : { + "link" : "/pve-docs/chapter-sysadmin.html#chapter_system_administration", + "title" : "Host System Administration" + }, + "chapter_user_management" : { + "link" : "/pve-docs/chapter-pveum.html#chapter_user_management", + "title" : "User Management" + }, + "chapter_virtual_machines" : { + "link" : "/pve-docs/chapter-qm.html#chapter_virtual_machines", + "title" : "QEMU/KVM Virtual Machines" + }, + "chapter_vzdump" : { + "link" : "/pve-docs/chapter-vzdump.html#chapter_vzdump", + "title" : "Backup and Restore" + }, + "chapter_zfs" : { + "link" : "/pve-docs/chapter-sysadmin.html#chapter_zfs", + "title" : "ZFS on Linux" + }, + "datacenter_configuration_file" : { + "link" : "/pve-docs/pve-admin-guide.html#datacenter_configuration_file", + "title" : "Datacenter Configuration" + }, + "external_metric_server" : { + "link" : "/pve-docs/chapter-sysadmin.html#external_metric_server", + "title" : "External Metric Server" + }, + "getting_help" : { + "link" : "/pve-docs/chapter-pve-intro.html#getting_help", + "title" : "Getting Help" + }, + "gui_consent_banner" : { + "link" : "/pve-docs/chapter-pve-gui.html#gui_consent_banner", + "subtitle" : "Consent Banner", + "title" : "Graphical User Interface" + }, + "gui_my_settings" : { + "link" : "/pve-docs/chapter-pve-gui.html#gui_my_settings", + "subtitle" : "My Settings", + "title" : "Graphical User Interface" + }, + "gui_tags" : { + "link" : "/pve-docs/chapter-pve-gui.html#gui_tags", + "subtitle" : "Tags", + "title" : "Graphical User Interface" + }, + "ha_manager_crs" : { + "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_crs", + "subtitle" : "Cluster Resource Scheduling", + "title" : "High Availability" + }, + "ha_manager_fencing" : { + "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_fencing", + "subtitle" : "Fencing", + "title" : "High Availability" + }, + "ha_manager_resource_config" : { + "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resource_config", + "subtitle" : "Resources", + "title" : "High Availability" + }, + "ha_manager_resources" : { + "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resources", + "subtitle" : "Resources", + "title" : "High Availability" + }, + "ha_manager_rules" : { + "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_rules", + "subtitle" : "Rules", + "title" : "High Availability" + }, + "ha_manager_shutdown_policy" : { + "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_shutdown_policy", + "subtitle" : "Shutdown Policy", + "title" : "High Availability" + }, + "markdown_basics" : { + "link" : "/pve-docs/pve-admin-guide.html#markdown_basics", + "title" : "Markdown Primer" + }, + "metric_server_graphite" : { + "link" : "/pve-docs/chapter-sysadmin.html#metric_server_graphite", + "subtitle" : "Graphite server configuration", + "title" : "External Metric Server" + }, + "metric_server_influxdb" : { + "link" : "/pve-docs/chapter-sysadmin.html#metric_server_influxdb", + "subtitle" : "Influxdb plugin configuration", + "title" : "External Metric Server" + }, + "notification_matchers" : { + "link" : "/pve-docs/chapter-notifications.html#notification_matchers", + "subtitle" : "Notification Matchers", + "title" : "Notifications" + }, + "notification_targets_gotify" : { + "link" : "/pve-docs/chapter-notifications.html#notification_targets_gotify", + "subtitle" : "Gotify", + "title" : "Notifications" + }, + "notification_targets_sendmail" : { + "link" : "/pve-docs/chapter-notifications.html#notification_targets_sendmail", + "subtitle" : "Sendmail", + "title" : "Notifications" + }, + "notification_targets_smtp" : { + "link" : "/pve-docs/chapter-notifications.html#notification_targets_smtp", + "subtitle" : "SMTP", + "title" : "Notifications" + }, + "notification_targets_webhook" : { + "link" : "/pve-docs/chapter-notifications.html#notification_targets_webhook", + "subtitle" : "Webhook", + "title" : "Notifications" + }, + "pct_configuration" : { + "link" : "/pve-docs/chapter-pct.html#pct_configuration", + "subtitle" : "Configuration", + "title" : "Proxmox Container Toolkit" + }, + "pct_container_images" : { + "link" : "/pve-docs/chapter-pct.html#pct_container_images", + "subtitle" : "Container Images", + "title" : "Proxmox Container Toolkit" + }, + "pct_container_network" : { + "link" : "/pve-docs/chapter-pct.html#pct_container_network", + "subtitle" : "Network", + "title" : "Proxmox Container Toolkit" + }, + "pct_container_storage" : { + "link" : "/pve-docs/chapter-pct.html#pct_container_storage", + "subtitle" : "Container Storage", + "title" : "Proxmox Container Toolkit" + }, + "pct_cpu" : { + "link" : "/pve-docs/chapter-pct.html#pct_cpu", + "subtitle" : "CPU", + "title" : "Proxmox Container Toolkit" + }, + "pct_general" : { + "link" : "/pve-docs/chapter-pct.html#pct_general", + "subtitle" : "General Settings", + "title" : "Proxmox Container Toolkit" + }, + "pct_memory" : { + "link" : "/pve-docs/chapter-pct.html#pct_memory", + "subtitle" : "Memory", + "title" : "Proxmox Container Toolkit" + }, + "pct_migration" : { + "link" : "/pve-docs/chapter-pct.html#pct_migration", + "subtitle" : "Migration", + "title" : "Proxmox Container Toolkit" + }, + "pct_options" : { + "link" : "/pve-docs/chapter-pct.html#pct_options", + "subtitle" : "Options", + "title" : "Proxmox Container Toolkit" + }, + "pct_startup_and_shutdown" : { + "link" : "/pve-docs/chapter-pct.html#pct_startup_and_shutdown", + "subtitle" : "Automatic Start and Shutdown of Containers", + "title" : "Proxmox Container Toolkit" + }, + "proxmox_node_management" : { + "link" : "/pve-docs/chapter-sysadmin.html#proxmox_node_management", + "title" : "Proxmox Node Management" + }, + "pve_admin_guide" : { + "link" : "/pve-docs/pve-admin-guide.html", + "title" : "Proxmox VE Administration Guide" + }, + "pve_ceph_install" : { + "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_install", + "subtitle" : "CLI Installation of Ceph Packages", + "title" : "Deploy Hyper-Converged Ceph Cluster" + }, + "pve_ceph_osds" : { + "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_osds", + "subtitle" : "Ceph OSDs", + "title" : "Deploy Hyper-Converged Ceph Cluster" + }, + "pve_ceph_pools" : { + "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_pools", + "subtitle" : "Ceph Pools", + "title" : "Deploy Hyper-Converged Ceph Cluster" + }, + "pve_documentation_index" : { + "link" : "/pve-docs/index.html", + "title" : "Proxmox VE Documentation Index" + }, + "pve_firewall_cluster_wide_setup" : { + "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_cluster_wide_setup", + "subtitle" : "Cluster Wide Setup", + "title" : "Proxmox VE Firewall" + }, + "pve_firewall_host_specific_configuration" : { + "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_host_specific_configuration", + "subtitle" : "Host Specific Configuration", + "title" : "Proxmox VE Firewall" + }, + "pve_firewall_ip_aliases" : { + "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_aliases", + "subtitle" : "IP Aliases", + "title" : "Proxmox VE Firewall" + }, + "pve_firewall_ip_sets" : { + "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_sets", + "subtitle" : "IP Sets", + "title" : "Proxmox VE Firewall" + }, + "pve_firewall_security_groups" : { + "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_security_groups", + "subtitle" : "Security Groups", + "title" : "Proxmox VE Firewall" + }, + "pve_firewall_vm_container_configuration" : { + "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_vm_container_configuration", + "subtitle" : "VM/Container Configuration", + "title" : "Proxmox VE Firewall" + }, + "pve_service_daemons" : { + "link" : "/pve-docs/index.html#_service_daemons", + "title" : "Service Daemons" + }, + "pveceph_fs" : { + "link" : "/pve-docs/chapter-pveceph.html#pveceph_fs", + "subtitle" : "CephFS", + "title" : "Deploy Hyper-Converged Ceph Cluster" + }, + "pveceph_fs_create" : { + "link" : "/pve-docs/chapter-pveceph.html#pveceph_fs_create", + "subtitle" : "Create CephFS", + "title" : "Deploy Hyper-Converged Ceph Cluster" + }, + "pvecm_create_cluster" : { + "link" : "/pve-docs/chapter-pvecm.html#pvecm_create_cluster", + "subtitle" : "Create a Cluster", + "title" : "Cluster Manager" + }, + "pvecm_join_node_to_cluster" : { + "link" : "/pve-docs/chapter-pvecm.html#pvecm_join_node_to_cluster", + "subtitle" : "Adding Nodes to the Cluster", + "title" : "Cluster Manager" + }, + "pvesdn_config_controllers" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_controllers", + "subtitle" : "Controllers", + "title" : "Software-Defined Network" + }, + "pvesdn_config_fabrics" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_fabrics", + "subtitle" : "Fabrics", + "title" : "Software-Defined Network" + }, + "pvesdn_config_vnet" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_vnet", + "subtitle" : "VNets", + "title" : "Software-Defined Network" + }, + "pvesdn_config_zone" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_zone", + "subtitle" : "Zones", + "title" : "Software-Defined Network" + }, + "pvesdn_controller_plugin_evpn" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_controller_plugin_evpn", + "subtitle" : "EVPN Controller", + "title" : "Software-Defined Network" + }, + "pvesdn_dns_plugin_powerdns" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_dns_plugin_powerdns", + "subtitle" : "PowerDNS Plugin", + "title" : "Software-Defined Network" + }, + "pvesdn_firewall_integration" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_firewall_integration", + "subtitle" : "Firewall Integration", + "title" : "Software-Defined Network" + }, + "pvesdn_ipam_plugin_netbox" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_netbox", + "subtitle" : "NetBox IPAM Plugin", + "title" : "Software-Defined Network" + }, + "pvesdn_ipam_plugin_phpipam" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_phpipam", + "subtitle" : "phpIPAM Plugin", + "title" : "Software-Defined Network" + }, + "pvesdn_ipam_plugin_pveipam" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_pveipam", + "subtitle" : "PVE IPAM Plugin", + "title" : "Software-Defined Network" + }, + "pvesdn_openfabric_fabric" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_openfabric_fabric", + "subtitle" : "On the Fabric", + "title" : "Software-Defined Network" + }, + "pvesdn_ospf_fabric" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ospf_fabric", + "subtitle" : "On the Fabric", + "title" : "Software-Defined Network" + }, + "pvesdn_zone_plugin_evpn" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_evpn", + "subtitle" : "EVPN Zones", + "title" : "Software-Defined Network" + }, + "pvesdn_zone_plugin_qinq" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_qinq", + "subtitle" : "QinQ Zones", + "title" : "Software-Defined Network" + }, + "pvesdn_zone_plugin_simple" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_simple", + "subtitle" : "Simple Zones", + "title" : "Software-Defined Network" + }, + "pvesdn_zone_plugin_vlan" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vlan", + "subtitle" : "VLAN Zones", + "title" : "Software-Defined Network" + }, + "pvesdn_zone_plugin_vxlan" : { + "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vxlan", + "subtitle" : "VXLAN Zones", + "title" : "Software-Defined Network" + }, + "pvesr_schedule_time_format" : { + "link" : "/pve-docs/chapter-pvesr.html#pvesr_schedule_time_format", + "subtitle" : "Schedule Format", + "title" : "Storage Replication" + }, + "pveum_authentication_realms" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_authentication_realms", + "subtitle" : "Authentication Realms", + "title" : "User Management" + }, + "pveum_configure_u2f" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_configure_u2f", + "subtitle" : "Server Side U2F Configuration", + "title" : "User Management" + }, + "pveum_configure_webauthn" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_configure_webauthn", + "subtitle" : "Server Side Webauthn Configuration", + "title" : "User Management" + }, + "pveum_groups" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_groups", + "subtitle" : "Groups", + "title" : "User Management" + }, + "pveum_ldap_sync" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_ldap_sync", + "subtitle" : "Syncing LDAP-Based Realms", + "title" : "User Management" + }, + "pveum_permission_management" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_permission_management", + "subtitle" : "Permission Management", + "title" : "User Management" + }, + "pveum_pools" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_pools", + "subtitle" : "Pools", + "title" : "User Management" + }, + "pveum_roles" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_roles", + "subtitle" : "Roles", + "title" : "User Management" + }, + "pveum_tokens" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_tokens", + "subtitle" : "API Tokens", + "title" : "User Management" + }, + "pveum_users" : { + "link" : "/pve-docs/chapter-pveum.html#pveum_users", + "subtitle" : "Users", + "title" : "User Management" + }, + "qm_audio_device" : { + "link" : "/pve-docs/chapter-qm.html#qm_audio_device", + "subtitle" : "Audio Device", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_bios_and_uefi" : { + "link" : "/pve-docs/chapter-qm.html#qm_bios_and_uefi", + "subtitle" : "BIOS and UEFI", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_bootorder" : { + "link" : "/pve-docs/chapter-qm.html#qm_bootorder", + "subtitle" : "Device Boot Order", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_cloud_init" : { + "link" : "/pve-docs/chapter-qm.html#qm_cloud_init", + "title" : "Cloud-Init Support" + }, + "qm_copy_and_clone" : { + "link" : "/pve-docs/chapter-qm.html#qm_copy_and_clone", + "subtitle" : "Copies and Clones", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_cpu" : { + "link" : "/pve-docs/chapter-qm.html#qm_cpu", + "subtitle" : "CPU", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_display" : { + "link" : "/pve-docs/chapter-qm.html#qm_display", + "subtitle" : "Display", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_general_settings" : { + "link" : "/pve-docs/chapter-qm.html#qm_general_settings", + "subtitle" : "General Settings", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_hard_disk" : { + "link" : "/pve-docs/chapter-qm.html#qm_hard_disk", + "subtitle" : "Hard Disk", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_import_virtual_machines" : { + "link" : "/pve-docs/chapter-qm.html#qm_import_virtual_machines", + "subtitle" : "Importing Virtual Machines", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_machine_type" : { + "link" : "/pve-docs/chapter-qm.html#qm_machine_type", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_memory" : { + "link" : "/pve-docs/chapter-qm.html#qm_memory", + "subtitle" : "Memory", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_migration" : { + "link" : "/pve-docs/chapter-qm.html#qm_migration", + "subtitle" : "Migration", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_network_device" : { + "link" : "/pve-docs/chapter-qm.html#qm_network_device", + "subtitle" : "Network Device", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_options" : { + "link" : "/pve-docs/chapter-qm.html#qm_options", + "subtitle" : "Options", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_os_settings" : { + "link" : "/pve-docs/chapter-qm.html#qm_os_settings", + "subtitle" : "OS Settings", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_pci_passthrough_vm_config" : { + "link" : "/pve-docs/chapter-qm.html#qm_pci_passthrough_vm_config", + "subtitle" : "VM Configuration", + "title" : "PCI(e) Passthrough" + }, + "qm_qemu_agent" : { + "link" : "/pve-docs/chapter-qm.html#qm_qemu_agent", + "subtitle" : "QEMU Guest Agent", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_spice_enhancements" : { + "link" : "/pve-docs/chapter-qm.html#qm_spice_enhancements", + "subtitle" : "SPICE Enhancements", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_startup_and_shutdown" : { + "link" : "/pve-docs/chapter-qm.html#qm_startup_and_shutdown", + "subtitle" : "Automatic Start and Shutdown of Virtual Machines", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_system_settings" : { + "link" : "/pve-docs/chapter-qm.html#qm_system_settings", + "subtitle" : "System Settings", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_tpm" : { + "link" : "/pve-docs/chapter-qm.html#qm_tpm", + "subtitle" : "Trusted Platform Module (TPM)", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_usb_passthrough" : { + "link" : "/pve-docs/chapter-qm.html#qm_usb_passthrough", + "subtitle" : "USB Passthrough", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_virtio_rng" : { + "link" : "/pve-docs/chapter-qm.html#qm_virtio_rng", + "subtitle" : "VirtIO RNG", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_virtiofs" : { + "link" : "/pve-docs/chapter-qm.html#qm_virtiofs", + "subtitle" : "Virtiofs", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_virtual_machines_settings" : { + "link" : "/pve-docs/chapter-qm.html#qm_virtual_machines_settings", + "subtitle" : "Virtual Machines Settings", + "title" : "QEMU/KVM Virtual Machines" + }, + "qm_vmstatestorage" : { + "link" : "/pve-docs/chapter-qm.html#qm_vmstatestorage", + "title" : "QEMU/KVM Virtual Machines" + }, + "resource_mapping" : { + "link" : "/pve-docs/chapter-qm.html#resource_mapping", + "subtitle" : "Resource Mapping", + "title" : "QEMU/KVM Virtual Machines" + }, + "storage_btrfs" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_btrfs", + "title" : "BTRFS Backend" + }, + "storage_cephfs" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_cephfs", + "title" : "Ceph Filesystem (CephFS)" + }, + "storage_cifs" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_cifs", + "title" : "CIFS Backend" + }, + "storage_directory" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_directory", + "title" : "Directory Backend" + }, + "storage_lvm" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_lvm", + "title" : "LVM Backend" + }, + "storage_lvmthin" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_lvmthin", + "title" : "LVM thin Backend" + }, + "storage_nfs" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_nfs", + "title" : "NFS Backend" + }, + "storage_open_iscsi" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_open_iscsi", + "title" : "Open-iSCSI initiator" + }, + "storage_pbs" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_pbs", + "title" : "Proxmox Backup Server" + }, + "storage_pbs_encryption" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_pbs_encryption", + "subtitle" : "Encryption", + "title" : "Proxmox Backup Server" + }, + "storage_zfspool" : { + "link" : "/pve-docs/chapter-pvesm.html#storage_zfspool", + "title" : "Local ZFS Pool Backend" + }, + "sysadmin_certificate_management" : { + "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certificate_management", + "title" : "Certificate Management" + }, + "sysadmin_certs_acme_account" : { + "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certs_acme_account", + "subtitle" : "ACME Account", + "title" : "Certificate Management" + }, + "sysadmin_network_configuration" : { + "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_network_configuration", + "title" : "Network Configuration" + }, + "sysadmin_package_repositories" : { + "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_package_repositories", + "title" : "Package Repositories" + }, + "user-realms-ad" : { + "link" : "/pve-docs/chapter-pveum.html#user-realms-ad", + "subtitle" : "Microsoft Active Directory (AD)", + "title" : "User Management" + }, + "user-realms-ldap" : { + "link" : "/pve-docs/chapter-pveum.html#user-realms-ldap", + "subtitle" : "LDAP", + "title" : "User Management" + }, + "user-realms-pam" : { + "link" : "/pve-docs/chapter-pveum.html#user-realms-pam", + "subtitle" : "Linux PAM Standard Authentication", + "title" : "User Management" + }, + "user_mgmt" : { + "link" : "/pve-docs/chapter-pveum.html#user_mgmt", + "title" : "User Management" + }, + "vzdump_retention" : { + "link" : "/pve-docs/chapter-vzdump.html#vzdump_retention", + "subtitle" : "Backup Retention", + "title" : "Backup and Restore" + } +}; +// Some configuration values are complex strings - so we need parsers/generators for them. +Ext.define('PVE.Parser', { + statics: { + // this class only contains static functions + + printACME: function (value) { + if (Ext.isArray(value.domains)) { + value.domains = value.domains.join(';'); + } + return PVE.Parser.printPropertyString(value); + }, + + parseACME: function (value) { + if (!value) { + return {}; + } + + let res = {}; + try { + value.split(',').forEach((property) => { + let [k, v] = property.split('=', 2); + if (Ext.isDefined(v)) { + res[k] = v; + } else { + throw `Failed to parse key-value pair: ${property}`; + } + }); + } catch (err) { + console.warn(err); + return undefined; + } + + if (res.domains !== undefined) { + res.domains = res.domains.split(/;/); + } + + return res; + }, + + parseBoolean: function (value, default_value) { + if (!Ext.isDefined(value)) { + return default_value; + } + value = value.toLowerCase(); + return value === '1' || value === 'on' || value === 'yes' || value === 'true'; + }, + + parsePropertyString: function (value, defaultKey) { + let res = {}; + + if (typeof value !== 'string' || value === '') { + return res; + } + + try { + value.split(',').forEach((property) => { + let [k, v] = property.split('=', 2); + if (Ext.isDefined(v)) { + res[k] = v; + } else if (Ext.isDefined(defaultKey)) { + if (Ext.isDefined(res[defaultKey])) { + throw 'defaultKey may be only defined once in propertyString'; + } + res[defaultKey] = k; // k is the value in this case + } else { + throw `Failed to parse key-value pair: ${property}`; + } + }); + } catch (err) { + console.warn(err); + return undefined; + } + + return res; + }, + + printPropertyString: function (data, defaultKey) { + var stringparts = [], + gotDefaultKeyVal = false, + defaultKeyVal; + + Ext.Object.each(data, function (key, value) { + if (defaultKey !== undefined && key === defaultKey) { + gotDefaultKeyVal = true; + defaultKeyVal = value; + } else if (value !== '') { + stringparts.push(key + '=' + value); + } + }); + + stringparts = stringparts.sort(); + if (gotDefaultKeyVal) { + stringparts.unshift(defaultKeyVal); + } + + return stringparts.join(','); + }, + + parseQemuNetwork: function (key, value) { + if (!(key && value)) { + return undefined; + } + + let res = {}, + errors = false; + Ext.Array.each(value.split(','), function (p) { + if (!p || p.match(/^\s*$/)) { + return undefined; // continue + } + + let match_res; + + if ( + (match_res = p.match( + /^(ne2k_pci|e1000e?|e1000-82540em|e1000-82544gc|e1000-82545em|vmxnet3|rtl8139|pcnet|virtio|ne2k_isa|i82551|i82557b|i82559er)(=([0-9a-f]{2}(:[0-9a-f]{2}){5}))?$/i, + )) !== null + ) { + res.model = match_res[1].toLowerCase(); + if (match_res[3]) { + res.macaddr = match_res[3]; + } + } else if ((match_res = p.match(/^bridge=(\S+)$/)) !== null) { + res.bridge = match_res[1]; + } else if ((match_res = p.match(/^rate=(\d+(\.\d+)?|\.\d+)$/)) !== null) { + res.rate = match_res[1]; + } else if ((match_res = p.match(/^tag=(\d+(\.\d+)?)$/)) !== null) { + res.tag = match_res[1]; + } else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) { + res.firewall = match_res[1]; + } else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) { + res.disconnect = match_res[1]; + } else if ((match_res = p.match(/^queues=(\d+)$/)) !== null) { + res.queues = match_res[1]; + } else if ( + (match_res = p.match(/^trunks=(\d+(?:-\d+)?(?:;\d+(?:-\d+)?)*)$/)) !== null + ) { + res.trunks = match_res[1]; + } else if ((match_res = p.match(/^mtu=(\d+)$/)) !== null) { + res.mtu = match_res[1]; + } else { + errors = true; + return false; // break + } + return undefined; // continue + }); + + if (errors || !res.model) { + return undefined; + } + + return res; + }, + + printQemuNetwork: function (net) { + var netstr = net.model; + if (net.macaddr) { + netstr += '=' + net.macaddr; + } + if (net.bridge) { + netstr += ',bridge=' + net.bridge; + if (net.tag) { + netstr += ',tag=' + net.tag; + } + if (net.firewall) { + netstr += ',firewall=' + net.firewall; + } + } + if (net.rate) { + netstr += ',rate=' + net.rate; + } + if (net.queues) { + netstr += ',queues=' + net.queues; + } + if (net.disconnect) { + netstr += ',link_down=' + net.disconnect; + } + if (net.trunks) { + netstr += ',trunks=' + net.trunks; + } + if (net.mtu) { + netstr += ',mtu=' + net.mtu; + } + return netstr; + }, + + parseQemuDrive: function (key, value) { + if (!(key && value)) { + return undefined; + } + + const [, bus, index] = key.match(/^([a-z]+)(\d+)$/); + if (!bus) { + return undefined; + } + let res = { + interface: bus, + index, + }; + + var errors = false; + Ext.Array.each(value.split(','), function (p) { + if (!p || p.match(/^\s*$/)) { + return undefined; // continue + } + let match = p.match(/^([a-z_]+)=(\S+)$/); + if (!match) { + if (!p.match(/[=]/)) { + res.file = p; + return undefined; // continue + } + errors = true; + return false; // break + } + let [, k, v] = match; + if (k === 'volume') { + k = 'file'; + } + + if (Ext.isDefined(res[k])) { + errors = true; + return false; // break + } + + if (k === 'cache' && v === 'off') { + v = 'none'; + } + + res[k] = v; + + return undefined; // continue + }); + + if (errors || !res.file) { + return undefined; + } + + return res; + }, + + printQemuDrive: function (drive) { + var drivestr = drive.file; + + Ext.Object.each(drive, function (key, value) { + if ( + !Ext.isDefined(value) || + key === 'file' || + key === 'index' || + key === 'interface' + ) { + return; // continue + } + drivestr += ',' + key + '=' + value; + }); + + return drivestr; + }, + + parseIPConfig: function (key, value) { + if (!(key && value)) { + return undefined; // continue + } + + let res = {}; + try { + value.split(',').forEach((p) => { + if (!p || p.match(/^\s*$/)) { + return; // continue + } + + const match = p.match(/^(ip|gw|ip6|gw6)=(\S+)$/); + if (!match) { + throw `could not parse as IP config: ${p}`; + } + let [, k, v] = match; + res[k] = v; + }); + } catch (err) { + console.warn(err); + return undefined; // continue + } + + return res; + }, + + printIPConfig: function (cfg) { + return Object.entries(cfg) + .filter(([k, v]) => v && k.match(/^(ip|gw|ip6|gw6)$/)) + .map(([k, v]) => `${k}=${v}`) + .join(','); + }, + + parseLxcNetwork: function (value) { + if (!value) { + return undefined; + } + + let data = {}; + value.split(',').forEach((p) => { + if (!p || p.match(/^\s*$/)) { + return; // continue + } + let match_res = p.match(/^(bridge|hwaddr|mtu|name|ip|ip6|gw|gw6|tag|rate)=(\S+)$/); + if (match_res) { + data[match_res[1]] = match_res[2]; + } else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) { + data.firewall = PVE.Parser.parseBoolean(match_res[1]); + } else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) { + data.link_down = PVE.Parser.parseBoolean(match_res[1]); + } else if ((match_res = p.match(/^host-managed=(\d+)$/)) !== null) { + data['host-managed'] = PVE.Parser.parseBoolean(match_res[1]); + } else if (!p.match(/^type=\S+$/)) { + console.warn(`could not parse LXC network string ${p}`); + } + }); + + return data; + }, + + printLxcNetwork: function (config) { + let knownKeys = { + bridge: 1, + firewall: 1, + gw6: 1, + gw: 1, + hwaddr: 1, + ip6: 1, + ip: 1, + mtu: 1, + name: 1, + rate: 1, + tag: 1, + link_down: 1, + 'host-managed': 1, + }; + return Object.entries(config) + .filter(([k, v]) => v !== undefined && v !== '' && knownKeys[k]) + .map(([k, v]) => `${k}=${v}`) + .join(','); + }, + + parseLxcMountPoint: function (value) { + if (!value) { + return undefined; + } + + let res = {}; + let errors = false; + Ext.Array.each(value.split(','), function (p) { + if (!p || p.match(/^\s*$/)) { + return undefined; // continue + } + let match = p.match(/^([a-z_]+)=(.+)$/); + if (!match) { + if (!p.match(/[=]/)) { + res.file = p; + return undefined; // continue + } + errors = true; + return false; // break + } + let [, k, v] = match; + if (k === 'volume') { + k = 'file'; + } + + if (Ext.isDefined(res[k])) { + errors = true; + return false; // break + } + + res[k] = v; + + return undefined; + }); + + if (errors || !res.file) { + return undefined; + } + + const match = res.file.match(/^([a-z][a-z0-9\-_.]*[a-z0-9]):/i); + if (match) { + res.storage = match[1]; + res.type = 'volume'; + } else if (res.file.match(/^\/dev\//)) { + res.type = 'device'; + } else { + res.type = 'bind'; + } + + return res; + }, + + printLxcMountPoint: function (mp) { + let drivestr = mp.file; + for (const [key, value] of Object.entries(mp)) { + if ( + !Ext.isDefined(value) || + key === 'file' || + key === 'type' || + key === 'storage' + ) { + continue; + } + drivestr += `,${key}=${value}`; + } + return drivestr; + }, + + parseStartup: function (value) { + if (value === undefined) { + return undefined; + } + + let res = {}; + try { + value.split(',').forEach((p) => { + if (!p || p.match(/^\s*$/)) { + return; // continue + } + + let match_res; + if ((match_res = p.match(/^(order)?=(\d+)$/)) !== null) { + res.order = match_res[2]; + } else if ((match_res = p.match(/^up=(\d+)$/)) !== null) { + res.up = match_res[1]; + } else if ((match_res = p.match(/^down=(\d+)$/)) !== null) { + res.down = match_res[1]; + } else { + throw `could not parse startup config ${p}`; + } + }); + } catch (err) { + console.warn(err); + return undefined; + } + + return res; + }, + + printStartup: function (startup) { + let arr = []; + if (startup.order !== undefined && startup.order !== '') { + arr.push('order=' + startup.order); + } + if (startup.up !== undefined && startup.up !== '') { + arr.push('up=' + startup.up); + } + if (startup.down !== undefined && startup.down !== '') { + arr.push('down=' + startup.down); + } + + return arr.join(','); + }, + + parseQemuSmbios1: function (value) { + let res = value.split(',').reduce((acc, currentValue) => { + const [k, v] = currentValue.split(/[=](.+)/); + acc[k] = v; + return acc; + }, {}); + + if (PVE.Parser.parseBoolean(res.base64, false)) { + for (const [k, v] of Object.entries(res)) { + if (k !== 'uuid') { + res[k] = Ext.util.Base64.decode(v); + } + } + } + + return res; + }, + + printQemuSmbios1: function (data) { + let base64 = false; + let datastr = Object.entries(data) + .map(([key, value]) => { + if (value === '') { + return undefined; + } + if (key !== 'uuid') { + base64 = true; // smbios values can be arbitrary, so encode and mark config as such + value = Ext.util.Base64.encode(value); + } + return `${key}=${value}`; + }) + .filter((v) => v !== undefined) + .join(','); + + if (base64) { + datastr += ',base64=1'; + } + return datastr; + }, + + parseTfaConfig: function (value) { + let res = {}; + value.split(',').forEach((p) => { + const [k, v] = p.split('=', 2); + res[k] = v; + }); + + return res; + }, + + parseTfaType: function (value) { + let match; + if (!value || !value.length) { + return undefined; + } else if (value === 'x!oath') { + return 'totp'; + } else if ((match = value.match(/^x!(.+)$/)) !== null) { + return match[1]; + } else { + return 1; + } + }, + + parseQemuCpu: function (value) { + if (!value) { + return {}; + } + + let res = {}; + let errors = false; + Ext.Array.each(value.split(','), function (p) { + if (!p || p.match(/^\s*$/)) { + return undefined; // continue + } + + if (!p.match(/[=]/)) { + if (Ext.isDefined(res.cpu)) { + errors = true; + return false; // break + } + res.cputype = p; + return undefined; // continue + } + + let match = p.match(/^([a-z_-]+)=(\S+)$/); + if (!match || Ext.isDefined(res[match[1]])) { + errors = true; + return false; // break + } + + let [, k, v] = match; + res[k] = v; + + return undefined; + }); + + if (errors || !res.cputype) { + return undefined; + } + + return res; + }, + + printQemuCpu: function (cpu) { + let cpustr = cpu.cputype; + let optstr = ''; + + Ext.Object.each(cpu, function (key, value) { + if (!Ext.isDefined(value) || key === 'cputype') { + return; // continue + } + optstr += ',' + key + '=' + value; + }); + + if (!cpustr) { + if (optstr) { + return 'kvm64' + optstr; + } else { + return undefined; + } + } + + return cpustr + optstr; + }, + + parseSSHKey: function (key) { + // |--- options can have quotes--| type key comment + let keyre = /^(?:((?:[^\s"]|"(?:\\.|[^"\\])*")+)\s+)?(\S+)\s+(\S+)(?:\s+(.*))?$/; + let typere = + /^(?:(?:sk-)?(?:ssh-(?:dss|rsa|ed25519)|ecdsa-sha2-nistp\d+)(?:@(?:[a-z0-9_-]+\.)+[a-z]{2,})?)$/; + + let m = key.match(keyre); + if (!m || m.length < 3 || !m[2]) { + // [2] is always either type or key + return null; + } + if (m[1] && m[1].match(typere)) { + return { + type: m[1], + key: m[2], + comment: m[3], + }; + } + if (m[2].match(typere)) { + return { + options: m[1], + type: m[2], + key: m[3], + comment: m[4], + }; + } + return null; + }, + + parseACMEPluginData: function (data) { + let res = {}; + let extradata = []; + data.split('\n').forEach((line) => { + // capture everything after the first = as value + let [key, value] = line.split(/[=](.+)/); + if (value !== undefined) { + res[key] = value; + } else { + extradata.push(line); + } + }); + return [res, extradata]; + }, + + filterPropertyStringList: function (list, filterFn, defaultKey) { + return list.filter((entry) => + filterFn(PVE.Parser.parsePropertyString(entry, defaultKey)), + ); + }, + }, +}); +/* This state provider keeps part of the state inside the browser history. + * + * We compress (shorten) url using dictionary based compression, i.e., we use + * column separated list instead of url encoded hash: + * #v\d* version/format + * := indicates string values + * :\d+ lookup value in dictionary hash + * #v1:=value1:5:=value2:=value3:... + */ + +Ext.define('PVE.StateProvider', { + extend: 'Ext.state.LocalStorageProvider', + + // private + setHV: function (name, newvalue, fireEvents) { + let me = this; + + let changes = false; + let oldtext = Ext.encode(me.UIState[name]); + let newtext = Ext.encode(newvalue); + if (newtext !== oldtext) { + changes = true; + me.UIState[name] = newvalue; + if (fireEvents) { + me.fireEvent('statechange', me, name, { value: newvalue }); + } + } + return changes; + }, + + // private + hslist: [ + // order is important for notifications + // [ name, default ] + ['view', 'server'], + ['rid', 'root'], + ['ltab', 'tasks'], + ['nodetab', ''], + ['storagetab', ''], + ['sdntab', ''], + ['pooltab', ''], + ['kvmtab', ''], + ['lxctab', ''], + ['dctab', ''], + ], + + hprefix: 'v1', + + compDict: { + tfa: 54, + sdn: 53, + cloudinit: 52, + replication: 51, + system: 50, + monitor: 49, + 'ha-fencing': 48, + 'ha-rules': 47, + 'ha-resources': 46, + 'ceph-log': 45, + 'ceph-crushmap': 44, + 'ceph-pools': 43, + 'ceph-osdtree': 42, + 'ceph-disklist': 41, + 'ceph-monlist': 40, + 'ceph-config': 39, + ceph: 38, + 'firewall-fwlog': 37, + 'firewall-options': 36, + 'firewall-ipset': 35, + 'firewall-aliases': 34, + 'firewall-sg': 33, + firewall: 32, + apt: 31, + members: 30, + snapshot: 29, + ha: 28, + support: 27, + pools: 26, + syslog: 25, + ubc: 24, + initlog: 23, + openvz: 22, + backup: 21, + resources: 20, + content: 19, + root: 18, + domains: 17, + roles: 16, + groups: 15, + users: 14, + time: 13, + dns: 12, + network: 11, + services: 10, + options: 9, + console: 8, + hardware: 7, + permissions: 6, + summary: 5, + tasks: 4, + clog: 3, + storage: 2, + folder: 1, + server: 0, + }, + + decodeHToken: function (token) { + let me = this; + + let state = {}; + if (!token) { + me.hslist.forEach(([k, v]) => { + state[k] = v; + }); + return state; + } + + let [prefix, ...items] = token.split(':'); + + if (prefix !== me.hprefix) { + return me.decodeHToken(); + } + + Ext.Array.each(me.hslist, function (rec) { + let value = items.shift(); + if (value) { + if (value[0] === '=') { + value = decodeURIComponent(value.slice(1)); + } + for (const [key, hash] of Object.entries(me.compDict)) { + if (String(value) === String(hash)) { + value = key; + break; + } + } + } + state[rec[0]] = value; + }); + + return state; + }, + + encodeHToken: function (state) { + let me = this; + + let ctoken = me.hprefix; + Ext.Array.each(me.hslist, function (rec) { + let value = state[rec[0]]; + if (!Ext.isDefined(value)) { + value = rec[1]; + } + value = encodeURIComponent(value); + if (!value) { + ctoken += ':'; + } else if (Ext.isDefined(me.compDict[value])) { + ctoken += ':' + me.compDict[value]; + } else { + ctoken += ':=' + value; + } + }); + + return ctoken; + }, + + constructor: function (config) { + let me = this; + + me.callParent([config]); + + me.UIState = me.decodeHToken(); // set default + + let history_change_cb = function (token) { + if (!token) { + Ext.History.back(); + return; + } + + let newstate = me.decodeHToken(token); + Ext.Array.each(me.hslist, function (rec) { + if (typeof newstate[rec[0]] === 'undefined') { + return; + } + me.setHV(rec[0], newstate[rec[0]], true); + }); + }; + + let start_token = Ext.History.getToken(); + if (start_token) { + history_change_cb(start_token); + } else { + let htext = me.encodeHToken(me.UIState); + Ext.History.add(htext); + } + + Ext.History.on('change', history_change_cb); + }, + + get: function (name, defaultValue) { + let me = this; + + let data; + if (typeof me.UIState[name] !== 'undefined') { + data = { value: me.UIState[name] }; + } else { + data = me.callParent(arguments); + if (!data && name === 'GuiCap') { + data = { + vms: {}, + storage: {}, + access: {}, + nodes: {}, + dc: {}, + sdn: {}, + }; + } + } + return data; + }, + + clear: function (name) { + let me = this; + + if (typeof me.UIState[name] !== 'undefined') { + me.UIState[name] = null; + } + me.callParent(arguments); + }, + + set: function (name, value, fireevent) { + let me = this; + + if (typeof me.UIState[name] !== 'undefined') { + let newvalue = value ? value.value : null; + if (me.setHV(name, newvalue, fireevent)) { + let htext = me.encodeHToken(me.UIState); + Ext.History.add(htext); + } + } else { + me.callParent(arguments); + } + }, +}); +Ext.ns('PVE'); + +console.log('Starting Proxmox VE Manager'); + +Ext.Ajax.defaultHeaders = { + Accept: 'application/json', +}; + +Ext.define('PVE.Utils', { + utilities: { + // this singleton contains miscellaneous utilities + + toolkit: undefined, // (extjs|touch), set inside Toolkit.js + + bus_match: /^(ide|sata|virtio|scsi)(\d+)$/, + + log_severity_hash: { + 0: 'panic', + 1: 'alert', + 2: 'critical', + 3: 'error', + 4: 'warning', + 5: 'notice', + 6: 'info', + 7: 'debug', + }, + + support_level_hash: { + c: gettext('Community'), + b: gettext('Basic'), + s: gettext('Standard'), + p: gettext('Premium'), + }, + + noSubKeyHtml: + 'You do not have a valid subscription for this server. Please visit ' + + '' + + 'www.proxmox.com to get a list of available options.', + + getClusterSubscriptionLevel: async function () { + let { result } = await Proxmox.Async.api2({ url: '/cluster/status' }); + let levelMap = Object.fromEntries( + result.data.filter((v) => v.type === 'node').map((v) => [v.name, v.level]), + ); + return levelMap; + }, + + kvm_ostypes: { + Linux: [ + { desc: '6.x - 2.6 Kernel', val: 'l26' }, + { desc: '2.4 Kernel', val: 'l24' }, + ], + 'Microsoft Windows': [ + { desc: '11/2022/2025', val: 'win11' }, + { desc: '10/2016/2019', val: 'win10' }, + { desc: '8.x/2012/2012r2', val: 'win8' }, + { desc: '7/2008r2', val: 'win7' }, + { desc: 'Vista/2008', val: 'w2k8' }, + { desc: 'XP/2003', val: 'wxp' }, + { desc: '2000', val: 'w2k' }, + ], + 'Solaris Kernel': [{ desc: '-', val: 'solaris' }], + Other: [{ desc: '-', val: 'other' }], + }, + + is_windows: function (ostype) { + for (let entry of PVE.Utils.kvm_ostypes['Microsoft Windows']) { + if (entry.val === ostype) { + return true; + } + } + return false; + }, + + get_health_icon: function (state, circle) { + if (circle === undefined) { + circle = false; + } + + if (state === undefined) { + state = 'uknown'; + } + + var icon = 'faded fa-question'; + switch (state) { + case 'good': + icon = 'good fa-check'; + break; + case 'upgrade': + icon = 'warning fa-upload'; + break; + case 'old': + icon = 'warning fa-refresh'; + break; + case 'warning': + icon = 'warning fa-exclamation'; + break; + case 'critical': + icon = 'critical fa-times'; + break; + default: + break; + } + + if (circle) { + icon += '-circle'; + } + + return icon; + }, + + parse_ceph_version: function (service) { + if (service.ceph_version_short) { + return service.ceph_version_short; + } + + if (service.ceph_version) { + // See PVE/Ceph/Tools.pm - get_local_version + const match = service.ceph_version.match(/^ceph.*\sv?(\d+(?:\.\d+)+)/); + if (match) { + return match[1]; + } + } + + return undefined; + }, + + compare_ceph_versions: function (a, b) { + let avers = []; + let bvers = []; + + if (a === b) { + return 0; + } + + if (Ext.isArray(a)) { + avers = a.slice(); // copy array + } else { + avers = a.toString().split('.'); + } + + if (Ext.isArray(b)) { + bvers = b.slice(); // copy array + } else { + bvers = b.toString().split('.'); + } + + for (;;) { + let av = avers.shift(); + let bv = bvers.shift(); + + if (av === undefined && bv === undefined) { + return 0; + } else if (av === undefined) { + return -1; + } else if (bv === undefined) { + return 1; + } else { + let diff = parseInt(av, 10) - parseInt(bv, 10); + if (diff !== 0) { + return diff; + } + // else we need to look at the next parts + } + } + }, + + get_ceph_icon_html: function (health, fw) { + var state = PVE.Utils.map_ceph_health[health]; + var cls = PVE.Utils.get_health_icon(state); + if (fw) { + cls += ' fa-fw'; + } + return " "; + }, + + map_ceph_health: { + HEALTH_OK: 'good', + HEALTH_UPGRADE: 'upgrade', + HEALTH_OLD: 'old', + HEALTH_WARN: 'warning', + HEALTH_ERR: 'critical', + }, + + render_sdn_pending: function (rec, value, key, index) { + if (rec.data.state === undefined || rec.data.state === null) { + return Ext.htmlEncode(value); + } + + if (rec.data.state === 'deleted') { + if (value === undefined) { + return ' '; + } else { + return `${Ext.htmlEncode(value)}`; + } + } else if (rec.data.pending[key] !== undefined && rec.data.pending[key] !== null) { + if (rec.data.pending[key] === 'deleted') { + return ' '; + } else { + return Ext.htmlEncode(rec.data.pending[key]); + } + } + return Ext.htmlEncode(value); + }, + + render_sdn_pending_state: function (rec, value) { + if (value === undefined || value === null) { + return ' '; + } + + let icon = ``; + + if (value === 'deleted') { + return `${icon}${Ext.htmlEncode(value)}`; + } + + let tip = gettext('Pending Changes') + ':
'; + + for (const [key, keyvalue] of Object.entries(rec.data.pending)) { + if ( + (rec.data[key] !== undefined && rec.data.pending[key] !== rec.data[key]) || + rec.data[key] === undefined + ) { + tip += `${Ext.htmlEncode(key)}: ${Ext.htmlEncode(keyvalue)}
`; + } + } + return `${icon}${Ext.htmlEncode(value)}`; + }, + + render_ceph_health: function (healthObj) { + var state = { + iconCls: PVE.Utils.get_health_icon(), + text: '', + }; + + if (!healthObj || !healthObj.status) { + return state; + } + + var health = PVE.Utils.map_ceph_health[healthObj.status]; + + state.iconCls = PVE.Utils.get_health_icon(health, true); + state.text = healthObj.status; + + return state; + }, + + render_zfs_health: function (value) { + if (typeof value === 'undefined') { + return ''; + } + var iconCls = 'question-circle'; + switch (value) { + case 'AVAIL': + case 'ONLINE': + iconCls = 'check-circle good'; + break; + case 'REMOVED': + case 'DEGRADED': + iconCls = 'exclamation-circle warning'; + break; + case 'UNAVAIL': + case 'FAULTED': + case 'OFFLINE': + iconCls = 'times-circle critical'; + break; + default: //unknown + } + + return ' ' + value; + }, + + render_pbs_fingerprint: (fp) => fp.substring(0, 23), + + render_backup_encryption: function (v, meta, record) { + if (!v) { + return gettext('No'); + } + + let tip = ''; + if (v.match(/^[a-fA-F0-9]{2}:/)) { + // fingerprint + tip = `Key fingerprint ${PVE.Utils.render_pbs_fingerprint(v)}`; + } + let icon = ``; + return `${icon} ${gettext('Encrypted')}`; + }, + + render_backup_verification: function (v, meta, record) { + let i = (cls, txt) => ` ${txt}`; + if (v === undefined || v === null) { + return i('question-circle-o warning', gettext('None')); + } + let tip = ''; + let txt = gettext('Failed'); + let iconCls = 'times critical'; + if (v.state === 'ok') { + txt = gettext('OK'); + iconCls = 'check good'; + let now = Date.now() / 1000; + let task = Proxmox.Utils.parse_task_upid(v.upid); + let verify_time = Proxmox.Utils.render_timestamp(task.starttime); + tip = `Last verify task started on ${verify_time}`; + if (now - v.starttime > 30 * 24 * 60 * 60) { + tip = `Last verify task over 30 days ago: ${verify_time}`; + iconCls = 'check warning'; + } + } + return ` ${i(iconCls, txt)} `; + }, + + render_backup_status: function (value, meta, record) { + if (typeof value === 'undefined') { + return ''; + } + + let iconCls = 'check-circle good'; + let text = gettext('Yes'); + + if (!PVE.Parser.parseBoolean(value.toString())) { + iconCls = 'times-circle critical'; + + text = gettext('No'); + + let reason = record.get('reason'); + if (typeof reason !== 'undefined') { + if (reason in PVE.Utils.backup_reasons_table) { + reason = PVE.Utils.backup_reasons_table[record.get('reason')]; + } + text = `${text} - ${reason}`; + } + } + + return ` ${text}`; + }, + + render_backup_days_of_week: function (val) { + var dows = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + var selected = []; + var cur = -1; + val.split(',').forEach(function (day) { + cur++; + var dow = (dows.indexOf(day) + 6) % 7; + if (cur === dow) { + if (selected.length === 0 || selected[selected.length - 1] === 0) { + selected.push(1); + } else { + selected[selected.length - 1]++; + } + } else { + while (cur < dow) { + cur++; + selected.push(0); + } + selected.push(1); + } + }); + + cur = -1; + var days = []; + selected.forEach(function (item) { + cur++; + if (item > 2) { + days.push( + Ext.Date.dayNames[cur + 1] + '-' + Ext.Date.dayNames[(cur + item) % 7], + ); + cur += item - 1; + } else if (item === 2) { + days.push(Ext.Date.dayNames[cur + 1]); + days.push(Ext.Date.dayNames[(cur + 2) % 7]); + cur++; + } else if (item === 1) { + days.push(Ext.Date.dayNames[(cur + 1) % 7]); + } + }); + return days.join(', '); + }, + + render_backup_selection: function (value, metaData, record) { + let allExceptText = gettext('All except {0}'); + let allText = '-- ' + gettext('All') + ' --'; + if (record.data.all) { + if (record.data.exclude) { + return Ext.String.format(allExceptText, record.data.exclude); + } + return allText; + } + if (record.data.vmid) { + return record.data.vmid; + } + + if (record.data.pool) { + return "Pool '" + record.data.pool + "'"; + } + + return '-'; + }, + + backup_reasons_table: { + 'backup=yes': gettext('Enabled'), + 'backup=no': gettext('Disabled'), + enabled: gettext('Enabled'), + disabled: gettext('Disabled'), + 'not a volume': gettext('Not a volume'), + 'efidisk but no OMVF BIOS': gettext('EFI Disk without OMVF BIOS'), + }, + + renderNotFound: (what) => Ext.String.format(gettext('No {0} found'), what), + + get_kvm_osinfo: function (value) { + var info = { base: 'Other' }; // default + if (value) { + Ext.each(Object.keys(PVE.Utils.kvm_ostypes), function (k) { + Ext.each(PVE.Utils.kvm_ostypes[k], function (e) { + if (e.val === value) { + info = { desc: e.desc, base: k }; + } + }); + }); + } + return info; + }, + + render_kvm_ostype: function (value) { + var osinfo = PVE.Utils.get_kvm_osinfo(value); + if (osinfo.desc && osinfo.desc !== '-') { + return osinfo.base + ' ' + osinfo.desc; + } else { + return osinfo.base; + } + }, + + render_hotplug_features: function (value) { + var fa = []; + + if (!value || value === '0') { + return gettext('Disabled'); + } + + if (value === '1') { + value = 'disk,network,usb'; + } + + Ext.each(value.split(','), function (el) { + if (el === 'disk') { + fa.push(gettext('Disk')); + } else if (el === 'network') { + fa.push(gettext('Network')); + } else if (el === 'usb') { + fa.push('USB'); + } else if (el === 'memory') { + fa.push(gettext('Memory')); + } else if (el === 'cpu') { + fa.push(gettext('CPU')); + } else { + fa.push(el); + } + }); + + return fa.join(', '); + }, + + render_localtime: function (value) { + if (value === '__default__') { + return Proxmox.Utils.defaultText + ' (' + gettext('Enabled for Windows') + ')'; + } + return Proxmox.Utils.format_boolean(value); + }, + + render_qga_features: function (config) { + if (!config) { + return Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')'; + } + let qga = PVE.Parser.parsePropertyString(config, 'enabled'); + if (!PVE.Parser.parseBoolean(qga.enabled)) { + return Proxmox.Utils.disabledText; + } + delete qga.enabled; + + let agentstring = Proxmox.Utils.enabledText; + + for (const [key, value] of Object.entries(qga)) { + let displayText = Proxmox.Utils.disabledText; + if (key === 'type') { + let map = { + isa: 'ISA', + virtio: 'VirtIO', + }; + displayText = map[value] || Proxmox.Utils.unknownText; + } else if (key === 'freeze-fs-on-backup' && PVE.Parser.parseBoolean(value)) { + continue; + } else if (PVE.Parser.parseBoolean(value)) { + displayText = Proxmox.Utils.enabledText; + } + agentstring += `, ${key}: ${displayText}`; + } + + return agentstring; + }, + + render_qemu_machine: function (value) { + return value || Proxmox.Utils.defaultText + ' (i440fx)'; + }, + + render_qemu_bios: function (value) { + if (!value) { + return Proxmox.Utils.defaultText + ' (SeaBIOS)'; + } else if (value === 'seabios') { + return 'SeaBIOS'; + } else if (value === 'ovmf') { + return 'OVMF (UEFI)'; + } else { + return value; + } + }, + + render_dc_ha_opts: function (value) { + if (!value) { + return Proxmox.Utils.defaultText; + } else { + return PVE.Parser.printPropertyString(value); + } + }, + render_as_property_string: (v) => + !v ? Proxmox.Utils.defaultText : PVE.Parser.printPropertyString(v), + + render_scsihw: function (value) { + if (!value || value === '__default__') { + return Proxmox.Utils.defaultText + ' (LSI 53C895A)'; + } else if (value === 'lsi') { + return 'LSI 53C895A'; + } else if (value === 'lsi53c810') { + return 'LSI 53C810'; + } else if (value === 'megasas') { + return 'MegaRAID SAS 8708EM2'; + } else if (value === 'virtio-scsi-pci') { + return 'VirtIO SCSI'; + } else if (value === 'virtio-scsi-single') { + return 'VirtIO SCSI single'; + } else if (value === 'pvscsi') { + return 'VMware PVSCSI'; + } else { + return value; + } + }, + + render_spice_enhancements: function (values) { + let props = PVE.Parser.parsePropertyString(values); + if (Ext.Object.isEmpty(props)) { + return Proxmox.Utils.noneText; + } + + let output = []; + if (PVE.Parser.parseBoolean(props.foldersharing)) { + output.push('Folder Sharing: ' + gettext('Enabled')); + } + if (props.videostreaming === 'all' || props.videostreaming === 'filter') { + output.push('Video Streaming: ' + props.videostreaming); + } + return output.join(', '); + }, + + // fixme: auto-generate this + // for now, please keep in sync with PVE::Tools::kvmkeymaps + kvm_keymaps: { + __default__: Proxmox.Utils.defaultText, + //ar: 'Arabic', + da: 'Danish', + de: 'German', + 'de-ch': 'German (Swiss)', + 'en-gb': 'English (UK)', + 'en-us': 'English (USA)', + es: 'Spanish', + //et: 'Estonia', + fi: 'Finnish', + //fo: 'Faroe Islands', + fr: 'French', + 'fr-be': 'French (Belgium)', + 'fr-ca': 'French (Canada)', + 'fr-ch': 'French (Swiss)', + //hr: 'Croatia', + hu: 'Hungarian', + is: 'Icelandic', + it: 'Italian', + ja: 'Japanese', + lt: 'Lithuanian', + //lv: 'Latvian', + mk: 'Macedonian', + nl: 'Dutch', + //'nl-be': 'Dutch (Belgium)', + no: 'Norwegian', + pl: 'Polish', + pt: 'Portuguese', + 'pt-br': 'Portuguese (Brazil)', + //ru: 'Russian', + sl: 'Slovenian', + sv: 'Swedish', + //th: 'Thai', + tr: 'Turkish', + }, + + kvm_vga_drivers: { + __default__: Proxmox.Utils.defaultText, + std: gettext('Standard VGA'), + vmware: gettext('VMware compatible'), + qxl: 'SPICE', + qxl2: 'SPICE dual monitor', + qxl3: 'SPICE three monitors', + qxl4: 'SPICE four monitors', + serial0: gettext('Serial terminal') + ' 0', + serial1: gettext('Serial terminal') + ' 1', + serial2: gettext('Serial terminal') + ' 2', + serial3: gettext('Serial terminal') + ' 3', + virtio: 'VirtIO-GPU', + 'virtio-gl': 'VirGL GPU', + none: Proxmox.Utils.noneText, + }, + + render_kvm_language: function (value) { + if (!value || value === '__default__') { + return Proxmox.Utils.defaultText; + } + let text = PVE.Utils.kvm_keymaps[value]; + return text ? `${text} (${value})` : value; + }, + + console_map: { + __default__: Proxmox.Utils.defaultText + ' (xterm.js)', + vv: 'SPICE (remote-viewer)', + html5: 'HTML5 (noVNC)', + xtermjs: 'xterm.js', + }, + + render_console_viewer: function (value) { + value = value || '__default__'; + return PVE.Utils.console_map[value] || value; + }, + + render_kvm_vga_driver: function (value) { + if (!value) { + return Proxmox.Utils.defaultText; + } + let vga = PVE.Parser.parsePropertyString(value, 'type'); + let text = PVE.Utils.kvm_vga_drivers[vga.type]; + if (!vga.type) { + text = Proxmox.Utils.defaultText; + } + return text ? `${text} (${value})` : value; + }, + + render_kvm_startup: function (value) { + var startup = PVE.Parser.parseStartup(value); + + var res = 'order='; + if (startup.order === undefined) { + res += 'any'; + } else { + res += startup.order; + } + if (startup.up !== undefined) { + res += ',up=' + startup.up; + } + if (startup.down !== undefined) { + res += ',down=' + startup.down; + } + + return res; + }, + + extractFormActionError: function (action) { + var msg; + switch (action.failureType) { + case Ext.form.action.Action.CLIENT_INVALID: + msg = gettext('Form fields may not be submitted with invalid values'); + break; + case Ext.form.action.Action.CONNECT_FAILURE: { + msg = gettext('Connection error'); + let resp = action.response; + if (resp.status && resp.statusText) { + msg += ' ' + resp.status + ': ' + resp.statusText; + } + break; + } + case Ext.form.action.Action.LOAD_FAILURE: + case Ext.form.action.Action.SERVER_INVALID: + msg = Proxmox.Utils.extractRequestError(action.result, true); + break; + } + return msg; + }, + + contentTypes: { + images: gettext('Disk image'), + backup: gettext('Backup'), + vztmpl: gettext('Container template'), + iso: gettext('ISO image'), + rootdir: gettext('Container'), + snippets: gettext('Snippets'), + import: gettext('Import'), + }, + + // volume can be a full volume info object, in which case the format parameter is ignored, or + // you can pass the volume ID and format as separate string parameters. + volume_is_qemu_backup: function (volume, format) { + let volid, subtype; + if (typeof volume === 'string') { + volid = volume; + } else if (typeof volume === 'object') { + ({ volid, format, subtype } = volume); + } else { + console.error('internal error - unexpected type', volume); + } + return format === 'pbs-vm' || volid.match(':backup/vzdump-qemu-') || subtype === 'qemu'; + }, + + volume_is_lxc_backup: function (volume) { + return ( + volume.format === 'pbs-ct' || + volume.volid.match(':backup/vzdump-(lxc|openvz)-') || + volume.subtype === 'lxc' + ); + }, + + authSchema: { + ad: { + name: gettext('Active Directory Server'), + ipanel: 'pveAuthADPanel', + syncipanel: 'pveAuthLDAPSyncPanel', + add: true, + tfa: true, + pwchange: true, + }, + ldap: { + name: gettext('LDAP Server'), + ipanel: 'pveAuthLDAPPanel', + syncipanel: 'pveAuthLDAPSyncPanel', + add: true, + tfa: true, + pwchange: true, + }, + openid: { + name: gettext('OpenID Connect Server'), + ipanel: 'pveAuthOpenIDPanel', + add: true, + tfa: false, + pwchange: false, + iconCls: 'pmx-itype-icon-openid-logo', + }, + pam: { + name: 'Linux PAM', + ipanel: 'pveAuthBasePanel', + add: false, + tfa: true, + pwchange: true, + }, + pve: { + name: 'Proxmox VE authentication server', + ipanel: 'pveAuthBasePanel', + add: false, + tfa: true, + pwchange: true, + }, + }, + + storageSchema: { + dir: { + name: Proxmox.Utils.directoryText, + ipanel: 'DirInputPanel', + faIcon: 'folder', + backups: true, + }, + lvm: { + name: 'LVM', + ipanel: 'LVMInputPanel', + faIcon: 'folder', + backups: false, + }, + lvmthin: { + name: 'LVM-Thin', + ipanel: 'LvmThinInputPanel', + faIcon: 'folder', + backups: false, + }, + btrfs: { + name: 'BTRFS', + ipanel: 'BTRFSInputPanel', + faIcon: 'folder', + backups: true, + }, + nfs: { + name: 'NFS', + ipanel: 'NFSInputPanel', + faIcon: 'building', + backups: true, + }, + cifs: { + name: 'SMB/CIFS', + ipanel: 'CIFSInputPanel', + faIcon: 'building', + backups: true, + }, + iscsi: { + name: 'iSCSI', + ipanel: 'IScsiInputPanel', + faIcon: 'building', + backups: false, + }, + cephfs: { + name: 'CephFS', + ipanel: 'CephFSInputPanel', + faIcon: 'building', + backups: true, + }, + pvecephfs: { + name: 'CephFS (PVE)', + ipanel: 'CephFSInputPanel', + hideAdd: true, + faIcon: 'building', + backups: true, + }, + rbd: { + name: 'RBD', + ipanel: 'RBDInputPanel', + faIcon: 'building', + backups: false, + }, + pveceph: { + name: 'RBD (PVE)', + ipanel: 'RBDInputPanel', + hideAdd: true, + faIcon: 'building', + backups: false, + }, + zfs: { + name: 'ZFS over iSCSI', + ipanel: 'ZFSInputPanel', + faIcon: 'building', + backups: false, + }, + zfspool: { + name: 'ZFS', + ipanel: 'ZFSPoolInputPanel', + faIcon: 'folder', + backups: false, + }, + pbs: { + name: 'Proxmox Backup Server', + ipanel: 'PBSInputPanel', + faIcon: 'floppy-o', + backups: true, + }, + drbd: { + name: 'DRBD', + hideAdd: true, + backups: false, + }, + esxi: { + name: 'ESXi', + ipanel: 'ESXIInputPanel', + faIcon: 'cloud-download', + backups: false, + }, + }, + + sdnvnetSchema: { + vnet: { + name: 'vnet', + faIcon: 'folder', + }, + }, + + sdnzoneSchema: { + zone: { + name: 'zone', + hideAdd: true, + }, + simple: { + name: 'Simple', + ipanel: 'SimpleInputPanel', + faIcon: 'th', + }, + vlan: { + name: 'VLAN', + ipanel: 'VlanInputPanel', + faIcon: 'th', + }, + qinq: { + name: 'QinQ', + ipanel: 'QinQInputPanel', + faIcon: 'th', + }, + vxlan: { + name: 'VXLAN', + ipanel: 'VxlanInputPanel', + faIcon: 'th', + }, + evpn: { + name: 'EVPN', + ipanel: 'EvpnInputPanel', + faIcon: 'th', + }, + }, + + sdncontrollerSchema: { + controller: { + name: 'controller', + hideAdd: true, + }, + evpn: { + name: 'evpn', + ipanel: 'EvpnInputPanel', + faIcon: 'crosshairs', + }, + bgp: { + name: 'bgp', + ipanel: 'BgpInputPanel', + faIcon: 'crosshairs', + }, + isis: { + name: 'isis', + ipanel: 'IsisInputPanel', + faIcon: 'crosshairs', + }, + }, + + sdnipamSchema: { + ipam: { + name: 'ipam', + hideAdd: true, + }, + pve: { + name: 'PVE', + ipanel: 'PVEIpamInputPanel', + faIcon: 'th', + hideAdd: true, + }, + netbox: { + name: 'Netbox', + ipanel: 'NetboxInputPanel', + faIcon: 'th', + }, + phpipam: { + name: 'PhpIpam', + ipanel: 'PhpIpamInputPanel', + faIcon: 'th', + }, + }, + + sdndnsSchema: { + dns: { + name: 'dns', + hideAdd: true, + }, + powerdns: { + name: 'powerdns', + ipanel: 'PowerdnsInputPanel', + faIcon: 'th', + }, + }, + + format_sdnvnet_type: function (value, md, record) { + var schema = PVE.Utils.sdnvnetSchema[value]; + if (schema) { + return schema.name; + } + return Proxmox.Utils.unknownText; + }, + + format_sdnzone_type: function (value, md, record) { + var schema = PVE.Utils.sdnzoneSchema[value]; + if (schema) { + return schema.name; + } + return Proxmox.Utils.unknownText; + }, + + format_sdncontroller_type: function (value, md, record) { + var schema = PVE.Utils.sdncontrollerSchema[value]; + if (schema) { + return schema.name; + } + return Proxmox.Utils.unknownText; + }, + + format_sdnipam_type: function (value, md, record) { + var schema = PVE.Utils.sdnipamSchema[value]; + if (schema) { + return schema.name; + } + return Proxmox.Utils.unknownText; + }, + + format_sdndns_type: function (value, md, record) { + var schema = PVE.Utils.sdndnsSchema[value]; + if (schema) { + return schema.name; + } + return Proxmox.Utils.unknownText; + }, + + format_storage_type: function (value, md, record) { + if (value === 'rbd') { + value = !record || record.get('monhost') ? 'rbd' : 'pveceph'; + } else if (value === 'cephfs') { + value = !record || record.get('monhost') ? 'cephfs' : 'pvecephfs'; + } + + let schema = PVE.Utils.storageSchema[value]; + return schema?.name ?? value; + }, + + format_ha: function (value) { + var text = Proxmox.Utils.noneText; + + if (value.managed) { + text = value.state || Proxmox.Utils.noneText; + } + + return text; + }, + + format_content_types: function (value) { + return value + .split(',') + .sort() + .map(function (ct) { + return PVE.Utils.contentTypes[ct] || ct; + }) + .join(', '); + }, + + render_storage_content: function (value, metaData, record) { + let data = record.data; + let result; + if (Ext.isNumber(data.channel) && Ext.isNumber(data.id) && Ext.isNumber(data.lun)) { + result = + 'CH ' + + Ext.String.leftPad(data.channel, 2, '0') + + ' ID ' + + data.id + + ' LUN ' + + data.lun; + } else if (data.content === 'import') { + if (data.volid.match(/^.*?:import\//)) { + // dir-based storages + result = data.volid.replace(/^.*?:import\//, ''); + } else { + // esxi storage + result = data.volid.replace(/^.*?:/, ''); + } + } else { + result = data.volid.replace(/^.*?:(.*?\/)?/, ''); + } + return Ext.String.htmlEncode(result); + }, + + render_serverity: function (value) { + return PVE.Utils.log_severity_hash[value] || value; + }, + + calculate_hostcpu: function (data) { + if (!(data.uptime && Ext.isNumeric(data.cpu))) { + return -1; + } + + if (data.type !== 'qemu' && data.type !== 'lxc') { + return -1; + } + + let node = PVE.data.ResourceStore.getNodeById(data.node); + if (!Ext.isDefined(node) || node === null) { + return -1; + } + var maxcpu = node.data.maxcpu || 1; + + if (!Ext.isNumeric(maxcpu) && maxcpu >= 1) { + return -1; + } + + return (data.cpu / maxcpu) * data.maxcpu; + }, + + render_hostcpu: function (value, metaData, record, rowIndex, colIndex, store) { + if (!(record.data.uptime && Ext.isNumeric(record.data.cpu))) { + return ''; + } + + if (record.data.type !== 'qemu' && record.data.type !== 'lxc') { + return ''; + } + + let node = PVE.data.ResourceStore.getNodeById(record.data.node); + if (!Ext.isDefined(node) || node === null) { + return ''; + } + var maxcpu = node.data.maxcpu || 1; + + if (!Ext.isNumeric(maxcpu) || maxcpu < 1) { + return ''; + } + + var per = (record.data.cpu / maxcpu) * record.data.maxcpu * 100; + const cpu_label = maxcpu > 1 ? 'CPUs' : 'CPU'; + + return `${per.toFixed(1)}% of ${maxcpu} ${cpu_label}`; + }, + + render_bandwidth: function (value) { + if (!Ext.isNumeric(value)) { + return ''; + } + + return Proxmox.Utils.format_size(value) + '/s'; + }, + + render_timestamp_human_readable: function (value) { + return Ext.Date.format(new Date(value * 1000), 'l d F Y H:i:s'); + }, + + // render a timestamp or pending + render_next_event: function (value) { + if (!value) { + return '-'; + } + let now = new Date(), + next = new Date(value * 1000); + if (next < now) { + return gettext('pending'); + } + return Proxmox.Utils.render_timestamp(value); + }, + + calculate_mem_usage: function (data) { + if (!Ext.isNumeric(data.mem) || data.maxmem === 0 || data.uptime < 1) { + return -1; + } + + return data.mem / data.maxmem; + }, + + calculate_hostmem_usage: function (data) { + if (data.type !== 'qemu' && data.type !== 'lxc') { + return -1; + } + + let node = PVE.data.ResourceStore.getNodeById(data.node); + + if (!Ext.isDefined(node) || node === null) { + return -1; + } + var maxmem = node.data.maxmem || 0; + + if (!Ext.isNumeric(data.mem) || maxmem === 0 || data.uptime < 1) { + return -1; + } + + if (data.type === 'qemu' && Ext.isNumeric(data.memhost)) { + return data.memhost / maxmem; + } + return data.mem / maxmem; + }, + + render_mem_usage_percent: function (value, metaData, record, rowIndex, colIndex, store) { + if (!Ext.isNumeric(value) || value === -1) { + return ''; + } + if (value > 1) { + // we got no percentage but bytes + let mem = value; + let maxmem = record.data.maxmem; + if (!record.data.uptime || maxmem === 0 || !Ext.isNumeric(mem)) { + return ''; + } + + return ((mem * 100) / maxmem).toFixed(1) + ' %'; + } + return (value * 100).toFixed(1) + ' %'; + }, + + render_hostmem_usage_percent: function ( + value, + metaData, + record, + rowIndex, + colIndex, + store, + ) { + if (!Ext.isNumeric(record.data.mem) || value === -1) { + return ''; + } + + if (record.data.type !== 'qemu' && record.data.type !== 'lxc') { + return ''; + } + + let node = PVE.data.ResourceStore.getNodeById(record.data.node); + var maxmem = node.data.maxmem || 0; + + if (record.data.mem > 1) { + // we got no percentage but bytes + let mem = record.data.mem; + if (record.data.type === 'qemu' && Ext.isNumeric(record.data.memhost)) { + mem = record.data.memhost; + } + if (!record.data.uptime || maxmem === 0 || !Ext.isNumeric(mem)) { + return ''; + } + + return ((mem * 100) / maxmem).toFixed(1) + ' %'; + } + return (value * 100).toFixed(1) + ' %'; + }, + + render_mem_usage: function (value, metaData, record, rowIndex, colIndex, store) { + var mem = value; + var maxmem = record.data.maxmem; + + if (!record.data.uptime) { + return ''; + } + + if (!(Ext.isNumeric(mem) && maxmem)) { + return ''; + } + + return Proxmox.Utils.render_size(value); + }, + + calculate_disk_usage: function (data) { + if ( + !Ext.isNumeric(data.disk) || + ((data.type === 'qemu' || data.type === 'lxc') && data.uptime === 0) || + data.maxdisk === 0 + ) { + return -1; + } + + return data.disk / data.maxdisk; + }, + + render_disk_usage_percent: function (value, metaData, record, rowIndex, colIndex, store) { + if (!Ext.isNumeric(value) || value === -1) { + return ''; + } + + return (value * 100).toFixed(1) + ' %'; + }, + + render_disk_usage: function (value, metaData, record, rowIndex, colIndex, store) { + var disk = value; + var maxdisk = record.data.maxdisk; + var type = record.data.type; + + if ( + !Ext.isNumeric(disk) || + maxdisk === 0 || + ((type === 'qemu' || type === 'lxc') && record.data.uptime === 0) + ) { + return ''; + } + + return Proxmox.Utils.render_size(value); + }, + + get_object_icon_class: function (type, record) { + var status = ''; + var objType = type; + + if (type === 'type') { + // for folder view + objType = record.groupbyid; + } else if (record.template) { + // templates + objType = 'template'; + status = type; + } else if (type === 'network') { + const networkTypeMapping = { + fabric: 'fa fa-road', + zone: 'fa fa-th', + }; + + return networkTypeMapping[record['network-type']] ?? ''; + } else if (type === 'storage' && record.content === 'import') { + return 'fa fa-cloud-download'; + } else { + // everything else + status = record.status + ' ha-' + record.hastate; + } + + if (record.lock) { + status += ' locked lock-' + record.lock; + } + + var defaults = PVE.tree.ResourceTree.typeDefaults[objType]; + if (defaults && defaults.iconCls) { + return defaults.iconCls + ' ' + status; + } + + return ''; + }, + + render_resource_type: function (value, metaData, record, rowIndex, colIndex, store) { + var cls = PVE.Utils.get_object_icon_class(value, record.data); + + var fa = ' '; + + if (value === 'network') { + return fa + record.data['network-type']; + } + + return fa + value; + }, + + render_support_level: function (value, metaData, record) { + return PVE.Utils.support_level_hash[value] || '-'; + }, + + render_upid: function (value, metaData, record) { + var type = record.data.type; + var id = record.data.id; + + return Ext.htmlEncode(Proxmox.Utils.format_task_description(type, id)); + }, + + render_optional_url: function (value) { + if (value && value.match(/^https?:\/\//)) { + return '' + value + ''; + } + return value; + }, + + render_san: function (value) { + var names = []; + if (Ext.isArray(value)) { + value.forEach(function (val) { + if (!Ext.isNumber(val)) { + names.push(val); + } + }); + return names.join('
'); + } + return value; + }, + + render_full_name: function (firstname, metaData, record) { + var first = firstname || ''; + var last = record.data.lastname || ''; + return Ext.htmlEncode(first + ' ' + last); + }, + + // expecting the following format: + // [v2:10.10.10.1:6802/2008,v1:10.10.10.1:6803/2008] + render_ceph_osd_addr: function (value) { + value = value.trim(); + if (value.startsWith('[') && value.endsWith(']')) { + value = value.slice(1, -1); // remove [] + } + value = value.replaceAll(',', '\n'); // split IPs in lines + let retVal = ''; + for (const i of value.matchAll(/^(v[0-9]):(.*):([0-9]*)\/([0-9]*)$/gm)) { + retVal += `${i[1]}: ${i[2]}:${i[3]}
`; + } + return retVal.length < 1 ? value : retVal; + }, + + windowHostname: function () { + return window.location.hostname.replace( + Proxmox.Utils.IP6_bracket_match, + function (m, addr, offset, original) { + return addr; + }, + ); + }, + + openDefaultConsoleWindow: function (consoles, consoleType, vmid, nodename, vmname, cmd) { + var dv = PVE.Utils.defaultViewer(consoles, consoleType); + PVE.Utils.openConsoleWindow(dv, consoleType, vmid, nodename, vmname, cmd); + }, + + openConsoleWindow: function (viewer, consoleType, vmid, nodename, vmname, cmd) { + if (vmid === undefined && (consoleType === 'kvm' || consoleType === 'lxc')) { + throw 'missing vmid'; + } + if (!nodename) { + throw 'no nodename specified'; + } + + if (viewer === 'html5') { + PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd); + } else if (viewer === 'xtermjs') { + Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd); + } else if (viewer === 'vv') { + let url = '/nodes/' + nodename + '/spiceshell'; + let params = { + proxy: PVE.Utils.windowHostname(), + }; + if (consoleType === 'kvm') { + url = '/nodes/' + nodename + '/qemu/' + vmid.toString() + '/spiceproxy'; + } else if (consoleType === 'lxc') { + url = '/nodes/' + nodename + '/lxc/' + vmid.toString() + '/spiceproxy'; + } else if (consoleType === 'upgrade') { + params.cmd = 'upgrade'; + } else if (consoleType === 'cmd') { + params.cmd = cmd; + } else if (consoleType !== 'shell') { + throw `unknown spice viewer type '${consoleType}'`; + } + PVE.Utils.openSpiceViewer(url, params); + } else { + throw `unknown viewer type '${viewer}'`; + } + }, + + defaultViewer: function (consoles, type) { + var allowSpice, allowXtermjs; + + if (consoles === true) { + allowSpice = true; + allowXtermjs = true; + } else if (typeof consoles === 'object') { + allowSpice = consoles.spice; + allowXtermjs = !!consoles.xtermjs; + } + let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs'); + if (dv === 'vv' && !allowSpice) { + dv = allowXtermjs ? 'xtermjs' : 'html5'; + } else if (dv === 'xtermjs' && !allowXtermjs) { + dv = allowSpice ? 'vv' : 'html5'; + } + + return dv; + }, + + openVNCViewer: function (vmtype, vmid, nodename, vmname, cmd) { + let scaling = 'off'; + if (Proxmox.Utils.toolkit !== 'touch') { + let sp = Ext.state.Manager.getProvider(); + scaling = sp.get('novnc-scaling', 'off'); + } + var url = Ext.Object.toQueryString({ + console: vmtype, // kvm, lxc, upgrade or shell + novnc: 1, + vmid: vmid, + vmname: vmname, + node: nodename, + resize: scaling, + cmd: cmd, + }); + var nw = window.open('?' + url, '_blank', 'innerWidth=745,innerheight=427'); + if (nw) { + nw.focus(); + } + }, + + openSpiceViewer: function (url, params) { + var downloadWithName = function (uri, name) { + var link = Ext.DomHelper.append(document.body, { + tag: 'a', + href: uri, + css: 'display:none;visibility:hidden;height:0px;', + }); + + // Note: we need to tell Android, AppleWebKit and Chrome + // the correct file name extension + // but we do not set 'download' tag for other environments, because + // It can have strange side effects (additional user prompt on firefox) + if (navigator.userAgent.match(/Android|AppleWebKit|Chrome/i)) { + link.download = name; + } + + if (link.fireEvent) { + link.fireEvent('onclick'); + } else { + let evt = document.createEvent('MouseEvents'); + evt.initMouseEvent( + 'click', + true, + true, + window, + 1, + 0, + 0, + 0, + 0, + false, + false, + false, + false, + 0, + null, + ); + link.dispatchEvent(evt); + } + }; + + Proxmox.Utils.API2Request({ + url: url, + params: params, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + success: function (response, opts) { + let cfg = response.result.data; + let raw = Object.entries(cfg).reduce( + (acc, [k, v]) => acc + `${k}=${v}\n`, + '[virt-viewer]\n', + ); + let spiceDownload = + 'data:application/x-virt-viewer;charset=UTF-8,' + encodeURIComponent(raw); + downloadWithName(spiceDownload, 'pve-spice.vv'); + }, + }); + }, + + openTreeConsole: function (tree, record, item, index, e) { + e.stopEvent(); + let nodename = record.data.node; + let vmid = record.data.vmid; + let vmname = record.data.name; + if (record.data.type === 'qemu' && !record.data.template) { + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/qemu/${vmid}/status/current`, + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + success: function (response, opts) { + let conf = response.result.data; + let consoles = { + spice: !!conf.spice, + xtermjs: !!conf.serial, + }; + PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname); + }, + }); + } else if (record.data.type === 'lxc' && !record.data.template) { + PVE.Utils.openDefaultConsoleWindow(true, 'lxc', vmid, nodename, vmname); + } + }, + + // test automation helper + call_menu_handler: function (menu, text) { + let item = menu.query('menuitem').find((el) => el.text === text); + if (item && item.handler) { + item.handler(); + } + }, + + createCmdMenu: function (v, record, item, index, event) { + event.stopEvent(); + if (!(v instanceof Ext.tree.View)) { + v.select(record); + } + let menu; + let type = record.data.type; + + if (record.data.template) { + if (type === 'qemu' || type === 'lxc') { + menu = Ext.create('PVE.menu.TemplateMenu', { + pveSelNode: record, + }); + } + } else if (type === 'qemu' || type === 'lxc' || type === 'node') { + menu = Ext.create('PVE.' + type + '.CmdMenu', { + pveSelNode: record, + nodename: record.data.node, + }); + } else if (type === 'tag') { + menu = Ext.create('PVE.dc.TagCmdMenu', { + tag: record.data.tag, + }); + } else if (record?.isRoot()) { + menu = Ext.create('PVE.dc.CmdMenu', { + pveSelNode: record, + nodename: record.data.node, + }); + } else { + return undefined; + } + + menu.showAt(event.getXY()); + return menu; + }, + + // helper for deleting field which are set to there default values + delete_if_default: function (values, fieldname, default_val, create) { + if (values[fieldname] === '' || values[fieldname] === default_val) { + if (!create) { + if (values.delete) { + if (Ext.isArray(values.delete)) { + values.delete.push(fieldname); + } else { + values.delete += ',' + fieldname; + } + } else { + values.delete = fieldname; + } + } + + delete values[fieldname]; + } + }, + + loadSSHKeyFromFile: function (file, callback) { + // ssh-keygen produces ~ 740 bytes for a 4096 bit RSA key, current max is 16 kbit, so assume: + // 740 * 8 for max. 32kbit (5920 bytes), round upwards to 8192 bytes, leaves lots of comment space + PVE.Utils.loadFile(file, callback, 8192); + }, + + loadFile: function (file, callback, maxSize) { + maxSize = maxSize || 32 * 1024; + if (file.size > maxSize) { + Ext.Msg.alert( + gettext('Error'), + `${gettext('Invalid file size')}: ${file.size} > ${maxSize}`, + ); + return; + } + let reader = new FileReader(); + reader.onload = (evt) => callback(evt.target.result); + reader.readAsText(file); + }, + + loadTextFromFile: function (file, callback, maxBytes) { + let maxSize = maxBytes || 8192; + if (file.size > maxSize) { + Ext.Msg.alert(gettext('Error'), gettext('Invalid file size: ') + file.size); + return; + } + let reader = new FileReader(); + reader.onload = (evt) => callback(evt.target.result); + reader.readAsText(file); + }, + + diskControllerMaxIDs: { + ide: 4, + sata: 6, + scsi: 31, + virtio: 16, + unused: 256, + }, + + // types is either undefined (all busses), an array of busses, or a single bus + forEachBus: function (types, func) { + let busses = Object.keys(PVE.Utils.diskControllerMaxIDs); + + if (Ext.isArray(types)) { + busses = types; + } else if (Ext.isDefined(types)) { + busses = [types]; + } + + // check if we only have valid busses + for (let i = 0; i < busses.length; i++) { + if (!PVE.Utils.diskControllerMaxIDs[busses[i]]) { + throw "invalid bus: '" + busses[i] + "'"; + } + } + + for (let i = 0; i < busses.length; i++) { + let count = PVE.Utils.diskControllerMaxIDs[busses[i]]; + for (let j = 0; j < count; j++) { + let cont = func(busses[i], j); + if (!cont && cont !== undefined) { + return; + } + } + } + }, + + lxc_mp_counts: { + mp: 256, + unused: 256, + }, + + forEachLxcMP: function (func, includeUnused) { + for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) { + let cont = func('mp', i, `mp${i}`); + if (!cont && cont !== undefined) { + return; + } + } + + if (!includeUnused) { + return; + } + + for (let i = 0; i < PVE.Utils.lxc_mp_counts.unused; i++) { + let cont = func('unused', i, `unused${i}`); + if (!cont && cont !== undefined) { + return; + } + } + }, + + lxc_dev_count: 256, + + forEachLxcDev: function (func) { + for (let i = 0; i < PVE.Utils.lxc_dev_count; i++) { + let cont = func(i, `dev${i}`); + if (!cont && cont !== undefined) { + return; + } + } + }, + + hardware_counts: { + net: 32, + usb: 14, + usb_old: 5, + hostpci: 16, + audio: 1, + efidisk: 1, + serial: 4, + rng: 1, + tpmstate: 1, + virtiofs: 10, + }, + + // we can have usb6 and up only for specific machine/ostypes + get_max_usb_count: function (ostype, machine) { + if (!ostype) { + return PVE.Utils.hardware_counts.usb_old; + } + + let match = /-(\d+).(\d+)/.exec(machine ?? ''); + if (!match || PVE.Utils.qemu_min_version([match[1], match[2]], [7, 1])) { + if (ostype === 'l26') { + return PVE.Utils.hardware_counts.usb; + } + let os_match = /^win(\d+)$/.exec(ostype); + if (os_match && os_match[1] > 7) { + return PVE.Utils.hardware_counts.usb; + } + } + + return PVE.Utils.hardware_counts.usb_old; + }, + + // parameters are expected to be arrays, e.g. [7,1], [4,0,1] + // returns true if toCheck is equal or greater than minVersion + qemu_min_version: function (toCheck, minVersion) { + let i; + for (i = 0; i < toCheck.length && i < minVersion.length; i++) { + if (toCheck[i] < minVersion[i]) { + return false; + } + } + + if (minVersion.length > toCheck.length) { + for (; i < minVersion.length; i++) { + if (minVersion[i] !== 0) { + return false; + } + } + } + + return true; + }, + + cleanEmptyObjectKeys: function (obj) { + for (const propName of Object.keys(obj)) { + if (obj[propName] === null || obj[propName] === undefined) { + delete obj[propName]; + } + } + }, + + acmedomain_count: 5, + + add_domain_to_acme: function (acme, domain) { + if (acme.domains === undefined) { + acme.domains = [domain]; + } else { + acme.domains.push(domain); + acme.domains = acme.domains.filter( + (value, index, self) => self.indexOf(value) === index, + ); + } + return acme; + }, + + remove_domain_from_acme: function (acme, domain) { + if (acme.domains !== undefined) { + acme.domains = acme.domains.filter( + (value, index, self) => self.indexOf(value) === index && value !== domain, + ); + } + return acme; + }, + + handleStoreErrorOrMask: function (view, store, regex, callback) { + view.mon(store, 'load', function (proxy, response, success, operation) { + if (success) { + Proxmox.Utils.setErrorMask(view, false); + return; + } + let msg; + if (operation.error.statusText) { + if (operation.error.statusText.match(regex)) { + callback(view, operation.error); + return; + } else { + msg = operation.error.statusText + ' (' + operation.error.status + ')'; + } + } else { + msg = gettext('Connection error'); + } + Proxmox.Utils.setErrorMask(view, Ext.htmlEncode(msg)); + }); + }, + + showCephInstallOrMask: function (container, msg, nodename, callback) { + if (msg.match(/not (installed|initialized)/i)) { + if (Proxmox.UserName === 'root@pam') { + container.el.mask(); + if (!container.down('pveCephInstallWindow')) { + let isInstalled = !!msg.match(/not initialized/i); + let win = Ext.create('PVE.ceph.Install', { + nodename: nodename, + }); + win.getViewModel().set('isInstalled', isInstalled); + container.add(win); + win.on('close', () => { + container.el.unmask(); + }); + win.show(); + callback(win); + } + } else { + container.mask( + Ext.String.format( + gettext('{0} not installed.') + + ' ' + + gettext('Log in as root to install.'), + 'Ceph', + ), + ['pve-static-mask'], + ); + } + return true; + } else { + return false; + } + }, + + monitor_ceph_installed: function (view, rstore, nodename, maskOwnerCt) { + PVE.Utils.handleStoreErrorOrMask( + view, + rstore, + /not (installed|initialized)/i, + (_, error) => { + nodename = nodename || Proxmox.NodeName; + let maskTarget = maskOwnerCt ? view.ownerCt : view; + rstore.stopUpdate(); + PVE.Utils.showCephInstallOrMask( + maskTarget, + error.statusText, + nodename, + (win) => { + view.mon(win, 'cephInstallWindowClosed', () => rstore.startUpdate()); + }, + ); + }, + ); + }, + + propertyStringSet: function (target, source, name, value) { + if (source) { + if (value === undefined) { + target[name] = source; + } else { + target[name] = value; + } + } else { + delete target[name]; + } + }, + + forEachCorosyncLink: function (nodeinfo, cb) { + let re = /(?:ring|link)(\d+)_addr/; + Ext.iterate(nodeinfo, (prop, val) => { + let match = re.exec(prop); + if (match) { + cb(Number(match[1]), val); + } + }); + }, + + cpu_vendor_map: { + default: 'QEMU', + AuthenticAMD: 'AMD', + GenuineIntel: 'Intel', + }, + + cpu_vendor_order: { + AMD: 1, + Intel: 2, + QEMU: 3, + Host: 4, + _default_: 5, // includes custom models + }, + + verify_ip64_address_list: function (value, with_suffix) { + for (let addr of value.split(/[ ,;]+/)) { + if (addr === '') { + continue; + } + + if (with_suffix) { + let parts = addr.split('%'); + addr = parts[0]; + + if (parts.length > 2) { + return false; + } + + if (parts.length > 1 && !addr.startsWith('fe80:')) { + return false; + } + } + + if (!Proxmox.Utils.IP64_match.test(addr)) { + return false; + } + } + + return true; + }, + + sortByPreviousUsage: function (vmconfig, controllerList) { + if (!controllerList) { + controllerList = ['ide', 'virtio', 'scsi', 'sata']; + } + let usedControllers = {}; + for (const type of Object.keys(PVE.Utils.diskControllerMaxIDs)) { + usedControllers[type] = 0; + } + + for (const property of Object.keys(vmconfig)) { + if ( + property.match(PVE.Utils.bus_match) && + !vmconfig[property].match(/media=cdrom/) + ) { + const foundController = property.match(PVE.Utils.bus_match)[1]; + usedControllers[foundController]++; + } + } + + let sortPriority = PVE.qemu.OSDefaults.getDefaults(vmconfig.ostype).busPriority; + + let sortedList = Ext.clone(controllerList); + sortedList.sort(function (a, b) { + if (usedControllers[b] === usedControllers[a]) { + return sortPriority[b] - sortPriority[a]; + } + return usedControllers[b] - usedControllers[a]; + }); + + return sortedList; + }, + + nextFreeDisk: function (controllers, config) { + for (const controller of controllers) { + for (let i = 0; i < PVE.Utils.diskControllerMaxIDs[controller]; i++) { + let confid = controller + i.toString(); + if (!Ext.isDefined(config[confid])) { + return { + controller, + id: i, + confid, + }; + } + } + } + + return undefined; + }, + + nextFreeLxcMP: function (type, config) { + for (let i = 0; i < PVE.Utils.lxc_mp_counts[type]; i++) { + let confid = `${type}${i}`; + if (!Ext.isDefined(config[confid])) { + return { + type, + id: i, + confid, + }; + } + } + + return undefined; + }, + + escapeNotesTemplate: function (value) { + let replace = { + '\\': '\\\\', + '\n': '\\n', + }; + return value.replace(/(\\|[\n])/g, (match) => replace[match]); + }, + + unEscapeNotesTemplate: function (value) { + let replace = { + '\\\\': '\\', + '\\n': '\n', + }; + return value.replace(/(\\\\|\\n)/g, (match) => replace[match]); + }, + + notesTemplateVars: ['cluster', 'guestname', 'node', 'vmid'], + + renderTags: function (tagstext, overrides) { + let text = ''; + if (tagstext) { + let tags = (tagstext.split(/[,; ]/) || []).filter((t) => !!t); + if (PVE.UIOptions.shouldSortTags()) { + tags = tags.sort((a, b) => { + let alc = a.toLowerCase(); + let blc = b.toLowerCase(); + return alc < blc ? -1 : blc < alc ? 1 : a.localeCompare(b); + }); + } + text += ' '; + tags.forEach((tag) => { + text += Proxmox.Utils.getTagElement(tag, overrides); + }); + } + return text; + }, + + tagCharRegex: /^[a-z0-9+_.-]+$/i, + + verificationStateOrder: { + failed: 0, + none: 1, + ok: 2, + __default__: 3, + }, + + isStandaloneNode: function () { + return PVE.data.ResourceStore.getNodes().length < 2; + }, + + // main use case of this helper is the login window + getUiLanguage: function () { + let languageCookie = Ext.util.Cookies.get('PVELangCookie'); + if (languageCookie === 'kr') { + // fix-up 'kr' being used for Korean by mistake FIXME: remove with PVE 9 + let dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10); + languageCookie = 'ko'; + Ext.util.Cookies.set('PVELangCookie', languageCookie, dt); + } + return languageCookie || Proxmox.defaultLang || 'en'; + }, + + getFormattedGuestIdentifier: function (vmid, guestName) { + if (PVE.UIOptions.getTreeSortingValue('sort-field') === 'vmid') { + return guestName ? `${vmid} (${guestName})` : vmid; + } else { + return guestName ? `${guestName} (${vmid})` : vmid; + } + }, + + formatGuestTaskConfirmation: function (taskType, vmid, guestName) { + let description = Proxmox.Utils.format_task_description( + taskType, + this.getFormattedGuestIdentifier(vmid, guestName), + ); + return Ext.htmlEncode(description); + }, + }, + + singleton: true, + constructor: function () { + var me = this; + Ext.apply(me, me.utilities); + + Proxmox.Utils.override_task_descriptions({ + acmedeactivate: ['ACME Account', gettext('Deactivate')], + acmenewcert: ['SRV', gettext('Order Certificate')], + acmerefresh: ['ACME Account', gettext('Refresh')], + acmeregister: ['ACME Account', gettext('Register')], + acmerenew: ['SRV', gettext('Renew Certificate')], + acmerevoke: ['SRV', gettext('Revoke Certificate')], + acmeupdate: ['ACME Account', gettext('Update')], + 'auth-realm-sync': [gettext('Realm'), gettext('Sync')], + 'auth-realm-sync-test': [gettext('Realm'), gettext('Sync Preview')], + 'bulk-migrate': ['', gettext('Bulk migrate VMs and Containers')], + 'bulk-start': ['', gettext('Bulk start VMs and Containers')], + 'bulk-shutdown': ['', gettext('Bulk shutdown VMs and Containers')], + 'bulk-suspend': ['', gettext('Bulk shutdown VMs and Containers')], + cephcreatemds: ['Ceph Metadata Server', gettext('Create')], + cephcreatemgr: ['Ceph Manager', gettext('Create')], + cephcreatemon: ['Ceph Monitor', gettext('Create')], + cephcreateosd: ['Ceph OSD', gettext('Create')], + cephcreatepool: ['Ceph Pool', gettext('Create')], + cephdestroymds: ['Ceph Metadata Server', gettext('Destroy')], + cephdestroymgr: ['Ceph Manager', gettext('Destroy')], + cephdestroymon: ['Ceph Monitor', gettext('Destroy')], + cephdestroyosd: ['Ceph OSD', gettext('Destroy')], + cephdestroypool: ['Ceph Pool', gettext('Destroy')], + cephdestroyfs: ['CephFS', gettext('Destroy')], + cephfscreate: ['CephFS', gettext('Create')], + cephsetpool: ['Ceph Pool', gettext('Edit')], + cephsetflags: ['', gettext('Change global Ceph flags')], + clustercreate: ['', gettext('Create Cluster')], + clusterjoin: ['', gettext('Join Cluster')], + dircreate: [gettext('Directory Storage'), gettext('Create')], + dirremove: [gettext('Directory'), gettext('Remove')], + download: [gettext('File'), gettext('Download')], + hamigrate: ['HA', gettext('Migrate')], + hashutdown: ['HA', gettext('Shutdown')], + hastart: ['HA', gettext('Start')], + hastop: ['HA', gettext('Stop')], + imgcopy: ['', gettext('Copy data')], + imgdel: ['', gettext('Erase data')], + lvmcreate: [gettext('LVM Storage'), gettext('Create')], + lvmremove: ['Volume Group', gettext('Remove')], + lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')], + lvmthinremove: ['Thinpool', gettext('Remove')], + migrateall: ['', gettext('Bulk migrate VMs and Containers')], + move_volume: ['CT', gettext('Move Volume')], + 'pbs-download': ['VM/CT', gettext('File Restore Download')], + pull_file: ['CT', gettext('Pull file')], + push_file: ['CT', gettext('Push file')], + qmclone: ['VM', gettext('Clone')], + qmconfig: ['VM', gettext('Configure')], + qmcreate: ['VM', gettext('Create')], + qmdelsnapshot: ['VM', gettext('Delete Snapshot')], + qmdestroy: ['VM', gettext('Destroy')], + qmigrate: ['VM', gettext('Migrate')], + qmmove: ['VM', gettext('Move disk')], + qmpause: ['VM', gettext('Pause')], + qmreboot: ['VM', gettext('Reboot')], + qmreset: ['VM', gettext('Reset')], + qmrestore: ['VM', gettext('Restore')], + qmresume: ['VM', gettext('Resume')], + qmrollback: ['VM', gettext('Rollback')], + qmshutdown: ['VM', gettext('Shutdown')], + qmsnapshot: ['VM', gettext('Snapshot')], + qmstart: ['VM', gettext('Start')], + qmstop: ['VM', gettext('Stop')], + qmsuspend: ['VM', gettext('Hibernate')], + qmtemplate: ['VM', gettext('Convert to template')], + resize: ['VM/CT', gettext('Resize')], + reloadnetworkall: ['', gettext('Reload network configuration on all nodes')], + spiceproxy: ['VM/CT', gettext('Console') + ' (Spice)'], + spiceshell: ['', gettext('Shell') + ' (Spice)'], + startall: ['', gettext('Bulk start VMs and Containers')], + stopall: ['', gettext('Bulk shutdown VMs and Containers')], + suspendall: ['', gettext('Suspend all VMs')], + unknownimgdel: ['', gettext('Destroy image from unknown guest')], + wipedisk: ['Device', gettext('Wipe Disk')], + vncproxy: ['VM/CT', gettext('Console')], + vncshell: ['', gettext('Shell')], + vzclone: ['CT', gettext('Clone')], + vzcreate: ['CT', gettext('Create')], + vzdelsnapshot: ['CT', gettext('Delete Snapshot')], + vzdestroy: ['CT', gettext('Destroy')], + vzdump: (type, id) => + id ? `VM/CT ${id} - ${gettext('Backup')}` : gettext('Backup Job'), + vzmigrate: ['CT', gettext('Migrate')], + vzmount: ['CT', gettext('Mount')], + vzreboot: ['CT', gettext('Reboot')], + vzrestore: ['CT', gettext('Restore')], + vzresume: ['CT', gettext('Resume')], + vzrollback: ['CT', gettext('Rollback')], + vzshutdown: ['CT', gettext('Shutdown')], + vzsnapshot: ['CT', gettext('Snapshot')], + vzstart: ['CT', gettext('Start')], + vzstop: ['CT', gettext('Stop')], + vzsuspend: ['CT', gettext('Suspend')], + vztemplate: ['CT', gettext('Convert to template')], + vzumount: ['CT', gettext('Unmount')], + zfscreate: [gettext('ZFS Storage'), gettext('Create')], + zfsremove: ['ZFS Pool', gettext('Remove')], + }); + + Proxmox.Utils.overrideNotificationFieldName({ + 'job-id': gettext('Job ID'), + }); + + Proxmox.Utils.overrideNotificationFieldValue({ + 'package-updates': gettext('Package updates are available'), + vzdump: gettext('Backup notifications'), + replication: gettext('Replication job notifications'), + fencing: gettext('Node fencing notifications'), + }); + }, +}); +Ext.define('PVE.UIOptions', { + singleton: true, + + options: { + 'allowed-tags': [], + }, + + update: function () { + Proxmox.Utils.API2Request({ + url: '/cluster/options', + method: 'GET', + success: function (response) { + for (const option of ['allowed-tags', 'console', 'tag-style']) { + PVE.UIOptions.options[option] = response?.result?.data?.[option]; + } + + PVE.UIOptions.updateTagList(PVE.UIOptions.options['allowed-tags']); + PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']); + PVE.UIOptions.fireUIConfigChanged(); + }, + }); + }, + + tagList: [], + + updateTagList: function (tags) { + PVE.UIOptions.tagList = [...new Set([...tags])].sort(); + }, + + parseTagOverrides: function (overrides) { + let colors = {}; + (overrides || '').split(';').forEach((color) => { + if (!color) { + return; + } + let [tag, color_hex, font_hex] = color.split(':'); + let r = parseInt(color_hex.slice(0, 2), 16); + let g = parseInt(color_hex.slice(2, 4), 16); + let b = parseInt(color_hex.slice(4, 6), 16); + colors[tag] = [r, g, b]; + if (font_hex) { + colors[tag].push(parseInt(font_hex.slice(0, 2), 16)); + colors[tag].push(parseInt(font_hex.slice(2, 4), 16)); + colors[tag].push(parseInt(font_hex.slice(4, 6), 16)); + } + }); + return colors; + }, + + tagOverrides: {}, + + updateTagOverrides: function (colors) { + let sp = Ext.state.Manager.getProvider(); + let color_state = sp.get('colors', ''); + let browser_colors = PVE.UIOptions.parseTagOverrides(color_state); + PVE.UIOptions.tagOverrides = Ext.apply({}, browser_colors, colors); + }, + + updateTagSettings: function (style) { + let overrides = style?.['color-map']; + PVE.UIOptions.updateTagOverrides(PVE.UIOptions.parseTagOverrides(overrides ?? '')); + + let shape = style?.shape ?? 'circle'; + if (shape === '__default__') { + style = 'circle'; + } + + Ext.ComponentQuery.query('pveResourceTree')[0].setUserCls(`proxmox-tags-${shape}`); + }, + + tagTreeStyles: { + __default__: `${Proxmox.Utils.defaultText} (${gettext('Circle')})`, + full: gettext('Full'), + circle: gettext('Circle'), + dense: gettext('Dense'), + none: Proxmox.Utils.NoneText, + }, + + tagOrderOptions: { + __default__: `${Proxmox.Utils.defaultText} (${gettext('Alphabetical')})`, + config: gettext('Configuration'), + alphabetical: gettext('Alphabetical'), + }, + + shouldSortTags: function () { + return !(PVE.UIOptions.options['tag-style']?.ordering === 'config'); + }, + + getTreeSortingValue: function (key) { + let localStorage = Ext.state.Manager.getProvider(); + let browserValues = localStorage.get('pve-tree-sorting'); + let defaults = { + 'sort-field': 'vmid', + 'group-templates': true, + 'group-guest-types': true, + }; + + return browserValues?.[key] ?? defaults[key]; + }, + + fireUIConfigChanged: function () { + PVE.data.ResourceStore.refresh(); + Ext.GlobalEvents.fireEvent('loadedUiOptions'); + }, +}); +// ExtJS related things + +Proxmox.Utils.toolkit = 'extjs'; + +// custom PVE specific VTypes +Ext.apply(Ext.form.field.VTypes, { + QemuStartDate: function (v) { + return /^(now|\d{4}-\d{1,2}-\d{1,2}(T\d{1,2}:\d{1,2}:\d{1,2})?)$/.test(v); + }, + QemuStartDateText: gettext('Format') + ': "now" or "2006-06-17T16:01:21" or "2006-06-17"', + IP64AddressList: (v) => PVE.Utils.verify_ip64_address_list(v, false), + IP64AddressWithSuffixList: (v) => PVE.Utils.verify_ip64_address_list(v, true), + IP64AddressListText: gettext('Example') + ': 192.168.1.1,192.168.1.2', + IP64AddressListMask: /[A-Fa-f0-9,:.; ]/, + PciIdText: gettext('Example') + ': 0x8086', + PciId: (v) => /^0x[0-9a-fA-F]{4}$/.test(v), +}); + +Ext.define('PVE.form.field.Display', { + override: 'Ext.form.field.Display', + + setSubmitValue: function (value) { + // do nothing, this is only to allow generalized bindings for the: + // `me.isCreate ? 'textfield' : 'displayfield'` cases we have. + }, +}); +Ext.define('PVE.noVncConsole', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNoVncConsole', + + nodename: undefined, + vmid: undefined, + cmd: undefined, + + consoleType: undefined, // lxc, kvm, shell, cmd + xtermjs: false, + + layout: 'fit', + border: false, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.consoleType) { + throw 'no console type specified'; + } + + if (!me.vmid && me.consoleType !== 'shell' && me.consoleType !== 'cmd') { + throw 'no VM ID specified'; + } + + // always use same iframe, to avoid running several noVnc clients + // at same time (to avoid performance problems) + var box = Ext.create('Ext.ux.IFrame', { itemid: 'vncconsole', flex: 1 }); + + let warning = Ext.create('Ext.Component', { + userCls: 'pmx-hint', + padding: 5, + hidden: true, + style: { + 'text-align': 'center', + }, + html: gettext( + 'Application container detected - console might not be fully functional.', + ), + }); + + var type = me.xtermjs ? 'xtermjs' : 'novnc'; + Ext.apply(me, { + layout: { + type: 'vbox', + align: 'stretch', + }, + items: [warning, box], + listeners: { + activate: function () { + let sp = Ext.state.Manager.getProvider(); + if (Ext.isFunction(me.beforeLoad)) { + me.beforeLoad(); + } + let queryDict = { + console: me.consoleType, // kvm, lxc, upgrade or shell + vmid: me.vmid, + node: me.nodename, + cmd: me.cmd, + 'cmd-opts': me.cmdOpts, + resize: sp.get('novnc-scaling', 'scale'), + }; + queryDict[type] = 1; + PVE.Utils.cleanEmptyObjectKeys(queryDict); + var url = '/?' + Ext.Object.toQueryString(queryDict); + box.load(url); + }, + }, + }); + + me.callParent(); + + // check for app container + if (me.consoleType === 'lxc') { + Proxmox.Utils.API2Request({ + url: `/nodes/${me.nodename}/lxc/${me.vmid}/config`, + success: function (response) { + let consoleMode = response?.result?.data?.cmode; + let entryPoint = response?.result?.data?.entrypoint; + let customEntryPoint = entryPoint !== undefined && entryPoint !== '/sbin/init'; + + if (customEntryPoint && consoleMode === 'console') { + warning.setVisible(true); + setTimeout(() => { + warning.setVisible(false); + }, 8_000); + } + }, + }); + } + + me.on('afterrender', function () { + box.focus(); + }); + }, + + reload: function () { + // reload IFrame content to forcibly reconnect VNC/xterm.js to VM + var box = this.down('[itemid=vncconsole]'); + box.getWin().location.reload(); + }, +}); +Ext.define('PVE.button.ConsoleButton', { + extend: 'Ext.button.Split', + alias: 'widget.pveConsoleButton', + + consoleType: 'shell', // one of 'shell', 'kvm', 'lxc', 'upgrade', 'cmd' + + cmd: undefined, + + consoleName: undefined, + + iconCls: 'fa fa-terminal', + + enableSpice: true, + enableXtermjs: true, + + nodename: undefined, + + vmid: 0, + + text: gettext('Console'), + + setEnableSpice: function (enable) { + var me = this; + + me.enableSpice = enable; + me.down('#spicemenu').setDisabled(!enable); + }, + + setEnableXtermJS: function (enable) { + var me = this; + + me.enableXtermjs = enable; + me.down('#xtermjs').setDisabled(!enable); + }, + + handler: function () { + // main, general, handler + let me = this; + PVE.Utils.openDefaultConsoleWindow( + { + spice: me.enableSpice, + xtermjs: me.enableXtermjs, + }, + me.consoleType, + me.vmid, + me.nodename, + me.consoleName, + me.cmd, + ); + }, + + openConsole: function (types) { + // used by split-menu buttons + let me = this; + PVE.Utils.openConsoleWindow( + types, + me.consoleType, + me.vmid, + me.nodename, + me.consoleName, + me.cmd, + ); + }, + + menu: [ + { + xtype: 'menuitem', + text: 'noVNC', + iconCls: 'pve-itype-icon-novnc', + type: 'html5', + handler: function (button) { + let view = this.up('button'); + view.openConsole(button.type); + }, + }, + { + xterm: 'menuitem', + itemId: 'spicemenu', + text: 'SPICE', + type: 'vv', + iconCls: 'pve-itype-icon-virt-viewer', + handler: function (button) { + let view = this.up('button'); + view.openConsole(button.type); + }, + }, + { + text: 'xterm.js', + itemId: 'xtermjs', + iconCls: 'pve-itype-icon-xtermjs', + type: 'xtermjs', + handler: function (button) { + let view = this.up('button'); + view.openConsole(button.type); + }, + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + me.callParent(); + }, +}); +Ext.define('PVE.button.PendingRevert', { + extend: 'Proxmox.button.Button', + alias: 'widget.pvePendingRevertButton', + + text: gettext('Revert'), + disabled: true, + config: { + pendingGrid: null, + apiurl: undefined, + }, + + handler: function () { + if (!this.pendingGrid) { + this.pendingGrid = this.up('proxmoxPendingObjectGrid'); + if (!this.pendingGrid) { + throw 'revert button requires a pendingGrid'; + } + } + let view = this.pendingGrid; + + let rec = view.getSelectionModel().getSelection()[0]; + if (!rec) { + return; + } + + let rowdef = view.rows[rec.data.key] || {}; + let keys = rowdef.multiKey || [rec.data.key]; + + Proxmox.Utils.API2Request({ + url: this.apiurl || view.editorConfig.url, + waitMsgTarget: view, + selModel: view.getSelectionModel(), + method: 'PUT', + params: { + revert: keys.join(','), + }, + callback: () => view.reload(), + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + }); + }, +}); +/* Button features: + * - observe selection changes to enable/disable the button using enableFn() + * - pop up confirmation dialog using confirmMsg() + * + * does this for the button and every menu item + */ +Ext.define('PVE.button.Split', { + extend: 'Ext.button.Split', + alias: 'widget.pveSplitButton', + + // the selection model to observe + selModel: undefined, + + // if 'false' handler will not be called (button disabled) + enableFn: function (record) { + // do nothing + }, + + // function(record) or text + confirmMsg: false, + + // take special care in confirm box (select no as default). + dangerous: false, + + handlerWrapper: function (button, event) { + var me = this; + var rec, msg; + if (me.selModel) { + rec = me.selModel.getSelection()[0]; + if (!rec || me.enableFn(rec) === false) { + return; + } + } + + if (me.confirmMsg) { + msg = me.confirmMsg; + // confirMsg can be boolean or function + if (Ext.isFunction(me.confirmMsg)) { + msg = me.confirmMsg(rec); + } + Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1; + Ext.Msg.show({ + title: gettext('Confirm'), + icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION, + msg: msg, + buttons: Ext.Msg.YESNO, + callback: function (btn) { + if (btn !== 'yes') { + return; + } + me.realHandler(button, event, rec); + }, + }); + } else { + me.realHandler(button, event, rec); + } + }, + + initComponent: function () { + var me = this; + + if (me.handler) { + me.realHandler = me.handler; + me.handler = me.handlerWrapper; + } + + if (me.menu && me.menu.items) { + me.menu.items.forEach(function (item) { + if (item.handler) { + item.realHandler = item.handler; + item.handler = me.handlerWrapper; + } + + if (item.selModel) { + me.mon(item.selModel, 'selectionchange', function () { + var rec = item.selModel.getSelection()[0]; + if (!rec || item.enableFn(rec) === false) { + item.setDisabled(true); + } else { + item.setDisabled(false); + } + }); + } + }); + } + + me.callParent(); + + if (me.selModel) { + me.mon(me.selModel, 'selectionchange', function () { + var rec = me.selModel.getSelection()[0]; + if (!rec || me.enableFn(rec) === false) { + me.setDisabled(true); + } else { + me.setDisabled(false); + } + }); + } + }, +}); +Ext.define('PVE.controller.StorageEdit', { + extend: 'Ext.app.ViewController', + alias: 'controller.storageEdit', + control: { + 'field[name=content]': { + change: function (field, value) { + const hasImages = Ext.Array.contains(value, 'images'); + const prealloc = field.up('form').getForm().findField('preallocation'); + if (prealloc) { + prealloc.setDisabled(!hasImages); + } + }, + }, + }, +}); +Ext.define('PVE.data.PermPathStore', { + extend: 'Ext.data.Store', + alias: 'store.pvePermPath', + fields: ['value'], + autoLoad: false, + data: [ + { value: '/' }, + { value: '/access' }, + { value: '/access/groups' }, + { value: '/access/realm' }, + { value: '/mapping' }, + { value: '/mapping/hwrng' }, + { value: '/mapping/notifications' }, + { value: '/mapping/pci' }, + { value: '/mapping/usb' }, + { value: '/nodes' }, + { value: '/pool' }, + { value: '/sdn/fabrics' }, + { value: '/sdn/zones' }, + { value: '/storage' }, + { value: '/vms' }, + ], + + constructor: function (config) { + var me = this; + + config = config || {}; + + me.callParent([config]); + + let donePaths = {}; + me.suspendEvents(); + PVE.data.ResourceStore.each(function (record) { + let path; + switch (record.get('type')) { + case 'node': + path = '/nodes/' + record.get('text'); + break; + case 'qemu': + path = '/vms/' + record.get('vmid'); + break; + case 'lxc': + path = '/vms/' + record.get('vmid'); + break; + case 'sdn': + path = '/sdn/zones/' + record.get('sdn'); + break; + case 'storage': + path = '/storage/' + record.get('storage'); + break; + case 'pool': + path = '/pool/' + record.get('pool'); + break; + } + if (path !== undefined && !donePaths[path]) { + me.add({ value: path }); + donePaths[path] = 1; + } + }); + me.resumeEvents(); + + me.fireEvent('refresh', me); + me.fireEvent('datachanged', me); + + me.sort({ + property: 'value', + direction: 'ASC', + }); + }, +}); +Ext.define('PVE.data.ResourceStore', { + extend: 'Proxmox.data.UpdateStore', + singleton: true, + + nodeCache: {}, + + findVMID: function (vmid) { + let me = this; + return me.findExact('vmid', parseInt(vmid, 10)) >= 0; + }, + + // returns the cached data from all nodes + getNodes: function () { + let me = this; + + let nodes = []; + me.each(function (record) { + if (record.get('type') === 'node') { + nodes.push(record.getData()); + } + }); + + return nodes; + }, + + getNodeById: function (id) { + let me = this; + + if (!me.nodeCache[id]) { + let idx = me.findExact('id', `node/${id}`); + me.nodeCache[id] = me.getAt(idx); + } + + return me.nodeCache[id]; + }, + + clearCache: function () { + let me = this; + me.nodeCache = {}; + }, + + storageIsShared: function (storage_path) { + let me = this; + + let index = me.findExact('id', storage_path); + if (index >= 0) { + return me.getAt(index).data.shared; + } else { + return undefined; + } + }, + + guestNode: function (vmid) { + let me = this; + + let index = me.findExact('vmid', parseInt(vmid, 10)); + + return me.getAt(index).data.node; + }, + + guestName: function (vmid) { + let me = this; + let index = me.findExact('vmid', parseInt(vmid, 10)); + if (index < 0) { + return '-'; + } + let rec = me.getAt(index).data; + if ('name' in rec) { + return rec.name; + } + return ''; + }, + + refresh: function () { + let me = this; + // can only refresh if we're loaded at least once and are not currently loading + if (!me.isLoading() && me.isLoaded()) { + let records = (me.getData().getSource() || me.getData()).getRange(); + me.fireEvent('load', me, records); + } + }, + + constructor: function (config) { + let me = this; + + config = config || {}; + + let field_defaults = { + type: { + header: gettext('Type'), + type: 'string', + renderer: PVE.Utils.render_resource_type, + sortable: true, + hideable: false, + width: 100, + }, + id: { + header: 'ID', + type: 'string', + hidden: true, + sortable: true, + width: 80, + }, + running: { + header: gettext('Online'), + type: 'boolean', + renderer: Proxmox.Utils.format_boolean, + hidden: true, + convert: function (value, record) { + var info = record.data; + return Ext.isNumeric(info.uptime) && info.uptime > 0; + }, + }, + text: { + header: gettext('Description'), + type: 'string', + sortable: true, + width: 200, + convert: function (value, record) { + if (value) { + return value; + } + + let info = record.data, + text; + if (Ext.isNumeric(info.vmid) && info.vmid > 0) { + text = String(info.vmid); + if (info.name) { + text += ' (' + info.name + ')'; + } + } else { + // node, pool, storage + text = info[info.type] || info.id; + if (info.node && info.type !== 'node') { + text += ' (' + info.node + ')'; + } + } + + return text; + }, + }, + vmid: { + header: 'VMID', + type: 'integer', + hidden: true, + sortable: true, + width: 80, + }, + name: { + header: gettext('Name'), + hidden: true, + sortable: true, + type: 'string', + }, + disk: { + header: gettext('Disk usage'), + type: 'integer', + renderer: PVE.Utils.render_disk_usage, + sortable: true, + width: 100, + hidden: true, + }, + diskuse: { + header: gettext('Disk usage') + ' %', + type: 'number', + sortable: true, + renderer: PVE.Utils.render_disk_usage_percent, + width: 100, + calculate: PVE.Utils.calculate_disk_usage, + sortType: 'asFloat', + }, + maxdisk: { + header: gettext('Disk size'), + type: 'integer', + renderer: Proxmox.Utils.render_size, + sortable: true, + hidden: true, + width: 100, + }, + mem: { + header: gettext('Memory usage'), + type: 'integer', + renderer: PVE.Utils.render_mem_usage, + sortable: true, + hidden: true, + width: 100, + }, + memhost: { + header: gettext('Host Memory usage'), + type: 'integer', + renderer: PVE.Utils.render_mem_usage, + sortable: true, + hidden: true, + width: 100, + }, + memuse: { + header: gettext('Memory usage') + ' %', + type: 'number', + renderer: PVE.Utils.render_mem_usage_percent, + calculate: PVE.Utils.calculate_mem_usage, + sortType: 'asFloat', + sortable: true, + width: 100, + }, + maxmem: { + header: gettext('Memory size'), + type: 'integer', + renderer: Proxmox.Utils.render_size, + hidden: true, + sortable: true, + width: 100, + }, + cpu: { + header: gettext('CPU usage'), + type: 'float', + renderer: Proxmox.Utils.render_cpu, + sortable: true, + width: 100, + }, + maxcpu: { + header: gettext('maxcpu'), + type: 'integer', + hidden: true, + sortable: true, + width: 60, + }, + diskread: { + header: gettext('Total Disk Read'), + type: 'integer', + hidden: true, + sortable: true, + renderer: Proxmox.Utils.format_size, + width: 100, + }, + diskwrite: { + header: gettext('Total Disk Write'), + type: 'integer', + hidden: true, + sortable: true, + renderer: Proxmox.Utils.format_size, + width: 100, + }, + netin: { + header: gettext('Total NetIn'), + type: 'integer', + hidden: true, + sortable: true, + renderer: Proxmox.Utils.format_size, + width: 100, + }, + netout: { + header: gettext('Total NetOut'), + type: 'integer', + hidden: true, + sortable: true, + renderer: Proxmox.Utils.format_size, + width: 100, + }, + template: { + header: gettext('Template'), + type: 'integer', + hidden: true, + sortable: true, + width: 60, + }, + uptime: { + header: gettext('Uptime'), + type: 'integer', + renderer: Proxmox.Utils.render_uptime, + sortable: true, + width: 110, + }, + node: { + header: gettext('Node'), + type: 'string', + hidden: true, + sortable: true, + width: 110, + }, + storage: { + header: gettext('Storage'), + type: 'string', + hidden: true, + sortable: true, + width: 110, + }, + pool: { + header: gettext('Pool'), + type: 'string', + hidden: true, + sortable: true, + width: 110, + }, + hastate: { + header: gettext('HA State'), + type: 'string', + defaultValue: 'unmanaged', + hidden: true, + sortable: true, + }, + status: { + header: gettext('Status'), + type: 'string', + hidden: true, + sortable: true, + width: 110, + }, + lock: { + header: gettext('Lock'), + type: 'string', + hidden: true, + sortable: true, + width: 110, + }, + hostcpu: { + header: gettext('Host CPU usage'), + type: 'float', + renderer: PVE.Utils.render_hostcpu, + calculate: PVE.Utils.calculate_hostcpu, + sortType: 'asFloat', + sortable: true, + width: 100, + }, + hostmemuse: { + header: gettext('Host Memory usage') + ' %', + type: 'number', + renderer: PVE.Utils.render_hostmem_usage_percent, + calculate: PVE.Utils.calculate_hostmem_usage, + sortType: 'asFloat', + sortable: true, + width: 100, + }, + tags: { + header: gettext('Tags'), + renderer: (value) => PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), + type: 'string', + sortable: true, + flex: 1, + }, + // note: flex only last column to keep info closer together + }; + + let fields = []; + let fieldNames = []; + Ext.Object.each(field_defaults, function (key, value) { + var field = { name: key, type: value.type }; + if (Ext.isDefined(value.convert)) { + field.convert = value.convert; + } + + if (Ext.isDefined(value.calculate)) { + field.calculate = value.calculate; + } + + if (Ext.isDefined(value.defaultValue)) { + field.defaultValue = value.defaultValue; + } + + fields.push(field); + fieldNames.push(key); + }); + + Ext.define('PVEResources', { + extend: 'Ext.data.Model', + fields: fields, + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/resources', + }, + }); + + Ext.define('PVETree', { + extend: 'Ext.data.Model', + fields: fields, + proxy: { type: 'memory' }, + }); + + Ext.apply(config, { + storeid: 'PVEResources', + model: 'PVEResources', + defaultColumns: function () { + let res = []; + Ext.Object.each(field_defaults, function (field, info) { + let fieldInfo = Ext.apply({ dataIndex: field }, info); + res.push(fieldInfo); + }); + return res; + }, + fieldNames: fieldNames, + }); + + me.callParent([config]); + + me.on('beforeload', me.clearCache, me); + }, +}); +Ext.define('pve-rrd-node', { + extend: 'Ext.data.Model', + fields: [ + { + name: 'cpu', + // percentage + convert: function (value) { + return value * 100; + }, + }, + { + name: 'iowait', + // percentage + convert: function (value) { + return value * 100; + }, + }, + 'loadavg', + 'maxcpu', + 'memtotal', + 'memused', + 'netin', + 'netout', + 'roottotal', + 'rootused', + 'swaptotal', + 'swapused', + 'memavailable', + 'arcsize', + 'pressurecpusome', + 'pressureiosome', + 'pressureiofull', + 'pressurememorysome', + 'pressurememoryfull', + { type: 'date', dateFormat: 'timestamp', name: 'time' }, + ], +}); + +Ext.define('pve-rrd-guest', { + extend: 'Ext.data.Model', + fields: [ + { + name: 'cpu', + // percentage + convert: function (value) { + return value * 100; + }, + }, + 'maxcpu', + 'netin', + 'netout', + { name: 'mem', defaultValue: null }, + 'maxmem', + 'disk', + 'maxdisk', + 'diskread', + 'diskwrite', + 'memhost', + 'pressurecpusome', + 'pressurecpufull', + 'pressureiosome', + 'pressurecpufull', + 'pressurememorysome', + 'pressurememoryfull', + { type: 'date', dateFormat: 'timestamp', name: 'time' }, + ], +}); + +Ext.define('pve-rrd-storage', { + extend: 'Ext.data.Model', + fields: ['used', 'total', { type: 'date', dateFormat: 'timestamp', name: 'time' }], +}); +// This is a container intended to show a field on the first column and one on the second column. +// One can set a ratio for the field sizes. +// +// Works around a limitation of our input panel column1/2 handling that entries are not vertically +// aligned when one of them has wrapping text (like it happens sometimes with such longer +// descriptions) +Ext.define('PVE.container.TwoColumnContainer', { + extend: 'Ext.container.Container', + alias: 'widget.pveTwoColumnContainer', + + layout: { + type: 'hbox', + align: 'begin', + }, + + // The default ratio of the start widget. It an be an integer or a floating point number + startFlex: 1, + + // The default ratio of the end widget. It an be an integer or a floating point number + endFlex: 1, + + // the padding between the two columns + columnPadding: 20, + + // the config of the first widget + startColumn: undefined, + + // the config of the second widget + endColumn: undefined, + + // same as fields in a panel + padding: '0 0 5 0', + + initComponent: function () { + let me = this; + + if (!me.startColumn) { + throw 'no start widget configured'; + } + if (!me.endColumn) { + throw 'no end widget configured'; + } + + Ext.apply(me, { + items: [ + Ext.applyIf({ flex: me.startFlex }, me.startColumn), + { + xtype: 'box', + width: me.columnPadding, + }, + Ext.applyIf({ flex: me.endFlex }, me.endColumn), + ], + }); + + me.callParent(); + }, +}); +Ext.define('pve-acme-challenges', { + extend: 'Ext.data.Model', + fields: ['id', 'type', 'schema'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/acme/challenge-schema', + }, + idProperty: 'id', +}); + +Ext.define('PVE.form.ACMEApiSelector', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveACMEApiSelector', + + fieldLabel: gettext('DNS API'), + displayField: 'name', + valueField: 'id', + + store: { + model: 'pve-acme-challenges', + autoLoad: true, + }, + + triggerAction: 'all', + queryMode: 'local', + allowBlank: false, + editable: true, + forceSelection: true, + anyMatch: true, + selectOnFocus: true, + + getSchema: function () { + let me = this; + let val = me.getValue(); + if (val) { + let record = me.getStore().findRecord('id', val, 0, false, true, true); + if (record) { + return record.data.schema; + } + } + return {}; + }, +}); +Ext.define('PVE.form.ACMEAccountSelector', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveACMEAccountSelector', + + displayField: 'name', + valueField: 'name', + + store: { + model: 'pve-acme-accounts', + autoLoad: true, + }, + + triggerAction: 'all', + queryMode: 'local', + allowBlank: false, + editable: false, + forceSelection: true, + + isEmpty: function () { + return this.getStore().getData().length === 0; + }, +}); +Ext.define('PVE.form.ACMEPluginSelector', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveACMEPluginSelector', + + fieldLabel: gettext('Plugin'), + displayField: 'plugin', + valueField: 'plugin', + + store: { + model: 'pve-acme-plugins', + autoLoad: true, + filters: (item) => item.data.type === 'dns', + }, + + triggerAction: 'all', + queryMode: 'local', + allowBlank: false, + editable: false, +}); +Ext.define('PVE.form.AgentFeatureSelector', { + extend: 'Proxmox.panel.InputPanel', + alias: ['widget.pveAgentFeatureSelector'], + + viewModel: {}, + + items: [ + { + xtype: 'proxmoxcheckbox', + boxLabel: Ext.String.format(gettext('Use {0}'), 'QEMU Guest Agent'), + name: 'enabled', + reference: 'enabled', + uncheckedValue: 0, + }, + { + xtype: 'proxmoxcheckbox', + boxLabel: gettext('Run guest-trim after a disk move or VM migration'), + name: 'fstrim_cloned_disks', + bind: { + disabled: '{!enabled.checked}', + }, + disabled: true, + }, + { + xtype: 'proxmoxcheckbox', + boxLabel: gettext('Freeze/thaw guest filesystems on backup for consistency'), + name: 'freeze-fs-on-backup', + reference: 'freeze_fs_on_backup', + bind: { + disabled: '{!enabled.checked}', + }, + disabled: true, + uncheckedValue: '0', + defaultValue: '1', + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'Freeze/thaw for guest filesystems disabled. This can lead to inconsistent disk backups.', + ), + bind: { + hidden: '{freeze_fs_on_backup.checked}', + }, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('Make sure the QEMU Guest Agent is installed in the VM'), + bind: { + hidden: '{!enabled.checked}', + }, + }, + ], + + advancedItems: [ + { + xtype: 'proxmoxKVComboBox', + name: 'type', + value: '__default__', + deleteEmpty: false, + fieldLabel: 'Type', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (VirtIO)'], + ['virtio', 'VirtIO'], + ['isa', 'ISA'], + ], + }, + ], + + onGetValues: function (values) { + if (PVE.Parser.parseBoolean(values['freeze-fs-on-backup'])) { + delete values['freeze-fs-on-backup']; + } + + const agentstr = PVE.Parser.printPropertyString(values, 'enabled'); + return { agent: agentstr }; + }, + + setValues: function (values) { + let res = PVE.Parser.parsePropertyString(values.agent, 'enabled'); + if (!Ext.isDefined(res['freeze-fs-on-backup'])) { + res['freeze-fs-on-backup'] = 1; + } + + this.callParent([res]); + }, +}); +Ext.define('PVE.form.BackupCompressionSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveBackupCompressionSelector'], + comboItems: [ + ['0', Proxmox.Utils.noneText], + ['lzo', 'LZO (' + gettext('fast') + ')'], + ['gzip', 'GZIP (' + gettext('good') + ')'], + ['zstd', 'ZSTD (' + gettext('fast and good') + ')'], + ], +}); +Ext.define('PVE.form.BackupModeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveBackupModeSelector'], + comboItems: [ + ['snapshot', gettext('Snapshot')], + ['suspend', gettext('Suspend')], + ['stop', gettext('Stop')], + ], +}); +Ext.define('PVE.form.SizeField', { + extend: 'Ext.form.FieldContainer', + alias: 'widget.pveSizeField', + + mixins: ['Proxmox.Mixin.CBind'], + + viewModel: { + data: { + unit: 'MiB', + unitPostfix: '', + }, + formulas: { + unitlabel: (get) => get('unit') + get('unitPostfix'), + }, + }, + + emptyText: '', + + layout: 'hbox', + defaults: { + hideLabel: true, + }, + + units: { + B: 1, + KiB: 1024, + MiB: 1024 * 1024, + GiB: 1024 * 1024 * 1024, + TiB: 1024 * 1024 * 1024 * 1024, + KB: 1000, + MB: 1000 * 1000, + GB: 1000 * 1000 * 1000, + TB: 1000 * 1000 * 1000 * 1000, + }, + + // display unit (TODO: make (optionally) selectable) + unit: 'MiB', + unitPostfix: '', + + // use this if the backend saves values in a unit other than bytes, e.g., + // for KiB set it to 'KiB' + backendUnit: undefined, + + // allow setting 0 and using it as a submit value + allowZero: false, + + emptyValue: null, + + items: [ + { + xtype: 'numberfield', + cbind: { + name: '{name}', + emptyText: '{emptyText}', + allowZero: '{allowZero}', + emptyValue: '{emptyValue}', + }, + minValue: 0, + step: 1, + submitLocaleSeparator: false, + fieldStyle: 'text-align: right', + flex: 1, + enableKeyEvents: true, + setValue: function (v) { + if (!this._transformed && v !== null) { + let fieldContainer = this.up('fieldcontainer'); + let vm = fieldContainer.getViewModel(); + let unit = vm.get('unit'); + + v /= fieldContainer.units[unit]; + v *= fieldContainer.backendFactor; + + this._transformed = true; + } + + if (Number(v) === 0 && !this.allowZero) { + v = undefined; + } + + return Ext.form.field.Text.prototype.setValue.call(this, v); + }, + getSubmitValue: function () { + let v = this.processRawValue(this.getRawValue()); + v = v.replace(this.decimalSeparator, '.'); + + if (v === undefined || v === '') { + return this.emptyValue; + } + + if (Number(v) === 0) { + return this.allowZero ? 0 : null; + } + + let fieldContainer = this.up('fieldcontainer'); + let vm = fieldContainer.getViewModel(); + let unit = vm.get('unit'); + + v = parseFloat(v) * fieldContainer.units[unit]; + v /= fieldContainer.backendFactor; + + return String(Math.floor(v)); + }, + listeners: { + // our setValue gets only called if we have a value, avoid + // transformation of the first user-entered value + keydown: function () { + this._transformed = true; + }, + }, + }, + { + xtype: 'displayfield', + name: 'unit', + submitValue: false, + padding: '0 0 0 10', + bind: { + value: '{unitlabel}', + }, + listeners: { + change: (f, v) => { + f.originalValue = v; + }, + }, + width: 40, + }, + ], + + initComponent: function () { + let me = this; + + me.unit = me.unit || 'MiB'; + if (!(me.unit in me.units)) { + throw 'unknown unit: ' + me.unit; + } + + me.backendFactor = 1; + if (me.backendUnit !== undefined) { + if (!(me.unit in me.units)) { + throw 'unknown backend unit: ' + me.backendUnit; + } + me.backendFactor = me.units[me.backendUnit]; + } + + me.callParent(arguments); + + me.getViewModel().set('unit', me.unit); + me.getViewModel().set('unitPostfix', me.unitPostfix); + }, +}); + +Ext.define('PVE.form.BandwidthField', { + extend: 'PVE.form.SizeField', + alias: 'widget.pveBandwidthField', + + unitPostfix: '/s', +}); +Ext.define('PVE.form.BridgeSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.PVE.form.BridgeSelector'], + + bridgeType: 'any_bridge', // bridge, OVSBridge or any_bridge + + store: { + fields: ['iface', 'active', 'type'], + filterOnLoad: true, + sorters: [ + { + property: 'iface', + direction: 'ASC', + }, + ], + }, + valueField: 'iface', + displayField: 'iface', + listConfig: { + columns: [ + { + header: gettext('Bridge'), + dataIndex: 'iface', + hideable: false, + width: 100, + }, + { + header: gettext('Active'), + width: 60, + dataIndex: 'active', + renderer: Proxmox.Utils.format_boolean, + }, + { + header: gettext('Comment'), + dataIndex: 'comments', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + }, + + setNodename: function (nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/network?type=' + me.bridgeType, + }); + + me.store.load(); + }, + + initComponent: function () { + var me = this; + + var nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + me.setNodename(nodename); + }, +}); +Ext.define('PVE.form.BusTypeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: 'widget.pveBusSelector', + + withVirtIO: true, + withUnused: false, + + initComponent: function () { + var me = this; + + me.comboItems = [ + ['ide', 'IDE'], + ['sata', 'SATA'], + ]; + + if (me.withVirtIO) { + me.comboItems.push(['virtio', 'VirtIO Block']); + } + + me.comboItems.push(['scsi', 'SCSI']); + + if (me.withUnused) { + me.comboItems.push(['unused', 'Unused']); + } + + me.callParent(); + }, +}); +Ext.define('PVE.data.CPUModel', { + extend: 'Ext.data.Model', + fields: [{ name: 'name' }, { name: 'vendor' }, { name: 'custom' }, { name: 'displayname' }], +}); + +Ext.define('PVE.form.CPUModelSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.CPUModelSelector'], + + valueField: 'name', + displayField: 'displayname', + + emptyText: Proxmox.Utils.defaultText + ' (kvm64)', + allowBlank: true, + + editable: true, + anyMatch: true, + forceSelection: true, + autoSelect: false, + + deleteEmpty: true, + + listConfig: { + columns: [ + { + header: gettext('Model'), + dataIndex: 'displayname', + hideable: false, + sortable: true, + flex: 3, + }, + { + header: gettext('Vendor'), + dataIndex: 'vendor', + hideable: false, + sortable: true, + flex: 2, + }, + ], + width: 360, + }, + + store: { + autoLoad: true, + model: 'PVE.data.CPUModel', + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/localhost/capabilities/qemu/cpu', + }, + sorters: [ + { + sorterFn: function (recordA, recordB) { + let a = recordA.data; + let b = recordB.data; + + let vendorOrder = PVE.Utils.cpu_vendor_order; + let orderA = vendorOrder[a.vendor] || vendorOrder._default_; + let orderB = vendorOrder[b.vendor] || vendorOrder._default_; + + if (orderA > orderB) { + return 1; + } else if (orderA < orderB) { + return -1; + } + + // Within same vendor, sort alphabetically + return a.name.localeCompare(b.name); + }, + direction: 'ASC', + }, + ], + listeners: { + load: function (store, records, success) { + if (success) { + records.forEach((rec) => { + rec.data.displayname = rec.data.name.replace(/^custom-/, ''); + + let vendor = rec.data.vendor; + + if (rec.data.name === 'host') { + vendor = 'Host'; + } + + // We receive vendor names as given to QEMU as CPUID + vendor = PVE.Utils.cpu_vendor_map[vendor] || vendor; + + if (rec.data.custom) { + vendor = gettext('Custom') + ` (${vendor})`; + } + + rec.data.vendor = vendor; + }); + + store.sort(); + } + }, + }, + }, +}); +Ext.define('PVE.form.CacheTypeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.CacheTypeSelector'], + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (' + gettext('No cache') + ')'], + ['directsync', 'Direct sync'], + ['writethrough', 'Write through'], + ['writeback', 'Write back'], + ['unsafe', 'Write back (' + gettext('unsafe') + ')'], + ['none', gettext('No cache')], + ], +}); +Ext.define('PVE.form.CalendarEvent', { + extend: 'Ext.form.field.ComboBox', + xtype: 'pveCalendarEvent', + + editable: true, + emptyText: gettext('Editable'), // FIXME: better way to convey that to confused users? + + valueField: 'value', + queryMode: 'local', + + matchFieldWidth: false, + listConfig: { + maxWidth: 450, + }, + + store: { + field: ['value', 'text'], + data: [ + { value: '*/30', text: Ext.String.format(gettext('Every {0} minutes'), 30) }, + { value: '*/2:00', text: gettext('Every two hours') }, + { value: '21:00', text: gettext('Every day') + ' 21:00' }, + { value: '2,22:30', text: gettext('Every day') + ' 02:30, 22:30' }, + { value: 'mon..fri 00:00', text: gettext('Monday to Friday') + ' 00:00' }, + { + value: 'mon..fri */1:00', + text: gettext('Monday to Friday') + ': ' + gettext('hourly'), + }, + { + value: 'mon..fri 7..18:00/15', + text: + gettext('Monday to Friday') + + ', ' + + Ext.String.format(gettext('{0} to {1}'), '07:00', '18:45') + + ': ' + + Ext.String.format(gettext('Every {0} minutes'), 15), + }, + { value: 'sun 01:00', text: gettext('Sunday') + ' 01:00' }, + { value: 'monthly', text: gettext('Every first day of the Month') + ' 00:00' }, + { value: 'sat *-1..7 15:00', text: gettext('First Saturday each month') + ' 15:00' }, + { value: 'yearly', text: gettext('First day of the year') + ' 00:00' }, + ], + }, + + tpl: [ + '
    ', + '
  • {text}
  • ', + '
', + ], + + displayTpl: ['', '{value}', ''], +}); +Ext.define('PVE.form.CephPoolSelector', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveCephPoolSelector', + + allowBlank: false, + valueField: 'pool_name', + displayField: 'pool_name', + listConfig: { + itemTpl: '{pool_name:htmlEncode}', + }, + editable: false, + queryMode: 'local', + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + let onlyRBDPools = ({ data }) => + !data?.application_metadata || !!data?.application_metadata?.rbd; + + var store = Ext.create('Ext.data.Store', { + fields: ['name'], + sorters: 'name', + filters: [onlyRBDPools], + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/ceph/pool', + }, + }); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + + store.load({ + callback: function (rec, op, success) { + let filteredRec = rec.filter(onlyRBDPools); + + if (success && filteredRec.length > 0) { + me.select(filteredRec[0]); + } + }, + }); + }, +}); +Ext.define('PVE.form.CephFSSelector', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveCephFSSelector', + + allowBlank: false, + valueField: 'name', + displayField: 'name', + editable: false, + queryMode: 'local', + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + var store = Ext.create('Ext.data.Store', { + fields: ['name'], + sorters: 'name', + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/ceph/fs', + }, + }); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + + store.load({ + callback: function (rec, op, success) { + if (success && rec.length > 0) { + me.select(rec[0]); + } + }, + }); + }, +}); +Ext.define('PVE.form.ComboBoxSetStoreNode', { + extend: 'Proxmox.form.ComboGrid', + config: { + apiBaseUrl: '/api2/json/nodes/', + apiSuffix: '', + }, + + showNodeSelector: false, + + setNodeName: function (value) { + let me = this; + value ||= Proxmox.NodeName; + + me.getStore().getProxy().setUrl(`${me.apiBaseUrl}${value}${me.apiSuffix}`); + me.clearValue(); + }, + + nodeChange: function (_field, value) { + let me = this; + // disable autoSelect if there is already a selection or we have the picker open + if (me.getValue() || me.isExpanded) { + let autoSelect = me.autoSelect; + me.autoSelect = false; + me.store.on( + 'afterload', + function () { + me.autoSelect = autoSelect; + }, + { single: true }, + ); + } + me.setNodeName(value); + me.fireEvent('nodechanged', value); + }, + + tbarMouseDown: function () { + this.topBarMousePress = true; + }, + + tbarMouseUp: function () { + let me = this; + delete this.topBarMousePress; + if (me.focusLeft) { + me.focus(); + delete me.focusLeft; + } + }, + + // conditionally prevent the focusLeave handler to continue, preventing collapsing of the picker + onFocusLeave: function () { + let me = this; + me.focusLeft = true; + if (!me.topBarMousePress) { + me.callParent(arguments); + } + + return undefined; + }, + + initComponent: function () { + let me = this; + + if (me.showNodeSelector && !PVE.Utils.isStandaloneNode()) { + me.errorHeight = 140; + Ext.apply(me.listConfig ?? {}, { + tbar: { + xtype: 'toolbar', + minHeight: 40, + listeners: { + mousedown: me.tbarMouseDown, + mouseup: me.tbarMouseUp, + element: 'el', + scope: me, + }, + items: [ + { + xtype: 'pveStorageScanNodeSelector', + autoSelect: false, + fieldLabel: gettext('Node to scan'), + listeners: { + change: (field, value) => me.nodeChange(field, value), + }, + }, + ], + }, + emptyText: me.listConfig?.emptyText ?? gettext('Nothing found'), + }); + } + + me.callParent(); + }, +}); +Ext.define('PVE.form.ContentTypeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveContentTypeSelector'], + + cts: undefined, + + initComponent: function () { + var me = this; + + me.comboItems = []; + + if (me.cts === undefined) { + me.cts = ['images', 'iso', 'vztmpl', 'backup', 'rootdir', 'snippets', 'import']; + } + + Ext.Array.each(me.cts, function (ct) { + me.comboItems.push([ct, PVE.Utils.format_content_types(ct)]); + }); + + me.callParent(); + }, +}); +Ext.define('PVE.form.ControllerSelector', { + extend: 'Ext.form.FieldContainer', + alias: 'widget.pveControllerSelector', + + withVirtIO: true, + withUnused: false, + + vmconfig: {}, // used to check for existing devices + + setToFree: function (controllers, busField, deviceIDField) { + let me = this; + let freeId = PVE.Utils.nextFreeDisk(controllers, me.vmconfig); + + if (freeId !== undefined) { + busField?.setValue(freeId.controller); + deviceIDField.setValue(freeId.id); + } + }, + + updateVMConfig: function (vmconfig) { + let me = this; + me.vmconfig = Ext.apply({}, vmconfig); + + me.down('field[name=deviceid]').validate(); + }, + + setVMConfig: function (vmconfig, autoSelect) { + let me = this; + + me.vmconfig = Ext.apply({}, vmconfig); + + let bussel = me.down('field[name=controller]'); + let deviceid = me.down('field[name=deviceid]'); + + let clist; + if (autoSelect === 'cdrom') { + if (!Ext.isDefined(me.vmconfig.ide2)) { + bussel.setValue('ide'); + deviceid.setValue(2); + return; + } + clist = ['ide', 'scsi', 'sata']; + } else { + // in most cases we want to add a disk to the same controller we previously used + clist = PVE.Utils.sortByPreviousUsage(me.vmconfig); + } + + me.setToFree(clist, bussel, deviceid); + + deviceid.validate(); + }, + + getConfId: function () { + let me = this; + let controller = me.getComponent('controller').getValue() || 'ide'; + let id = me.getComponent('deviceid').getValue() || 0; + + return `${controller}${id}`; + }, + + initComponent: function () { + let me = this; + + Ext.apply(me, { + fieldLabel: gettext('Bus/Device'), + layout: 'hbox', + defaults: { + hideLabel: true, + }, + items: [ + { + xtype: 'pveBusSelector', + name: 'controller', + itemId: 'controller', + value: PVE.qemu.OSDefaults.generic.busType, + withVirtIO: me.withVirtIO, + withUnused: me.withUnused, + allowBlank: false, + flex: 2, + listeners: { + change: function (t, value) { + if (!value) { + return; + } + let field = me.down('field[name=deviceid]'); + me.setToFree([value], undefined, field); + field.setMaxValue(PVE.Utils.diskControllerMaxIDs[value] - 1); + field.validate(); + }, + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'deviceid', + itemId: 'deviceid', + minValue: 0, + maxValue: PVE.Utils.diskControllerMaxIDs.ide - 1, + value: '0', + flex: 1, + allowBlank: false, + validator: function (value) { + if (!me.rendered) { + return undefined; + } + let controller = me.down('field[name=controller]').getValue(); + let confid = controller + value; + if (Ext.isDefined(me.vmconfig[confid])) { + return 'This device is already in use.'; + } + return true; + }, + }, + ], + }); + + me.callParent(); + + if (me.selectFree) { + me.setVMConfig(me.vmconfig); + } + }, +}); +Ext.define('PVE.form.DayOfWeekSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveDayOfWeekSelector'], + comboItems: [], + initComponent: function () { + var me = this; + me.comboItems = [ + ['mon', Ext.util.Format.htmlDecode(Ext.Date.dayNames[1])], + ['tue', Ext.util.Format.htmlDecode(Ext.Date.dayNames[2])], + ['wed', Ext.util.Format.htmlDecode(Ext.Date.dayNames[3])], + ['thu', Ext.util.Format.htmlDecode(Ext.Date.dayNames[4])], + ['fri', Ext.util.Format.htmlDecode(Ext.Date.dayNames[5])], + ['sat', Ext.util.Format.htmlDecode(Ext.Date.dayNames[6])], + ['sun', Ext.util.Format.htmlDecode(Ext.Date.dayNames[0])], + ]; + this.callParent(); + }, +}); +Ext.define('PVE.form.DirMapSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.pveDirMapSelector', + + store: { + fields: ['name', 'path'], + filterOnLoad: true, + sorters: [ + { + property: 'id', + direction: 'ASC', + }, + ], + }, + + allowBlank: false, + autoSelect: false, + displayField: 'id', + valueField: 'id', + + listConfig: { + columns: [ + { + header: gettext('Directory ID'), + dataIndex: 'id', + flex: 1, + }, + { + header: gettext('Comment'), + dataIndex: 'description', + flex: 1, + }, + ], + }, + + setNodename: function (nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/cluster/mapping/dir?check-node=${nodename}`, + }); + + me.store.load(); + }, + + initComponent: function () { + var me = this; + + var nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + me.setNodename(nodename); + }, +}); +Ext.define('PVE.form.DiskFormatSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: 'widget.pveDiskFormatSelector', + comboItems: [ + ['raw', gettext('Raw disk image') + ' (raw)'], + ['qcow2', gettext('QEMU image format') + ' (qcow2)'], + ['vmdk', gettext('VMware image format') + ' (vmdk)'], + ], +}); +Ext.define('PVE.form.DiskStorageSelector', { + extend: 'Ext.container.Container', + alias: 'widget.pveDiskStorageSelector', + + layout: 'fit', + defaults: { + margin: '0 0 5 0', + }, + + // the fieldLabel for the storageselector + storageLabel: gettext('Storage'), + + // the content to show (e.g., images or rootdir) + storageContent: undefined, + + // if true, selects the first available storage + autoSelect: false, + + allowBlank: false, + emptyText: '', + + // hides the selection field + // this is always hidden on creation, + // and only shown when the storage needs a selection and + // hideSelection is not true + hideSelection: undefined, + + // hides the size field (e.g, for the efi disk dialog) + hideSize: false, + + // hides the format field + hideFormat: false, + + // sets the initial size value + // string because else we get a type confusion + defaultSize: '32', + + changeStorage: function (f, value) { + var me = this; + var formatsel = me.getComponent('diskformat'); + var hdfilesel = me.getComponent('hdimage'); + var hdsizesel = me.getComponent('disksize'); + + // initial store load, and reset/deletion of the storage + if (!value) { + hdfilesel.setDisabled(true); + hdfilesel.setVisible(false); + + formatsel.setDisabled(true); + return; + } + + var rec = f.store.getById(value); + // if the storage is not defined, or valid, + // we cannot know what to enable/disable + if (!rec) { + return; + } + + let validFormats = {}; + let defaultFormat = 'raw'; + let selectFormat = defaultFormat; + if (rec.data.formats) { + for (const format of rec.data.formats.supported) { + validFormats[format] = true; + } + defaultFormat = rec.data.formats.default; + } else if (rec.data.format) { + // legacy api, just for compatibility + // 0 is the formats, 1 the default in the backend + validFormats = rec.data.format[0]; + defaultFormat = rec.data.format[1]; + } + + if (Object.keys(validFormats).length > 0) { + delete validFormats.subvol; // we never need subvol in the gui + if (validFormats.qcow2) { + selectFormat = 'qcow2'; + } else if (validFormats.raw) { + selectFormat = 'raw'; + } else { + selectFormat = defaultFormat; + } + } + + var select = !!rec.data.select_existing && !me.hideSelection; + + let numberOfValidFormats = Ext.Object.getValues(validFormats).filter( + (valid) => !!valid, + ).length; + formatsel.setDisabled(me.hideFormat || numberOfValidFormats <= 1); + formatsel.setValue(selectFormat); + + hdfilesel.setDisabled(!select); + hdfilesel.setVisible(select); + if (select) { + hdfilesel.setStorage(value); + } + + hdsizesel.setDisabled(select || me.hideSize); + hdsizesel.setVisible(!select && !me.hideSize); + }, + + setNodename: function (nodename) { + var me = this; + var hdstorage = me.getComponent('hdstorage'); + var hdfilesel = me.getComponent('hdimage'); + + hdstorage.setNodename(nodename); + hdfilesel.setNodename(nodename); + }, + + setDisabled: function (value) { + var me = this; + var hdstorage = me.getComponent('hdstorage'); + + // reset on disable + if (value) { + hdstorage.setValue(); + } + hdstorage.setDisabled(value); + + // disabling does not always fire this event and we do not need + // the value of the validity + hdstorage.fireEvent('validitychange'); + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'pveStorageSelector', + itemId: 'hdstorage', + name: 'hdstorage', + fieldLabel: me.storageLabel, + nodename: me.nodename, + storageContent: me.storageContent, + disabled: me.disabled, + autoSelect: me.autoSelect, + allowBlank: me.allowBlank, + emptyText: me.emptyText, + listeners: { + change: { + fn: me.changeStorage, + scope: me, + }, + }, + }, + { + xtype: 'pveFileSelector', + name: 'hdimage', + itemId: 'hdimage', + fieldLabel: gettext('Disk image'), + nodename: me.nodename, + disabled: true, + hidden: true, + }, + { + xtype: 'numberfield', + itemId: 'disksize', + name: 'disksize', + fieldLabel: `${gettext('Disk size')} (${gettext('GiB')})`, + hidden: me.hideSize, + disabled: me.hideSize, + minValue: 0.001, + maxValue: 128 * 1024, + decimalPrecision: 3, + value: me.defaultSize, + allowBlank: false, + }, + { + xtype: 'pveDiskFormatSelector', + itemId: 'diskformat', + name: 'diskformat', + fieldLabel: gettext('Format'), + nodename: me.nodename, + disabled: true, + hidden: me.hideFormat || me.storageContent === 'rootdir', + value: 'qcow2', + allowBlank: false, + }, + ]; + + // use it to disable the children but not ourself + me.disabled = false; + + me.callParent(); + }, +}); +Ext.define('PVE.form.FileSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.pveFileSelector', + + editable: true, + anyMatch: true, + forceSelection: true, + + listeners: { + afterrender: function () { + var me = this; + if (!me.disabled) { + me.setStorage(me.storage, me.nodename); + } + }, + }, + + setStorage: function (storage, nodename) { + var me = this; + + var change = false; + if (storage && me.storage !== storage) { + me.storage = storage; + change = true; + } + + if (nodename && me.nodename !== nodename) { + me.nodename = nodename; + change = true; + } + + if (!(me.storage && me.nodename && change)) { + return; + } + + var url = '/api2/json/nodes/' + me.nodename + '/storage/' + me.storage + '/content'; + if (me.storageContent) { + url += '?content=' + me.storageContent; + } + + me.store.setProxy({ + type: 'proxmox', + url: url, + }); + + if (Ext.isFunction(me.filter)) { + me.store.clearFilter(); + me.store.addFilter([me.filter]); + } else { + me.store.clearFilter(); + } + + me.store.removeAll(); + me.store.load(); + }, + + setNodename: function (nodename) { + this.setStorage(undefined, nodename); + }, + + store: { + model: 'pve-storage-content', + }, + + allowBlank: false, + autoSelect: false, + valueField: 'volid', + displayField: 'text', + + // An optional filter function + filter: undefined, + + listConfig: { + width: 600, + columns: [ + { + header: gettext('Name'), + dataIndex: 'text', + hideable: false, + flex: 1, + }, + { + header: gettext('Format'), + width: 60, + dataIndex: 'format', + }, + { + header: gettext('Size'), + width: 100, + dataIndex: 'size', + renderer: Proxmox.Utils.format_size, + }, + ], + }, +}); +Ext.define('PVE.form.FirewallPolicySelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveFirewallPolicySelector'], + comboItems: [ + ['ACCEPT', 'ACCEPT'], + ['REJECT', 'REJECT'], + ['DROP', 'DROP'], + ], +}); +/* + * This is a global search field it loads the /cluster/resources on focus and displays the + * result in a floating grid. Filtering and sorting is done in the customFilter function + * + * Accepts key up/down and enter for input, and it opens to CTRL+SHIFT+F and CTRL+SPACE + */ +Ext.define('PVE.form.GlobalSearchField', { + extend: 'Ext.form.field.Text', + alias: 'widget.pveGlobalSearchField', + + emptyText: gettext('Search'), + enableKeyEvents: true, + selectOnFocus: true, + padding: '0 5 0 5', + + grid: { + xtype: 'gridpanel', + userCls: 'proxmox-tags-full', + focusOnToFront: false, + floating: true, + emptyText: Proxmox.Utils.noneText, + width: 600, + height: 400, + scrollable: { + xtype: 'scroller', + y: true, + x: true, + }, + store: { + model: 'PVEResources', + proxy: { + type: 'proxmox', + url: '/api2/extjs/cluster/resources', + }, + }, + plugins: { + ptype: 'bufferedrenderer', + trailingBufferZone: 20, + leadingBufferZone: 20, + }, + + hideMe: function () { + var me = this; + if (typeof me.ctxMenu !== 'undefined' && me.ctxMenu.isVisible()) { + return; + } + me.hasFocus = false; + if (!me.textfield.hasFocus) { + me.hide(); + } + }, + + setFocus: function () { + var me = this; + me.hasFocus = true; + }, + + listeners: { + rowclick: function (grid, record) { + var me = this; + me.textfield.selectAndHide(record.id); + }, + itemcontextmenu: function (v, record, item, index, event) { + var me = this; + me.ctxMenu = PVE.Utils.createCmdMenu(v, record, item, index, event); + }, + focusleave: 'hideMe', + focusenter: 'setFocus', + }, + + columns: [ + { + text: gettext('Type'), + dataIndex: 'type', + width: 100, + renderer: PVE.Utils.render_resource_type, + }, + { + text: gettext('Description'), + flex: 1, + dataIndex: 'text', + renderer: function (value, mD, rec) { + let overrides = PVE.UIOptions.tagOverrides; + let tags = PVE.Utils.renderTags(rec.data.tags, overrides); + return `${value}${tags}`; + }, + }, + { + text: gettext('Node'), + dataIndex: 'node', + }, + { + text: gettext('Pool'), + dataIndex: 'pool', + }, + ], + }, + + customFilter: function (item) { + let me = this; + + if (me.filterVal === '') { + item.data.relevance = 0; + return true; + } + // different types have different fields to search, e.g., a node will never have a pool + const fieldMap = { + pool: ['type', 'pool', 'text'], + node: ['type', 'node', 'text'], + storage: ['type', 'pool', 'node', 'storage'], + default: ['name', 'type', 'node', 'pool', 'vmid'], + }; + let fields = fieldMap[item.data.type] || fieldMap.default; + let fieldArr = fields.map((field) => item.data[field]?.toString().toLowerCase()); + if (item.data.tags) { + let tags = item.data.tags.split(/[;, ]/); + fieldArr.push(...tags); + } + + let filterWords = me.filterVal.split(/\s+/); + + // all text is case insensitive and each split-out word is searched for separately. + // a row gets 1 point for every partial match, and and additional point for every exact match + let match = 0; + for (let fieldValue of fieldArr) { + if (fieldValue === undefined || fieldValue === '') { + continue; + } + for (let filterWord of filterWords) { + if (fieldValue.indexOf(filterWord) !== -1) { + match++; // partial match + if (fieldValue === filterWord) { + match++; // exact match is worth more + } + } + } + } + item.data.relevance = match; // set the row's virtual 'relevance' value for ordering + return match > 0; + }, + + updateFilter: function (field, newValue, oldValue) { + let me = this; + // parse input and filter store, show grid + me.grid.store.filterVal = newValue.toLowerCase().trim(); + me.grid.store.clearFilter(true); + me.grid.store.filterBy(me.customFilter); + me.grid.getSelectionModel().select(0); + }, + + selectAndHide: function (id) { + var me = this; + me.tree.selectById(id); + me.grid.hide(); + me.setValue(''); + me.blur(); + }, + + onKey: function (field, e) { + var me = this; + var key = e.getKey(); + + switch (key) { + case Ext.event.Event.ENTER: + // go to first entry if there is one + if (me.grid.store.getCount() > 0) { + me.selectAndHide(me.grid.getSelection()[0].data.id); + } + break; + case Ext.event.Event.UP: + me.grid.getSelectionModel().selectPrevious(); + break; + case Ext.event.Event.DOWN: + me.grid.getSelectionModel().selectNext(); + break; + case Ext.event.Event.ESC: + me.grid.hide(); + me.blur(); + break; + } + }, + + loadValues: function (field) { + let me = this; + me.hasFocus = true; + me.grid.textfield = me; + me.grid.store.load(); + me.grid.showBy(me, 'tl-bl'); + }, + + hideGrid: function () { + let me = this; + me.hasFocus = false; + if (!me.grid.hasFocus) { + me.grid.hide(); + } + }, + + listeners: { + change: { + fn: 'updateFilter', + buffer: 250, + }, + specialkey: 'onKey', + focusenter: 'loadValues', + focusleave: { + fn: 'hideGrid', + delay: 100, + }, + }, + + toggleFocus: function () { + let me = this; + if (!me.hasFocus) { + me.focus(); + } else { + me.blur(); + } + }, + + initComponent: function () { + let me = this; + + if (!me.tree) { + throw 'no tree given'; + } + + me.grid = Ext.create(me.grid); + + me.callParent(); + + // bind CTRL + SHIFT + F and CTRL + SPACE to open/close the search + me.keymap = new Ext.KeyMap({ + target: Ext.get(document), + binding: [ + { + key: 'F', + ctrl: true, + shift: true, + fn: me.toggleFocus, + scope: me, + }, + { + key: ' ', + ctrl: true, + fn: me.toggleFocus, + scope: me, + }, + ], + }); + + // always select first item and sort by relevance after load + me.mon(me.grid.store, 'load', function () { + me.grid.getSelectionModel().select(0); + me.grid.store.sort({ + property: 'relevance', + direction: 'DESC', + }); + }); + }, +}); +Ext.define('pve-groups', { + extend: 'Ext.data.Model', + fields: ['groupid', 'comment', 'users'], + proxy: { + type: 'proxmox', + url: '/api2/json/access/groups', + }, + idProperty: 'groupid', +}); + +Ext.define('PVE.form.GroupSelector', { + extend: 'Proxmox.form.ComboGrid', + xtype: 'pveGroupSelector', + + editable: true, + anyMatch: true, + forceSelection: true, + + allowBlank: false, + autoSelect: false, + valueField: 'groupid', + displayField: 'groupid', + listConfig: { + columns: [ + { + header: gettext('Group'), + sortable: true, + dataIndex: 'groupid', + flex: 1, + }, + { + header: gettext('Comment'), + sortable: false, + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + { + header: gettext('Users'), + sortable: false, + dataIndex: 'users', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + }, + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-groups', + sorters: [ + { + property: 'groupid', + }, + ], + }); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + + store.load(); + }, +}); +Ext.define('PVE.form.GuestIDSelector', { + extend: 'Ext.form.field.Number', + alias: 'widget.pveGuestIDSelector', + + allowBlank: false, + + minValue: 100, + + maxValue: 999999999, + + validateExists: undefined, + + loadNextFreeID: false, + + guestType: undefined, + + validator: function (value) { + var me = this; + + if (!Ext.isNumeric(value) || value < me.minValue || value > me.maxValue) { + // check is done by ExtJS + return true; + } + + if (me.validateExists === true && !me.exists) { + return me.unknownID; + } + + if (me.validateExists === false && me.exists) { + return me.inUseID; + } + + return true; + }, + + initComponent: function () { + var me = this; + var label = '{0} ID'; + var unknownID = gettext('This {0} ID does not exist'); + var inUseID = gettext('This {0} ID is already in use'); + var type = 'CT/VM'; + + if (me.guestType === 'lxc') { + type = 'CT'; + } else if (me.guestType === 'qemu') { + type = 'VM'; + } + + me.label = Ext.String.format(label, type); + me.unknownID = Ext.String.format(unknownID, type); + me.inUseID = Ext.String.format(inUseID, type); + + Ext.apply(me, { + fieldLabel: me.label, + listeners: { + change: function (field, newValue, oldValue) { + if (!Ext.isDefined(me.validateExists)) { + return; + } + Proxmox.Utils.API2Request({ + params: { vmid: newValue }, + url: '/cluster/nextid', + method: 'GET', + success: function (response, opts) { + me.exists = false; + me.validate(); + }, + failure: function (response, opts) { + me.exists = true; + me.validate(); + }, + }); + }, + }, + }); + + me.callParent(); + + if (me.loadNextFreeID) { + Proxmox.Utils.API2Request({ + url: '/cluster/nextid', + method: 'GET', + success: function (response, opts) { + me.setRawValue(response.result.data); + }, + }); + } + }, +}); +Ext.define('PVE.form.hashAlgorithmSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveHashAlgorithmSelector'], + config: { + deleteEmpty: false, + }, + comboItems: [ + ['__default__', 'None'], + ['md5', 'MD5'], + ['sha1', 'SHA-1'], + ['sha224', 'SHA-224'], + ['sha256', 'SHA-256'], + ['sha384', 'SHA-384'], + ['sha512', 'SHA-512'], + ], +}); +Ext.define('PVE.form.HotplugFeatureSelector', { + extend: 'Ext.form.CheckboxGroup', + alias: 'widget.pveHotplugFeatureSelector', + + columns: 1, + vertical: true, + + defaults: { + name: 'hotplugCbGroup', + submitValue: false, + }, + items: [ + { + boxLabel: gettext('Disk'), + inputValue: 'disk', + checked: true, + }, + { + boxLabel: gettext('Network'), + inputValue: 'network', + checked: true, + }, + { + boxLabel: 'USB', + inputValue: 'usb', + checked: true, + }, + { + boxLabel: gettext('Memory'), + inputValue: 'memory', + }, + { + boxLabel: gettext('CPU'), + inputValue: 'cpu', + }, + ], + + setValue: function (value) { + var me = this; + var newVal = []; + if (value === '1') { + newVal = ['disk', 'network', 'usb']; + } else if (value !== '0') { + newVal = value.split(','); + } + me.callParent([{ hotplugCbGroup: newVal }]); + }, + + // override framework function to + // assemble the hotplug value + getSubmitData: function () { + var me = this, + boxes = me.getBoxes(), + data = []; + Ext.Array.forEach(boxes, function (box) { + if (box.getValue()) { + data.push(box.inputValue); + } + }); + + /* because above is hotplug an array */ + if (data.length === 0) { + return { hotplug: '0' }; + } else { + return { hotplug: data.join(',') }; + } + }, +}); +Ext.define('PVE.form.IPProtocolSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveIPProtocolSelector'], + valueField: 'p', + displayField: 'p', + listConfig: { + columns: [ + { + header: gettext('Protocol'), + dataIndex: 'p', + hideable: false, + sortable: false, + width: 100, + }, + { + header: gettext('Number'), + dataIndex: 'n', + hideable: false, + sortable: false, + width: 50, + }, + { + header: gettext('Description'), + dataIndex: 'd', + hideable: false, + sortable: false, + flex: 1, + }, + ], + }, + store: { + fields: ['p', 'd', 'n'], + data: [ + { p: 'tcp', n: 6, d: 'Transmission Control Protocol' }, + { p: 'udp', n: 17, d: 'User Datagram Protocol' }, + { p: 'icmp', n: 1, d: 'Internet Control Message Protocol' }, + { p: 'igmp', n: 2, d: 'Internet Group Management' }, + { p: 'ggp', n: 3, d: 'gateway-gateway protocol' }, + { p: 'ipencap', n: 4, d: 'IP encapsulated in IP' }, + { p: 'st', n: 5, d: 'ST datagram mode' }, + { p: 'egp', n: 8, d: 'exterior gateway protocol' }, + { p: 'igp', n: 9, d: 'any private interior gateway (Cisco)' }, + { p: 'pup', n: 12, d: 'PARC universal packet protocol' }, + { p: 'hmp', n: 20, d: 'host monitoring protocol' }, + { p: 'xns-idp', n: 22, d: 'Xerox NS IDP' }, + { p: 'rdp', n: 27, d: '"reliable datagram" protocol' }, + { p: 'iso-tp4', n: 29, d: 'ISO Transport Protocol class 4 [RFC905]' }, + { p: 'dccp', n: 33, d: 'Datagram Congestion Control Prot. [RFC4340]' }, + { p: 'xtp', n: 36, d: 'Xpress Transfer Protocol' }, + { p: 'ddp', n: 37, d: 'Datagram Delivery Protocol' }, + { p: 'idpr-cmtp', n: 38, d: 'IDPR Control Message Transport' }, + { p: 'ipv6', n: 41, d: 'Internet Protocol, version 6' }, + { p: 'ipv6-route', n: 43, d: 'Routing Header for IPv6' }, + { p: 'ipv6-frag', n: 44, d: 'Fragment Header for IPv6' }, + { p: 'idrp', n: 45, d: 'Inter-Domain Routing Protocol' }, + { p: 'rsvp', n: 46, d: 'Reservation Protocol' }, + { p: 'gre', n: 47, d: 'General Routing Encapsulation' }, + { p: 'esp', n: 50, d: 'Encap Security Payload [RFC2406]' }, + { p: 'ah', n: 51, d: 'Authentication Header [RFC2402]' }, + { p: 'skip', n: 57, d: 'SKIP' }, + { p: 'ipv6-icmp', n: 58, d: 'ICMP for IPv6' }, + { p: 'ipv6-nonxt', n: 59, d: 'No Next Header for IPv6' }, + { p: 'ipv6-opts', n: 60, d: 'Destination Options for IPv6' }, + { p: 'vmtp', n: 81, d: 'Versatile Message Transport' }, + { p: 'eigrp', n: 88, d: 'Enhanced Interior Routing Protocol (Cisco)' }, + { p: 'ospf', n: 89, d: 'Open Shortest Path First IGP' }, + { p: 'ax.25', n: 93, d: 'AX.25 frames' }, + { p: 'ipip', n: 94, d: 'IP-within-IP Encapsulation Protocol' }, + { p: 'etherip', n: 97, d: 'Ethernet-within-IP Encapsulation [RFC3378]' }, + { p: 'encap', n: 98, d: 'Yet Another IP encapsulation [RFC1241]' }, + { p: 'pim', n: 103, d: 'Protocol Independent Multicast' }, + { p: 'ipcomp', n: 108, d: 'IP Payload Compression Protocol' }, + { p: 'vrrp', n: 112, d: 'Virtual Router Redundancy Protocol [RFC5798]' }, + { p: 'l2tp', n: 115, d: 'Layer Two Tunneling Protocol [RFC2661]' }, + { p: 'isis', n: 124, d: 'IS-IS over IPv4' }, + { p: 'sctp', n: 132, d: 'Stream Control Transmission Protocol' }, + { p: 'fc', n: 133, d: 'Fibre Channel' }, + { p: 'mobility-header', n: 135, d: 'Mobility Support for IPv6 [RFC3775]' }, + { p: 'udplite', n: 136, d: 'UDP-Lite [RFC3828]' }, + { p: 'mpls-in-ip', n: 137, d: 'MPLS-in-IP [RFC4023]' }, + { p: 'hip', n: 139, d: 'Host Identity Protocol' }, + { p: 'shim6', n: 140, d: 'Shim6 Protocol [RFC5533]' }, + { p: 'wesp', n: 141, d: 'Wrapped Encapsulating Security Payload' }, + { p: 'rohc', n: 142, d: 'Robust Header Compression' }, + ], + }, +}); +Ext.define('PVE.form.IPRefSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveIPRefSelector'], + + base_url: undefined, + + preferredValue: '', // hack: else Form sets dirty flag? + + ref_type: undefined, // undefined = any [undefined, 'ipset' or 'alias'] + + valueField: 'scopedref', + displayField: 'ref', + notFoundIsValid: true, + + initComponent: function () { + var me = this; + + if (!me.base_url) { + throw 'no base_url specified'; + } + + var url = '/api2/json' + me.base_url; + if (me.ref_type) { + url += '?type=' + me.ref_type; + } + + var store = Ext.create('Ext.data.Store', { + autoLoad: true, + fields: [ + 'type', + 'name', + 'ref', + 'comment', + 'scope', + { + name: 'scopedref', + calculate: function (v) { + if (v.type === 'alias') { + return `${v.scope}/${v.name}`; + } else if (v.type === 'ipset') { + return `+${v.scope}/${v.name}`; + } else { + return v.ref; + } + }, + }, + ], + idProperty: 'ref', + proxy: { + type: 'proxmox', + url: url, + }, + sorters: { + property: 'ref', + direction: 'ASC', + }, + }); + + var columns = []; + + if (!me.ref_type) { + columns.push({ + header: gettext('Type'), + dataIndex: 'type', + hideable: false, + width: 60, + }); + } + + let scopes = { + dc: gettext('Datacenter'), + guest: gettext('Guest'), + sdn: gettext('SDN'), + }; + + columns.push( + { + header: gettext('Name'), + dataIndex: 'ref', + hideable: false, + width: 140, + }, + { + header: gettext('Scope'), + dataIndex: 'scope', + hideable: false, + width: 140, + renderer: function (value) { + return scopes[value] ?? 'unknown scope'; + }, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + minWidth: 60, + flex: 1, + }, + ); + + Ext.apply(me, { + store: store, + listConfig: { + columns: columns, + width: 500, + }, + }); + + me.on('beforequery', function (queryPlan) { + return !(queryPlan.query === null || queryPlan.query.match(/^\d/)); + }); + + me.callParent(); + }, +}); +Ext.define('PVE.form.MDevSelector', { + extend: 'Proxmox.form.ComboGrid', + xtype: 'pveMDevSelector', + + store: { + fields: ['type', 'available', 'description'], + filterOnLoad: true, + sorters: [ + { + property: 'type', + direction: 'ASC', + }, + ], + }, + autoSelect: false, + valueField: 'type', + displayField: 'type', + listConfig: { + width: 550, + columns: [ + { + header: gettext('Type'), + dataIndex: 'type', + renderer: function (value, md, rec) { + if (rec.data.name !== undefined) { + return `${rec.data.name} (${value})`; + } + return value; + }, + flex: 1, + }, + { + header: gettext('Avail'), + dataIndex: 'available', + width: 60, + }, + { + header: gettext('Description'), + dataIndex: 'description', + flex: 1, + cellWrap: true, + renderer: function (value) { + if (!value) { + return ''; + } + + return value.split('\n').join('
'); + }, + }, + ], + }, + + setPciIdOrMapping: function (pciIdOrMapping, force) { + var me = this; + + if (!force && (!pciIdOrMapping || me.pciIdOrMapping === pciIdOrMapping)) { + return; + } + + me.pciIdOrMapping = pciIdOrMapping; + me.updateProxy(); + }, + + setNodename: function (nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + me.updateProxy(); + }, + + updateProxy: function () { + var me = this; + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/hardware/pci/${me.pciIdOrMapping}/mdev`, + }); + me.store.load(); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + me.callParent(); + + if (me.pciIdOrMapping) { + me.setPciIdOrMapping(me.pciIdOrMapping, true); + } + }, +}); +Ext.define('PVE.form.MemoryField', { + extend: 'Ext.form.field.Number', + alias: 'widget.pveMemoryField', + + allowBlank: false, + + hotplug: false, + + minValue: 32, + + maxValue: 4178944, + + step: 32, + + value: '512', // qm backend default + + allowDecimals: false, + + allowExponential: false, + + computeUpDown: function (value) { + var me = this; + + if (!me.hotplug) { + return { up: value + me.step, down: value - me.step }; + } + + var dimm_size = 512; + var prev_dimm_size = 0; + var min_size = 1024; + var current_size = min_size; + var value_up = min_size; + var value_down = min_size; + var value_start = min_size; + + var i, j; + for (j = 0; j < 9; j++) { + for (i = 0; i < 32; i++) { + if (value >= current_size && value < current_size + dimm_size) { + value_start = current_size; + value_up = current_size + dimm_size; + value_down = current_size - (i === 0 ? prev_dimm_size : dimm_size); + } + current_size += dimm_size; + } + prev_dimm_size = dimm_size; + dimm_size = dimm_size * 2; + } + + return { up: value_up, down: value_down, start: value_start }; + }, + + onSpinUp: function () { + var me = this; + if (!me.readOnly) { + let res = me.computeUpDown(me.getValue()); + me.setValue(Ext.Number.constrain(res.up, me.minValue, me.maxValue)); + } + }, + + onSpinDown: function () { + var me = this; + if (!me.readOnly) { + let res = me.computeUpDown(me.getValue()); + me.setValue(Ext.Number.constrain(res.down, me.minValue, me.maxValue)); + } + }, + + initComponent: function () { + var me = this; + + if (me.hotplug) { + me.minValue = 1024; + + me.on('blur', function (field) { + var value = me.getValue(); + var res = me.computeUpDown(value); + if (value === res.start || value === res.up || value === res.down) { + return; + } + field.setValue(res.up); + }); + } + + me.callParent(); + }, +}); +Ext.define('PVE.form.MultiPCISelector', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveMultiPCISelector', + + emptyText: gettext('No Devices found'), + + mixins: { + field: 'Ext.form.field.Field', + }, + + // will be called after loading finished + onLoadCallBack: Ext.emptyFn, + + getValue: function () { + let me = this; + return me.value ?? []; + }, + + getSubmitData: function () { + let me = this; + let res = {}; + res[me.name] = me.getValue(); + return res; + }, + + setValue: function (value) { + let me = this; + + value ??= []; + + me.updateSelectedDevices(value); + + return me.mixins.field.setValue.call(me, value); + }, + + getErrors: function () { + let me = this; + + let errorCls = ['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']; + + if (me.getValue().length < 1) { + let error = gettext('Must choose at least one device'); + me.addCls(errorCls); + me.getActionEl()?.dom.setAttribute('data-errorqtip', error); + + return [error]; + } + + me.removeCls(errorCls); + me.getActionEl()?.dom.setAttribute('data-errorqtip', ''); + + return []; + }, + + viewConfig: { + getRowClass: function (record) { + if (record.data.disabled === true) { + return 'x-item-disabled'; + } + return ''; + }, + }, + + updateSelectedDevices: function (value = []) { + let me = this; + + let recs = []; + let store = me.getStore(); + + for (const map of value) { + let parsed = PVE.Parser.parsePropertyString(map); + if (parsed.node !== me.nodename) { + continue; + } + + let rec = store.getById(parsed.path); + if (rec) { + recs.push(rec); + } + } + + me.suspendEvent('change'); + me.setSelection(); + me.setSelection(recs); + me.resumeEvent('change'); + }, + + setNodename: function (nodename) { + let me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.getStore().setProxy({ + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/hardware/pci?pci-class-blacklist=', + }); + + me.setSelection(); + + me.getStore().load({ + callback: (recs, op, success) => me.addSlotRecords(recs, op, success), + }); + }, + + setMdev: function (mdev) { + let me = this; + if (mdev) { + me.getStore().addFilter({ + id: 'mdev-filter', + property: 'mdev', + value: '1', + operator: '=', + }); + } else { + me.getStore().removeFilter('mdev-filter'); + } + me.setSelection(); + }, + + // adds the virtual 'slot' records (e.g. '0000:01:00') to the store + addSlotRecords: function (records, _op, success) { + let me = this; + if (!success) { + return; + } + + let slots = {}; + records.forEach((rec) => { + let slotname = rec.data.id.slice(0, -2); // remove function + if (slots[slotname] !== undefined) { + slots[slotname].count++; + rec.set('slot', slots[slotname]); + return; + } + slots[slotname] = { + count: 1, + }; + + rec.set('slot', slots[slotname]); + + if (rec.data.id.endsWith('.0')) { + slots[slotname].device = rec.data; + } + }); + + let store = me.getStore(); + + for (const [slot, { count, device }] of Object.entries(slots)) { + if (count === 1) { + continue; + } + store.add( + Ext.apply( + {}, + { + id: slot, + mdev: undefined, + device_name: gettext('Pass through all functions as one device'), + }, + device, + ), + ); + } + + me.updateSelectedDevices(me.value); + }, + + selectionChange: function (_grid, selection) { + let me = this; + + let ids = {}; + selection + .filter((rec) => rec.data.id.indexOf('.') === -1) + .forEach((rec) => { + ids[rec.data.id] = true; + }); + + let to_disable = []; + + me.getStore().each((rec) => { + let id = rec.data.id; + rec.set('disabled', false); + if (id.indexOf('.') === -1) { + return; + } + let slot = id.slice(0, -2); // remove function + + if (ids[slot]) { + to_disable.push(rec); + rec.set('disabled', true); + } + }); + + me.suspendEvent('selectionchange'); + me.getSelectionModel().deselect(to_disable); + me.resumeEvent('selectionchange'); + + me.value = me.getSelection().map((rec) => { + let res = { + path: rec.data.id, + node: me.nodename, + id: `${rec.data.vendor}:${rec.data.device}`.replace(/0x/g, ''), + 'subsystem-id': `${rec.data.subsystem_vendor}:${rec.data.subsystem_device}`.replace( + /0x/g, + '', + ), + }; + + if (rec.data.iommugroup !== -1) { + res.iommugroup = rec.data.iommugroup; + } + + return PVE.Parser.printPropertyString(res); + }); + me.checkChange(); + }, + + selModel: { + type: 'checkboxmodel', + mode: 'SIMPLE', + }, + + columns: [ + { + header: 'ID', + dataIndex: 'id', + renderer: function (value, _md, rec) { + if (value.match(/\.[0-9a-f]/i) && rec.data.slot?.count > 1) { + return ` ${value}`; + } + return value; + }, + width: 150, + }, + { + header: gettext('IOMMU Group'), + dataIndex: 'iommugroup', + renderer: (v, _md, rec) => (rec.data.slot === rec.data.id ? '' : v === -1 ? '-' : v), + width: 50, + }, + { + header: gettext('Vendor'), + dataIndex: 'vendor_name', + flex: 3, + }, + { + header: gettext('Device'), + dataIndex: 'device_name', + flex: 6, + }, + { + header: gettext('Mediated Devices'), + dataIndex: 'mdev', + flex: 1, + renderer: function (val) { + return Proxmox.Utils.format_boolean(!!val); + }, + }, + ], + + listeners: { + selectionchange: function () { + this.selectionChange(...arguments); + }, + }, + + store: { + fields: [ + 'id', + 'vendor_name', + 'device_name', + 'vendor', + 'device', + 'iommugroup', + 'mdev', + 'subsystem_vendor', + 'subsystem_device', + 'disabled', + { + name: 'subsystem-vendor', + calculate: function (data) { + return data.subsystem_vendor; + }, + }, + { + name: 'subsystem-device', + calculate: function (data) { + return data.subsystem_device; + }, + }, + ], + sorters: [ + { + property: 'id', + direction: 'ASC', + }, + ], + }, + + initComponent: function () { + let me = this; + + let nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + me.mon(me.getStore(), 'load', me.onLoadCallBack); + + Proxmox.Utils.monStoreErrors(me, me.getStore(), true); + + me.setNodename(nodename); + + me.initField(); + }, +}); +Ext.define('PVE.form.NetworkCardSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: 'widget.pveNetworkCardSelector', + comboItems: [ + ['e1000', 'Intel E1000'], + ['e1000e', 'Intel E1000E'], + ['virtio', 'VirtIO (' + gettext('paravirtualized') + ')'], + ['rtl8139', 'Realtek RTL8139'], + ['vmxnet3', 'VMware vmxnet3'], + ], +}); +Ext.define('PVE.form.NodeSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveNodeSelector'], + + // invalidate nodes which are offline + onlineValidator: false, + + selectCurNode: false, + + // do not allow those nodes (array) + disallowedNodes: undefined, + + // only allow those nodes (array) + allowedNodes: undefined, + + valueField: 'node', + displayField: 'node', + store: { + fields: ['node', 'cpu', 'maxcpu', 'mem', 'maxmem', 'uptime'], + proxy: { + type: 'proxmox', + url: '/api2/json/nodes', + }, + sorters: [ + { + property: 'node', + direction: 'ASC', + }, + { + property: 'mem', + direction: 'DESC', + }, + ], + }, + + listConfig: { + columns: [ + { + header: gettext('Node'), + dataIndex: 'node', + sortable: true, + hideable: false, + flex: 1, + }, + { + header: gettext('Memory usage') + ' %', + renderer: PVE.Utils.render_mem_usage_percent, + sortable: true, + width: 100, + dataIndex: 'mem', + }, + { + header: gettext('CPU usage'), + renderer: Proxmox.Utils.render_cpu, + sortable: true, + width: 100, + dataIndex: 'cpu', + }, + ], + }, + + validator: function (value) { + let me = this; + if (!me.onlineValidator || (me.allowBlank && !value)) { + return true; + } + + let offline = [], + notAllowed = []; + Ext.Array.each(value.split(/\s*,\s*/), function (node) { + let rec = me.store.findRecord(me.valueField, node, 0, false, true, true); + if (!(rec && rec.data) || rec.data.status !== 'online') { + offline.push(node); + } else if (me.allowedNodes && !Ext.Array.contains(me.allowedNodes, node)) { + notAllowed.push(node); + } + }); + + if (value && notAllowed.length !== 0) { + return 'Node ' + notAllowed.join(', ') + ' is not allowed for this action!'; + } + if (value && offline.length !== 0) { + return 'Node ' + offline.join(', ') + ' seems to be offline!'; + } + return true; + }, + + initComponent: function () { + var me = this; + + if (me.selectCurNode && PVE.curSelectedNode && PVE.curSelectedNode.data.node) { + me.preferredValue = PVE.curSelectedNode.data.node; + } + + me.callParent(); + me.getStore().load(); + + me.getStore().addFilter( + new Ext.util.Filter({ + // filter out disallowed nodes + filterFn: (item) => + !(me.disallowedNodes && me.disallowedNodes.includes(item.data.node)), + }), + ); + + me.mon(me.getStore(), 'load', () => me.isValid()); + }, +}); +Ext.define('PVE.form.NotificationModeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveNotificationModeSelector'], + comboItems: [ + ['notification-target', gettext('Target')], + ['mailto', gettext('E-Mail')], + ], +}); +Ext.define('PVE.form.NotificationTargetSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveNotificationTargetSelector'], + + // set default value to empty array, else it inits it with + // null and after the store load it is an empty array, + // triggering dirtychange + value: [], + valueField: 'name', + displayField: 'name', + deleteEmpty: true, + skipEmptyText: true, + + store: { + fields: ['name', 'type', 'comment'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/notifications/targets', + }, + sorters: [ + { + property: 'name', + direction: 'ASC', + }, + ], + autoLoad: true, + }, + + listConfig: { + columns: [ + { + header: gettext('Target'), + dataIndex: 'name', + sortable: true, + hideable: false, + flex: 1, + }, + { + header: gettext('Type'), + dataIndex: 'type', + sortable: true, + hideable: false, + flex: 1, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + sortable: true, + hideable: false, + flex: 2, + }, + ], + }, +}); +Ext.define('PVE.form.EmailNotificationSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveEmailNotificationSelector'], + comboItems: [ + ['always', gettext('Always')], + ['failure', gettext('On failure only')], + ], +}); +Ext.define('PVE.form.PCISelector', { + extend: 'Proxmox.form.ComboGrid', + xtype: 'pvePCISelector', + + store: { + fields: ['id', 'vendor_name', 'device_name', 'vendor', 'device', 'iommugroup', 'mdev'], + filterOnLoad: true, + sorters: [ + { + property: 'id', + direction: 'ASC', + }, + ], + }, + + autoSelect: false, + valueField: 'id', + displayField: 'id', + + // can contain a load callback for the store + // useful to determine the state of the IOMMU + onLoadCallBack: undefined, + + listConfig: { + minHeight: 80, + width: 800, + columns: [ + { + header: 'ID', + dataIndex: 'id', + width: 100, + }, + { + header: gettext('IOMMU Group'), + dataIndex: 'iommugroup', + renderer: (v) => (v === -1 ? '-' : v), + width: 75, + }, + { + header: gettext('Vendor'), + dataIndex: 'vendor_name', + flex: 2, + }, + { + header: gettext('Device'), + dataIndex: 'device_name', + flex: 6, + }, + { + header: gettext('Mediated Devices'), + dataIndex: 'mdev', + flex: 1, + renderer: function (val) { + return Proxmox.Utils.format_boolean(!!val); + }, + }, + ], + }, + + setNodename: function (nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/hardware/pci', + }); + + me.store.load(); + }, + + initComponent: function () { + var me = this; + + var nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + if (me.onLoadCallBack !== undefined) { + me.mon(me.getStore(), 'load', me.onLoadCallBack); + } + + me.setNodename(nodename); + }, +}); +Ext.define('pve-mapped-pci-model', { + extend: 'Ext.data.Model', + + fields: ['id', 'path', 'vendor', 'device', 'iommugroup', 'mdev', 'description', 'map'], + idProperty: 'id', +}); + +Ext.define('PVE.form.PCIMapSelector', { + extend: 'Proxmox.form.ComboGrid', + xtype: 'pvePCIMapSelector', + + store: { + model: 'pve-mapped-pci-model', + filterOnLoad: true, + sorters: [ + { + property: 'id', + direction: 'ASC', + }, + ], + }, + + autoSelect: false, + valueField: 'id', + displayField: 'id', + + // can contain a load callback for the store + // useful to determine the state of the IOMMU + onLoadCallBack: undefined, + + listConfig: { + width: 800, + columns: [ + { + header: gettext('ID'), + dataIndex: 'id', + flex: 1, + }, + { + header: gettext('Description'), + dataIndex: 'description', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + { + header: gettext('Status'), + dataIndex: 'checks', + renderer: function (value) { + let _me = this; + + if (!Ext.isArray(value) || !value?.length) { + return ` ${gettext('Mapping matches host data')}`; + } + + let checks = []; + + value.forEach((check) => { + let iconCls; + switch (check?.severity) { + case 'warning': + iconCls = 'fa-exclamation-circle warning'; + break; + case 'error': + iconCls = 'fa-times-circle critical'; + break; + } + + let message = check?.message; + let icon = ``; + if (iconCls !== undefined) { + checks.push(`${icon} ${message}`); + } + }); + + return checks.join('
'); + }, + flex: 3, + }, + ], + }, + + setNodename: function (nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/cluster/mapping/pci?check-node=${nodename}`, + }); + + me.store.load(); + }, + + initComponent: function () { + var me = this; + + var nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + if (me.onLoadCallBack !== undefined) { + me.mon(me.getStore(), 'load', me.onLoadCallBack); + } + + me.setNodename(nodename); + }, +}); +Ext.define('PVE.form.PermPathSelector', { + extend: 'Ext.form.field.ComboBox', + xtype: 'pvePermPathSelector', + + valueField: 'value', + displayField: 'value', + typeAhead: true, + queryMode: 'local', + width: 380, + + store: { + type: 'pvePermPath', + }, +}); +Ext.define( + 'PVE.form.PoolSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pvePoolSelector'], + + allowBlank: false, + valueField: 'poolid', + displayField: 'poolid', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-pools', + sorters: 'poolid', + }); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('Pool'), + sortable: true, + dataIndex: 'poolid', + flex: 1, + }, + { + header: gettext('Comment'), + sortable: false, + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-pools', { + extend: 'Ext.data.Model', + fields: ['poolid', 'comment'], + proxy: { + type: 'proxmox', + url: '/api2/json/pools', + }, + idProperty: 'poolid', + }); + }, +); +Ext.define('PVE.form.preallocationSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pvePreallocationSelector'], + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['off', 'Off'], + ['metadata', 'Metadata'], + ['falloc', 'Full (posix_fallocate)'], + ['full', 'Full'], + ], +}); +Ext.define('PVE.form.PrivilegesSelector', { + extend: 'Proxmox.form.KVComboBox', + xtype: 'pvePrivilegesSelector', + + multiSelect: true, + + initComponent: function () { + let me = this; + + me.callParent(); + + Proxmox.Utils.API2Request({ + url: '/access/roles/Administrator', + method: 'GET', + success: function (response, options) { + let data = Object.keys(response.result.data).map((key) => [key, key]); + + me.store.setData(data); + + me.store.sort({ + property: 'key', + direction: 'ASC', + }); + }, + failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, +}); +Ext.define('PVE.form.QemuBiosSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveQemuBiosSelector'], + + initComponent: function () { + var me = this; + + me.comboItems = [ + ['__default__', PVE.Utils.render_qemu_bios('')], + ['seabios', PVE.Utils.render_qemu_bios('seabios')], + ['ovmf', PVE.Utils.render_qemu_bios('ovmf')], + ]; + + me.callParent(); + }, +}); +Ext.define( + 'PVE.form.SDNControllerSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveSDNControllerSelector'], + + allowBlank: false, + valueField: 'controller', + displayField: 'controller', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-sdn-controller', + sorters: { + property: 'controller', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('Controller'), + sortable: true, + dataIndex: 'controller', + flex: 1, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-sdn-controller', { + extend: 'Ext.data.Model', + fields: ['controller'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/controllers', + }, + idProperty: 'controller', + }); + }, +); +Ext.define( + 'PVE.form.SDNZoneSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveSDNZoneSelector'], + + allowBlank: false, + valueField: 'zone', + displayField: 'zone', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-sdn-zone', + sorters: { + property: 'zone', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('Zone'), + sortable: true, + dataIndex: 'zone', + flex: 1, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-sdn-zone', { + extend: 'Ext.data.Model', + fields: ['zone', 'type'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/zones', + }, + idProperty: 'zone', + }); + }, +); +Ext.define( + 'PVE.form.SDNVnetSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveSDNVnetSelector'], + + allowBlank: false, + valueField: 'vnet', + displayField: 'vnet', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-sdn-vnet', + sorters: { + property: 'vnet', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('VNet'), + sortable: true, + dataIndex: 'vnet', + flex: 1, + }, + { + header: gettext('Alias'), + flex: 1, + dataIndex: 'alias', + }, + { + header: gettext('Tag'), + flex: 1, + dataIndex: 'tag', + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-sdn-vnet', { + extend: 'Ext.data.Model', + fields: ['alias', 'tag', 'type', 'vnet', 'zone'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/vnets', + }, + idProperty: 'vnet', + }); + }, +); +Ext.define( + 'PVE.form.SDNIpamSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveSDNIpamSelector'], + + allowBlank: false, + valueField: 'ipam', + displayField: 'ipam', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-sdn-ipam', + sorters: { + property: 'ipam', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('Ipam'), + sortable: true, + dataIndex: 'ipam', + flex: 1, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-sdn-ipam', { + extend: 'Ext.data.Model', + fields: ['ipam'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/ipams', + }, + idProperty: 'ipam', + }); + }, +); +Ext.define( + 'PVE.form.SDNDnsSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveSDNDnsSelector'], + + allowBlank: false, + valueField: 'dns', + displayField: 'dns', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-sdn-dns', + sorters: { + property: 'dns', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('dns'), + sortable: true, + dataIndex: 'dns', + flex: 1, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-sdn-dns', { + extend: 'Ext.data.Model', + fields: ['dns'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/dns', + }, + idProperty: 'dns', + }); + }, +); +Ext.define('PVE.form.ScsiHwSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveScsiHwSelector'], + comboItems: [ + ['__default__', PVE.Utils.render_scsihw('')], + ['lsi', PVE.Utils.render_scsihw('lsi')], + ['lsi53c810', PVE.Utils.render_scsihw('lsi53c810')], + ['megasas', PVE.Utils.render_scsihw('megasas')], + ['virtio-scsi-pci', PVE.Utils.render_scsihw('virtio-scsi-pci')], + ['virtio-scsi-single', PVE.Utils.render_scsihw('virtio-scsi-single')], + ['pvscsi', PVE.Utils.render_scsihw('pvscsi')], + ], +}); +Ext.define('PVE.form.SecurityGroupsSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveSecurityGroupsSelector'], + + valueField: 'group', + displayField: 'group', + initComponent: function () { + var me = this; + + var store = Ext.create('Ext.data.Store', { + autoLoad: true, + fields: ['group', 'comment'], + idProperty: 'group', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/firewall/groups', + }, + sorters: { + property: 'group', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + listConfig: { + columns: [ + { + header: gettext('Security Group'), + dataIndex: 'group', + hideable: false, + width: 100, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: function (value, metaData) { + let comment = Ext.String.htmlEncode(value) || ''; + if (comment.length * 12 > metaData.column.cellWidth) { + let qtip = Ext.htmlEncode(comment); + comment = `${comment}`; + } + return comment; + }, + flex: 1, + }, + ], + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.form.SnapshotSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.PVE.form.SnapshotSelector'], + + valueField: 'name', + displayField: 'name', + + loadStore: function (nodename, vmid) { + var me = this; + + if (!nodename) { + return; + } + + me.nodename = nodename; + + if (!vmid) { + return; + } + + me.vmid = vmid; + + me.store.setProxy({ + type: 'proxmox', + url: + '/api2/json/nodes/' + + me.nodename + + '/' + + me.guestType + + '/' + + me.vmid + + '/snapshot', + }); + + me.store.load(); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + if (!me.guestType) { + throw 'no guest type specified'; + } + + var store = Ext.create('Ext.data.Store', { + fields: ['name'], + filterOnLoad: true, + }); + + Ext.apply(me, { + store: store, + listConfig: { + columns: [ + { + header: gettext('Snapshot'), + dataIndex: 'name', + hideable: false, + flex: 1, + }, + ], + }, + }); + + me.callParent(); + + me.loadStore(me.nodename, me.vmid); + }, +}); +Ext.define('PVE.form.SpiceEnhancementSelector', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveSpiceEnhancementSelector', + + viewModel: {}, + + items: [ + { + xtype: 'proxmoxcheckbox', + itemId: 'foldersharing', + name: 'foldersharing', + reference: 'foldersharing', + fieldLabel: 'Folder Sharing', + uncheckedValue: 0, + }, + { + xtype: 'proxmoxKVComboBox', + itemId: 'videostreaming', + name: 'videostreaming', + value: 'off', + fieldLabel: 'Video Streaming', + comboItems: [ + ['off', 'off'], + ['all', 'all'], + ['filter', 'filter'], + ], + }, + { + xtype: 'displayfield', + itemId: 'spicehint', + userCls: 'pmx-hint', + value: gettext( + 'To use these features set the display to SPICE in the hardware settings of the VM.', + ), + hidden: true, + }, + { + xtype: 'displayfield', + itemId: 'spicefolderhint', + userCls: 'pmx-hint', + value: gettext('Make sure the SPICE WebDav daemon is installed in the VM.'), + bind: { + hidden: '{!foldersharing.checked}', + }, + }, + ], + + onGetValues: function (values) { + var ret = {}; + + if (values.videostreaming !== 'off') { + ret.videostreaming = values.videostreaming; + } + if (values.foldersharing) { + ret.foldersharing = 1; + } + if (Ext.Object.isEmpty(ret)) { + return { delete: 'spice_enhancements' }; + } + var enhancements = PVE.Parser.printPropertyString(ret); + return { spice_enhancements: enhancements }; + }, + + setValues: function (values) { + var vga = PVE.Parser.parsePropertyString(values.vga, 'type'); + if (!/^qxl\d?$/.test(vga.type)) { + this.down('#spicehint').setVisible(true); + } + if (values.spice_enhancements) { + let enhancements = PVE.Parser.parsePropertyString(values.spice_enhancements); + enhancements.foldersharing = PVE.Parser.parseBoolean(enhancements.foldersharing, 0); + this.callParent([enhancements]); + } + }, +}); +Ext.define('PVE.form.StorageScanNodeSelector', { + extend: 'PVE.form.NodeSelector', + xtype: 'pveStorageScanNodeSelector', + + name: 'storageScanNode', + itemId: 'pveStorageScanNodeSelector', + fieldLabel: gettext('Scan node'), + allowBlank: true, + disallowedNodes: undefined, + autoSelect: false, + submitValue: false, + value: null, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Scan for available storages on the selected node'), + }, + triggers: { + clear: { + handler: function () { + let me = this; + me.setValue(null); + }, + }, + }, + + emptyText: Proxmox.NodeName, + + setValue: function (value) { + let me = this; + me.callParent([value]); + me.triggers.clear.setVisible(!!value); + }, +}); +Ext.define( + 'PVE.form.StorageSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.pveStorageSelector', + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: { + clusterView: false, + }, + + allowBlank: false, + valueField: 'storage', + displayField: 'storage', + listConfig: { + cbind: { + clusterView: '{clusterView}', + }, + width: 450, + columns: [ + { + header: gettext('Name'), + dataIndex: 'storage', + hideable: false, + flex: 1, + }, + { + header: gettext('Type'), + width: 75, + dataIndex: 'type', + }, + { + header: gettext('Avail'), + width: 90, + dataIndex: 'avail', + renderer: Proxmox.Utils.format_size, + cbind: { + hidden: '{clusterView}', + }, + }, + { + header: gettext('Capacity'), + width: 90, + dataIndex: 'total', + renderer: Proxmox.Utils.format_size, + cbind: { + hidden: '{clusterView}', + }, + }, + { + header: gettext('Nodes'), + width: 120, + dataIndex: 'nodes', + renderer: (value) => (value ? value : '-- ' + gettext('All') + ' --'), + cbind: { + hidden: '{!clusterView}', + }, + }, + { + header: gettext('Shared'), + width: 70, + dataIndex: 'shared', + renderer: Proxmox.Utils.format_boolean, + cbind: { + hidden: '{!clusterView}', + }, + }, + ], + }, + + reloadStorageList: function () { + let me = this; + + if (me.clusterView) { + me.getStore().setProxy({ + type: 'proxmox', + url: `/api2/json/storage`, + }); + + // filter here, back-end does not support it currently + let filters = [(storage) => !storage.data.disable]; + + if (me.storageContent) { + filters.push((storage) => + storage.data.content.split(',').includes(me.storageContent), + ); + } + + if (me.nodename) { + filters.push( + (storage) => + !storage.data.nodes || storage.data.nodes.includes(me.nodename), + ); + } + + me.getStore().clearFilter(); + me.getStore().setFilters(filters); + } else { + if (!me.nodename) { + return; + } + + let params = { + format: 1, + }; + if (me.storageContent) { + params.content = me.storageContent; + } + if (me.targetNode) { + params.target = me.targetNode; + params.enabled = 1; // skip disabled storages + } + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/storage`, + extraParams: params, + }); + } + + me.store.load(() => me.validate()); + }, + + setTargetNode: function (targetNode) { + var me = this; + + if (!targetNode || me.targetNode === targetNode) { + return; + } + + if (me.clusterView) { + throw 'setting targetNode with clusterView is not implemented'; + } + + me.targetNode = targetNode; + + me.reloadStorageList(); + }, + + setNodename: function (nodename) { + var me = this; + + nodename = nodename || ''; + + if (me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.reloadStorageList(); + }, + + initComponent: function () { + var me = this; + + let nodename = me.nodename; + me.nodename = undefined; + + var store = Ext.create('Ext.data.Store', { + model: 'pve-storage-status', + sorters: { + property: 'storage', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + + me.setNodename(nodename); + }, + }, + function () { + Ext.define('pve-storage-status', { + extend: 'Ext.data.Model', + fields: ['storage', 'active', 'type', 'avail', 'total', 'nodes', 'shared'], + idProperty: 'storage', + }); + }, +); +Ext.define('PVE.form.TFASelector', { + extend: 'Ext.container.Container', + xtype: 'pveTFASelector', + mixins: ['Proxmox.Mixin.CBind'], + + deleteEmpty: true, + + viewModel: { + data: { + type: '__default__', + step: null, + digits: null, + id: null, + key: null, + url: null, + }, + + formulas: { + isOath: (get) => get('type') === 'oath', + isYubico: (get) => get('type') === 'yubico', + tfavalue: { + get: function (get) { + let val = { + type: get('type'), + }; + if (get('isOath')) { + let step = get('step'); + let digits = get('digits'); + if (step) { + val.step = step; + } + if (digits) { + val.digits = digits; + } + } else if (get('isYubico')) { + let id = get('id'); + let key = get('key'); + let url = get('url'); + val.id = id; + val.key = key; + if (url) { + val.url = url; + } + } else if (val.type === '__default__') { + return ''; + } + + return PVE.Parser.printPropertyString(val); + }, + set: function (value) { + let val = PVE.Parser.parseTfaConfig(value); + this.set(val); + this.notify(); + // we need to reset the original values, so that + // we can reliably track the state of the form + let form = this.getView().up('form'); + if (form.trackResetOnLoad) { + let fields = this.getView().query('field[name!="tfa"]'); + fields.forEach((field) => field.resetOriginalValue()); + } + }, + }, + }, + }, + + items: [ + { + xtype: 'proxmoxtextfield', + name: 'tfa', + hidden: true, + submitValue: true, + cbind: { + deleteEmpty: '{deleteEmpty}', + }, + bind: { + value: '{tfavalue}', + }, + }, + { + xtype: 'proxmoxKVComboBox', + value: '__default__', + deleteEmpty: false, + submitValue: false, + fieldLabel: gettext('Require TFA'), + comboItems: [ + ['__default__', Proxmox.Utils.noneText], + ['oath', 'OATH/TOTP'], + ['yubico', 'Yubico'], + ], + bind: { + value: '{type}', + }, + }, + { + xtype: 'proxmoxintegerfield', + hidden: true, + minValue: 10, + submitValue: false, + emptyText: Proxmox.Utils.defaultText + ' (30)', + fieldLabel: gettext('Time Step'), + bind: { + value: '{step}', + hidden: '{!isOath}', + disabled: '{!isOath}', + }, + }, + { + xtype: 'proxmoxintegerfield', + hidden: true, + submitValue: false, + fieldLabel: gettext('Secret Length'), + minValue: 6, + maxValue: 8, + emptyText: Proxmox.Utils.defaultText + ' (6)', + bind: { + value: '{digits}', + hidden: '{!isOath}', + disabled: '{!isOath}', + }, + }, + { + xtype: 'textfield', + hidden: true, + submitValue: false, + allowBlank: false, + fieldLabel: 'Yubico API Id', + bind: { + value: '{id}', + hidden: '{!isYubico}', + disabled: '{!isYubico}', + }, + }, + { + xtype: 'textfield', + hidden: true, + submitValue: false, + allowBlank: false, + fieldLabel: 'Yubico API Key', + bind: { + value: '{key}', + hidden: '{!isYubico}', + disabled: '{!isYubico}', + }, + }, + { + xtype: 'textfield', + hidden: true, + submitValue: false, + fieldLabel: 'Yubico URL', + bind: { + value: '{url}', + hidden: '{!isYubico}', + disabled: '{!isYubico}', + }, + }, + ], +}); +Ext.define( + 'PVE.form.TokenSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveTokenSelector'], + + allowBlank: false, + autoSelect: false, + displayField: 'id', + + editable: true, + anyMatch: true, + forceSelection: true, + + store: { + model: 'pve-tokens', + autoLoad: true, + proxy: { + type: 'proxmox', + url: 'api2/json/access/users', + extraParams: { full: 1 }, + }, + sorters: 'id', + listeners: { + load: function (store, records, success) { + let tokens = []; + for (const { data: user } of records) { + if (!user.tokens || user.tokens.length === 0) { + continue; + } + for (const token of user.tokens) { + tokens.push({ + id: `${user.userid}!${token.tokenid}`, + comment: token.comment, + }); + } + } + store.loadData(tokens); + }, + }, + }, + + listConfig: { + columns: [ + { + header: gettext('API Token'), + sortable: true, + dataIndex: 'id', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + { + header: gettext('Comment'), + sortable: false, + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + }, + }, + function () { + Ext.define('pve-tokens', { + extend: 'Ext.data.Model', + fields: [ + 'id', + 'userid', + 'tokenid', + 'comment', + { type: 'boolean', name: 'privsep' }, + { type: 'date', dateFormat: 'timestamp', name: 'expire' }, + ], + idProperty: 'id', + }); + }, +); +Ext.define( + 'PVE.form.USBSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveUSBSelector'], + + allowBlank: false, + autoSelect: false, + anyMatch: true, + displayField: 'product_and_id', + valueField: 'usbid', + editable: true, + + validator: function (value) { + var me = this; + if (!value) { + return true; // handled later by allowEmpty in the getErrors call chain + } + value = me.getValue(); // as the valueField is not the displayfield + if (me.type === 'device') { + return /^[a-f0-9]{4}:[a-f0-9]{4}$/i.test(value); + } else if (me.type === 'port') { + return /^[0-9]+-[0-9]+(\.[0-9]+)*$/.test(value); + } + return gettext('Invalid Value'); + }, + + setNodename: function (nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/hardware/usb`, + }); + + me.store.load(); + }, + + initComponent: function () { + var me = this; + + if (me.pveSelNode) { + me.nodename = me.pveSelNode.data.node; + } + + var nodename = me.nodename; + me.nodename = undefined; + + if (me.type !== 'device' && me.type !== 'port') { + throw 'no valid type specified'; + } + + let store = new Ext.data.Store({ + model: `pve-usb-${me.type}`, + filters: [ + ({ data }) => !!data.usbpath && !!data.prodid && String(data.class) !== '9', + ], + }); + let emptyText = ''; + if (me.type === 'device') { + emptyText = gettext('Passthrough a specific device'); + } else { + emptyText = gettext('Passthrough a full port'); + } + + Ext.apply(me, { + store: store, + emptyText: emptyText, + listConfig: { + minHeight: 80, + width: 520, + columns: [ + { + header: me.type === 'device' ? gettext('Device') : gettext('Port'), + sortable: true, + dataIndex: 'usbid', + width: 80, + }, + { + header: gettext('Manufacturer'), + sortable: true, + dataIndex: 'manufacturer', + width: 150, + }, + { + header: gettext('Product'), + sortable: true, + dataIndex: 'product', + flex: 1, + }, + { + header: gettext('Speed'), + width: 75, + sortable: true, + dataIndex: 'speed', + renderer: function (value) { + let speed2Class = { + 10000: 'USB 3.1', + 5000: 'USB 3.0', + 480: 'USB 2.0', + 12: 'USB 1.x', + 1.5: 'USB 1.x', + }; + return speed2Class[value] || value + ' Mbps'; + }, + }, + ], + }, + }); + + me.callParent(); + + me.setNodename(nodename); + }, + }, + function () { + Ext.define('pve-usb-device', { + extend: 'Ext.data.Model', + fields: [ + { + name: 'usbid', + convert: function (val, data) { + if (val) { + return val; + } + return data.get('vendid') + ':' + data.get('prodid'); + }, + }, + 'speed', + 'product', + 'manufacturer', + 'vendid', + 'prodid', + 'usbpath', + { name: 'port', type: 'number' }, + { name: 'level', type: 'number' }, + { name: 'class', type: 'number' }, + { name: 'devnum', type: 'number' }, + { name: 'busnum', type: 'number' }, + { + name: 'product_and_id', + type: 'string', + convert: (v, rec) => { + let res = rec.data.product || gettext('Unknown'); + res += ' (' + rec.data.usbid + ')'; + return res; + }, + }, + ], + }); + + Ext.define('pve-usb-port', { + extend: 'Ext.data.Model', + fields: [ + { + name: 'usbid', + convert: function (val, data) { + if (val) { + return val; + } + return data.get('busnum') + '-' + data.get('usbpath'); + }, + }, + 'speed', + 'product', + 'manufacturer', + 'vendid', + 'prodid', + 'usbpath', + { name: 'port', type: 'number' }, + { name: 'level', type: 'number' }, + { name: 'class', type: 'number' }, + { name: 'devnum', type: 'number' }, + { name: 'busnum', type: 'number' }, + { + name: 'product_and_id', + type: 'string', + convert: (v, rec) => { + let res = rec.data.product || gettext('Unplugged'); + res += ' (' + rec.data.usbid + ')'; + return res; + }, + }, + ], + }); + }, +); +Ext.define('PVE.form.USBMapSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.pveUSBMapSelector', + + store: { + fields: ['name', 'vendor', 'device', 'path'], + filterOnLoad: true, + sorters: [ + { + property: 'name', + direction: 'ASC', + }, + ], + }, + + allowBlank: false, + autoSelect: false, + displayField: 'id', + valueField: 'id', + + listConfig: { + width: 800, + columns: [ + { + header: gettext('Name'), + dataIndex: 'id', + flex: 1, + }, + { + header: gettext('Status'), + dataIndex: 'errors', + flex: 2, + renderer: function (value) { + let _me = this; + + if (!Ext.isArray(value) || !value?.length) { + return ` ${gettext('Mapping matches host data')}`; + } + + let errors = []; + + value.forEach((error) => { + let iconCls; + switch (error?.severity) { + case 'warning': + iconCls = 'fa-exclamation-circle warning'; + break; + case 'error': + iconCls = 'fa-times-circle critical'; + break; + } + + let message = error?.message; + let icon = ``; + if (iconCls !== undefined) { + errors.push(`${icon} ${message}`); + } + }); + + return errors.join('
'); + }, + }, + { + header: gettext('Comment'), + dataIndex: 'description', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + ], + }, + + setNodename: function (nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/cluster/mapping/usb?check-node=${nodename}`, + }); + + me.store.load(); + }, + + initComponent: function () { + var me = this; + + var nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + me.setNodename(nodename); + }, +}); +Ext.define('pmx-users', { + extend: 'Ext.data.Model', + fields: [ + 'userid', + 'firstname', + 'lastname', + 'email', + 'comment', + { type: 'boolean', name: 'enable' }, + { type: 'date', dateFormat: 'timestamp', name: 'expire' }, + ], + proxy: { + type: 'proxmox', + url: '/api2/json/access/users?full=1', + }, + idProperty: 'userid', +}); +Ext.define('PVE.form.VlanField', { + extend: 'Ext.form.field.Number', + alias: ['widget.pveVlanField'], + + deleteEmpty: false, + + emptyText: gettext('no VLAN'), + + fieldLabel: gettext('VLAN Tag'), + + allowBlank: true, + + getSubmitData: function () { + var me = this, + data = null, + val; + if (!me.disabled && me.submitValue) { + val = me.getSubmitValue(); + if (val) { + data = {}; + data[me.getName()] = val; + } else if (me.deleteEmpty) { + data = {}; + data.delete = me.getName(); + } + } + return data; + }, + + initComponent: function () { + var me = this; + + Ext.apply(me, { + minValue: 1, + maxValue: 4094, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.form.VMCPUFlagSelector', { + extend: 'Ext.grid.Panel', + alias: 'widget.vmcpuflagselector', + + mixins: { + field: 'Ext.form.field.Field', + }, + + disableSelection: true, + columnLines: false, + selectable: false, + hideHeaders: true, + + scrollable: 'y', + height: 200, + + unkownFlags: [], + + store: { + type: 'store', + fields: ['name', { name: 'state', defaultValue: '=' }, 'description'], + autoLoad: true, + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/localhost/capabilities/qemu/cpu-flags', + }, + listeners: { + update: function () { + this.commitChanges(); + }, + refresh: function (store, eOpts) { + let me = this; + let view = me.view; + + if (store.adjustedForValue !== view.value) { + view.adjustStoreForValue(); + } + }, + }, + adjustedForValue: undefined, + }, + + getValue: function () { + let me = this; + let store = me.getStore(); + + if (!store.isLoaded()) { + return me.value; + } + + let flags = ''; + + store.getData().each(function (rec) { + let s = rec.get('state'); + if (s && s !== '=') { + let f = rec.get('name'); + if (flags === '') { + flags = s + f; + } else { + flags += ';' + s + f; + } + } + }); + + flags += me.unkownFlags.join(';'); + + return flags; + }, + + // Adjusts the store for the current value and determines the unkown flags based on what the + // store does not know. + adjustStoreForValue: function () { + let me = this; + let store = me.getStore(); + let value = me.value; + + me.unkownFlags = []; + + store.getData().each((rec) => rec.set('state', '=')); + + let flags = value ? value.split(';') : []; + flags.forEach(function (flag) { + let sign = flag.substr(0, 1); + flag = flag.substr(1); + + let rec = store.findRecord('name', flag, 0, false, true, true); + if (rec !== null) { + rec.set('state', sign); + } else { + me.unkownFlags.push(flag); + } + }); + + store.adjustedForValue = value; + }, + + setValue: function (value) { + let me = this; + + me.value = value || ''; + + if (me.getStore().isLoaded()) { + me.adjustStoreForValue(); + } // if not yet loaded, the store will trigger the function + + let res = me.mixins.field.setValue.call(me, value); + + return res; + }, + columns: [ + { + dataIndex: 'state', + renderer: function (v) { + switch (v) { + case '=': + return 'Default'; + case '-': + return 'Off'; + case '+': + return 'On'; + default: + return 'Unknown'; + } + }, + width: 65, + }, + { + xtype: 'widgetcolumn', + dataIndex: 'state', + width: 95, + onWidgetAttach: function (column, widget, record) { + let val = record.get('state') || '='; + widget.down('[inputValue=' + val + ']').setValue(true); + // TODO: disable if selected CPU model and flag are incompatible + }, + widget: { + xtype: 'radiogroup', + hideLabel: true, + layout: 'hbox', + validateOnChange: false, + value: '=', + listeners: { + change: function (f, value) { + let v = Object.values(value)[0]; + f.getWidgetRecord().set('state', v); + + let view = this.up('grid'); + view.dirty = view.getValue() !== view.originalValue; + view.checkDirty(); + //view.checkChange(); + }, + }, + items: [ + { + boxLabel: '-', + boxLabelAlign: 'before', + inputValue: '-', + isFormField: false, + }, + { + checked: true, + inputValue: '=', + isFormField: false, + }, + { + boxLabel: '+', + inputValue: '+', + isFormField: false, + }, + ], + }, + }, + { + dataIndex: 'name', + width: 100, + }, + { + dataIndex: 'description', + cellWrap: true, + flex: 1, + }, + ], + + initComponent: function () { + let me = this; + + me.value = me.originalValue = ''; + me.store.view = me; + + me.callParent(arguments); + }, +}); +/* filter is a javascript builtin, but extjs calls it also filter */ +Ext.define('PVE.form.VMSelector', { + extend: 'Ext.grid.Panel', + alias: 'widget.vmselector', + + mixins: { + field: 'Ext.form.field.Field', + }, + + allowBlank: true, + selectAll: false, + isFormField: true, + + plugins: 'gridfilters', + + store: { + model: 'PVEResources', + sorters: 'vmid', + }, + + userCls: 'proxmox-tags-circle', + + columnsDeclaration: [ + { + header: 'ID', + dataIndex: 'vmid', + width: 80, + filter: { + type: 'number', + }, + }, + { + header: gettext('Node'), + dataIndex: 'node', + }, + { + header: gettext('Status'), + dataIndex: 'status', + filter: { + type: 'list', + }, + }, + { + header: gettext('Name'), + dataIndex: 'name', + flex: 1, + filter: { + type: 'string', + }, + }, + { + header: gettext('Pool'), + dataIndex: 'pool', + filter: { + type: 'list', + }, + }, + { + header: gettext('Type'), + dataIndex: 'type', + width: 120, + renderer: function (value) { + if (value === 'qemu') { + return gettext('Virtual Machine'); + } else if (value === 'lxc') { + return gettext('LXC Container'); + } + + return ''; + }, + filter: { + type: 'list', + store: { + data: [ + { id: 'qemu', text: gettext('Virtual Machine') }, + { id: 'lxc', text: gettext('LXC Container') }, + ], + un: function () { + // Due to EXTJS-18711. we have to do a static list via a store but to avoid + // creating an object, we have to have an empty pseudo un function + }, + }, + }, + }, + { + header: gettext('Tags'), + dataIndex: 'tags', + renderer: (tags) => PVE.Utils.renderTags(tags, PVE.UIOptions.tagOverrides), + flex: 1, + }, + { + header: 'HA ' + gettext('Status'), + dataIndex: 'hastate', + flex: 1, + filter: { + type: 'list', + }, + }, + ], + + // should be a list of 'dataIndex' values, if 'undefined' all declared columns will be included + columnSelection: undefined, + + selModel: { + selType: 'checkboxmodel', + mode: 'SIMPLE', + }, + + checkChangeEvents: ['selectionchange', 'change'], + + listeners: { + selectionchange: function () { + // to trigger validity and error checks + this.checkChange(); + }, + }, + + getValue: function () { + var me = this; + if (me.savedValue !== undefined) { + return me.savedValue; + } + var sm = me.getSelectionModel(); + var selection = sm.getSelection(); + var values = []; + var store = me.getStore(); + selection.forEach(function (item) { + // only add if not filtered + if (store.findExact('vmid', item.data.vmid) !== -1) { + values.push(item.data.vmid); + } + }); + return values; + }, + + setValueSelection: function (value) { + let me = this; + + let store = me.getStore(); + let notFound = []; + let selection = value + .map((item) => { + let found = store.findRecord('vmid', item, 0, false, true, true); + if (!found) { + if (Ext.isNumeric(item)) { + notFound.push(item); + } else { + console.warn(`invalid item in vm selection: ${item}`); + } + } + return found; + }) + .filter((r) => r); + + for (const vmid of notFound) { + let rec = store.add({ + vmid, + node: 'unknown', + }); + selection.push(rec[0]); + } + + let sm = me.getSelectionModel(); + if (selection.length) { + sm.select(selection); + } else { + sm.deselectAll(); + } + // to correctly trigger invalid class + me.getErrors(); + }, + + setValue: function (value) { + let me = this; + value ??= []; + if (!Ext.isArray(value)) { + value = value.split(',').filter((v) => v !== ''); + } + + let store = me.getStore(); + if (!store.isLoaded()) { + me.savedValue = value; + store.on( + 'load', + function () { + me.setValueSelection(value); + delete me.savedValue; + }, + { single: true }, + ); + } else { + me.setValueSelection(value); + } + return me.mixins.field.setValue.call(me, value); + }, + + getErrors: function (value) { + let me = this; + if (!me.isDisabled() && me.allowBlank === false && me.getValue().length === 0) { + me.addBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']); + return [gettext('No VM selected')]; + } + + me.removeBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']); + return []; + }, + + setDisabled: function (disabled) { + let me = this; + let res = me.callParent([disabled]); + me.getErrors(); + return res; + }, + + initComponent: function () { + let me = this; + + let columns = me.columnsDeclaration + .filter((column) => + me.columnSelection ? me.columnSelection.indexOf(column.dataIndex) !== -1 : true, + ) + .map((x) => x); + + me.columns = columns; + + me.callParent(); + + me.getStore().load({ params: { type: 'vm' } }); + + if (me.nodename) { + me.getStore().addFilter({ + property: 'node', + exactMatch: true, + value: me.nodename, + }); + } + + // only show the relevant guests by default + if (me.action) { + let statusfilter = ''; + switch (me.action) { + case 'startall': + statusfilter = 'stopped'; + break; + case 'stopall': + statusfilter = 'running'; + break; + } + if (statusfilter !== '') { + me.getStore().addFilter([ + { + property: 'template', + value: 0, + }, + { + id: 'x-gridfilter-status', + operator: 'in', + property: 'status', + value: [statusfilter], + }, + ]); + } + } + + if (me.selectAll) { + me.mon(me.getStore(), 'load', function () { + me.getSelectionModel().selectAll(false); + }); + } + }, +}); + +Ext.define('PVE.form.VMComboSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.vmComboSelector', + + valueField: 'vmid', + displayField: 'vmid', + + autoSelect: false, + editable: true, + anyMatch: true, + forceSelection: true, + + store: { + model: 'PVEResources', + autoLoad: true, + sorters: 'vmid', + filters: [ + { + property: 'type', + value: /lxc|qemu/, + }, + ], + }, + + listConfig: { + width: 600, + plugins: 'gridfilters', + columns: [ + { + header: 'ID', + dataIndex: 'vmid', + width: 80, + filter: { + type: 'number', + }, + }, + { + header: gettext('Name'), + dataIndex: 'name', + flex: 1, + filter: { + type: 'string', + }, + }, + { + header: gettext('Node'), + dataIndex: 'node', + }, + { + header: gettext('Status'), + dataIndex: 'status', + filter: { + type: 'list', + }, + }, + { + header: gettext('Pool'), + dataIndex: 'pool', + hidden: true, + filter: { + type: 'list', + }, + }, + { + header: gettext('Type'), + dataIndex: 'type', + width: 120, + renderer: function (value) { + if (value === 'qemu') { + return gettext('Virtual Machine'); + } else if (value === 'lxc') { + return gettext('LXC Container'); + } + + return ''; + }, + filter: { + type: 'list', + store: { + data: [ + { id: 'qemu', text: gettext('Virtual Machine') }, + { id: 'lxc', text: gettext('LXC Container') }, + ], + un: function () { + /* due to EXTJS-18711 */ + }, + }, + }, + }, + { + header: 'HA ' + gettext('Status'), + dataIndex: 'hastate', + hidden: true, + flex: 1, + filter: { + type: 'list', + }, + }, + ], + }, +}); +Ext.define('PVE.form.VNCKeyboardSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.VNCKeyboardSelector'], + comboItems: Object.entries(PVE.Utils.kvm_keymaps), +}); +/* + * Top left combobox, used to select a view of the underneath RessourceTree + */ +Ext.define('PVE.form.ViewSelector', { + extend: 'Ext.form.field.ComboBox', + alias: ['widget.pveViewSelector'], + + editable: false, + allowBlank: false, + forceSelection: true, + autoSelect: false, + valueField: 'key', + displayField: 'value', + hideLabel: true, + queryMode: 'local', + + initComponent: function () { + let me = this; + + let default_views = { + server: { + text: gettext('Server View'), + groups: ['node'], + }, + folder: { + text: gettext('Folder View'), + groups: ['type'], + }, + pool: { + text: gettext('Pool View'), + groups: ['pool'], + // Pool View only lists VMs and Containers + getFilterFn: + () => + ({ data }) => + data.type === 'qemu' || data.type === 'lxc' || data.type === 'pool', + }, + tags: { + text: gettext('Tag View'), + groups: ['tag'], + getFilterFn: + () => + ({ data }) => + ['qemu', 'lxc', 'node', 'storage'].indexOf(data.type) !== -1, + groupRenderer: function (info) { + let tag = PVE.Utils.renderTags(info.tag, PVE.UIOptions.tagOverrides); + return `${tag}`; + }, + itemMap: function (item) { + let tags = (item.data.tags ?? '').split(/[;, ]/); + if (tags.length === 1 && tags[0] === '') { + return item; + } + let items = []; + for (const tag of tags) { + let id = `${item.data.id}-${tag}`; + let info = Ext.apply({ leaf: true }, item.data); + info.tag = tag; + info.realId = info.id; + info.id = id; + items.push(Ext.create('Ext.data.TreeModel', info)); + } + return items; + }, + attrMoveChecks: { + tag: (newitem, olditem) => newitem.data.tags !== olditem.data.tags, + }, + }, + }; + let groupdef = Object.entries(default_views).map(([name, config]) => [name, config.text]); + + let store = Ext.create('Ext.data.Store', { + model: 'KeyValue', + proxy: { + type: 'memory', + reader: 'array', + }, + data: groupdef, + autoload: true, + }); + + Ext.apply(me, { + store: store, + value: groupdef[0][0], + getViewFilter: function () { + let view = me.getValue(); + return Ext.apply({ id: view }, default_views[view] || default_views.server); + }, + getState: function () { + return { value: me.getValue() }; + }, + applyState: function (state, doSelect) { + let view = me.getValue(); + if (state && state.value && view !== state.value) { + let record = store.findRecord('key', state.value, 0, false, true, true); + if (record) { + me.setValue(state.value, true); + if (doSelect) { + me.fireEvent('select', me, [record]); + } + } + } + }, + stateEvents: ['select'], + stateful: true, + stateId: 'pveview', + id: 'view', + }); + + me.callParent(); + + let statechange = function (sp, key, value) { + if (key === me.id) { + me.applyState(value, true); + } + }; + let sp = Ext.state.Manager.getProvider(); + me.mon(sp, 'statechange', statechange, me); + }, +}); +Ext.define('PVE.form.iScsiProviderSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveiScsiProviderSelector'], + comboItems: [ + ['comstar', 'Comstar'], + ['istgt', 'istgt'], + ['iet', 'IET'], + ['LIO', 'LIO'], + ], +}); +Ext.define('PVE.form.ColorPicker', { + extend: 'Ext.form.FieldContainer', + alias: 'widget.pveColorPicker', + + defaultBindProperty: 'value', + + config: { + value: null, + }, + + height: 24, + + layout: { + type: 'hbox', + align: 'stretch', + }, + + getValue: function () { + return this.realvalue.slice(1); + }, + + setValue: function (value) { + let me = this; + me.setColor(value); + if (value && value.length === 6) { + me.picker.value = value[0] !== '#' ? `#${value}` : value; + } + }, + + setColor: function (value) { + let me = this; + let oldValue = me.realvalue; + me.realvalue = value; + let color = value.length === 6 ? `#${value}` : undefined; + me.down('#picker').setStyle('background-color', color); + me.down('#text').setValue(value ?? ''); + me.fireEvent('change', me, me.realvalue, oldValue); + }, + + initComponent: function () { + let me = this; + me.picker = document.createElement('input'); + me.picker.type = 'color'; + me.picker.style = `opacity: 0; border: 0px; width: 100%; height: ${me.height}px`; + me.picker.value = `${me.value}`; + + me.items = [ + { + xtype: 'textfield', + itemId: 'text', + minLength: !me.allowBlank ? 6 : undefined, + maxLength: 6, + enforceMaxLength: true, + allowBlank: me.allowBlank, + emptyText: me.allowBlank ? gettext('Automatic') : undefined, + maskRe: /[a-f0-9]/i, + regex: /^[a-f0-9]{6}$/i, + flex: 1, + listeners: { + change: function (field, value) { + me.setValue(value); + }, + }, + }, + { + xtype: 'box', + style: { + 'margin-left': '1px', + border: '1px solid #cfcfcf', + }, + itemId: 'picker', + width: 24, + contentEl: me.picker, + }, + ]; + + me.callParent(); + me.picker.oninput = function () { + me.setColor(me.picker.value.slice(1)); + }; + }, +}); + +Ext.define('PVE.form.TagColorGrid', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveTagColorGrid', + + mixins: ['Ext.form.field.Field'], + + allowBlank: true, + selectAll: false, + isFormField: true, + deleteEmpty: false, + selModel: 'checkboxmodel', + + config: { + deleteEmpty: false, + }, + + emptyText: gettext('No Overrides'), + viewConfig: { + deferEmptyText: false, + }, + + setValue: function (value) { + let me = this; + let colors; + if (Ext.isObject(value)) { + colors = value.colors; + } else { + colors = value; + } + if (!colors) { + me.getStore().removeAll(); + me.checkChange(); + return me; + } + let entries = (colors.split(';') || []).map((entry) => { + let [tag, bg, fg] = entry.split(':'); + fg = fg || ''; + return { + tag, + color: bg, + text: fg, + }; + }); + me.getStore().setData(entries); + me.checkChange(); + return me; + }, + + getValue: function () { + let me = this; + let values = []; + me.getStore().each((rec) => { + if (rec.data.tag) { + let val = `${rec.data.tag}:${rec.data.color}`; + if (rec.data.text) { + val += `:${rec.data.text}`; + } + values.push(val); + } + }); + return values.join(';'); + }, + + getErrors: function (value) { + let me = this; + let emptyTag = false; + let notValidColor = false; + let colorRegex = new RegExp(/^[0-9a-f]{6}$/i); + me.getStore().each((rec) => { + if (!rec.data.tag) { + emptyTag = true; + } + if (!rec.data.color?.match(colorRegex)) { + notValidColor = true; + } + if (rec.data.text && !rec.data.text?.match(colorRegex)) { + notValidColor = true; + } + }); + let errors = []; + if (emptyTag) { + errors.push(gettext('Tag must not be empty.')); + } + if (notValidColor) { + errors.push(gettext('Not a valid color.')); + } + return errors; + }, + + // override framework function to implement deleteEmpty behaviour + getSubmitData: function () { + let me = this, + data = null, + val; + if (!me.disabled && me.submitValue) { + val = me.getValue(); + if (val !== null && val !== '') { + data = {}; + data[me.getName()] = val; + } else if (me.getDeleteEmpty()) { + data = {}; + data.delete = me.getName(); + } + } + return data; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + addLine: function () { + let me = this; + me.getView().getStore().add({ + tag: '', + color: '', + text: '', + }); + }, + + removeSelection: function () { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (selection === undefined) { + return; + } + + selection.forEach((sel) => { + view.getStore().remove(sel); + }); + view.checkChange(); + }, + + tagChange: function (field, newValue, oldValue) { + let me = this; + let rec = field.getWidgetRecord(); + if (!rec) { + return; + } + if (newValue && newValue !== oldValue) { + let newrgb = Proxmox.Utils.stringToRGB(newValue); + let newvalue = Proxmox.Utils.rgbToHex(newrgb); + if (!rec.get('color')) { + rec.set('color', newvalue); + } else if (oldValue) { + let oldrgb = Proxmox.Utils.stringToRGB(oldValue); + let oldvalue = Proxmox.Utils.rgbToHex(oldrgb); + if (rec.get('color') === oldvalue) { + rec.set('color', newvalue); + } + } + } + me.fieldChange(field, newValue, oldValue); + }, + + backgroundChange: function (field, newValue, oldValue) { + let me = this; + let rec = field.getWidgetRecord(); + if (!rec) { + return; + } + if (newValue && newValue !== oldValue) { + let newrgb = Proxmox.Utils.hexToRGB(newValue); + let newcls = Proxmox.Utils.getTextContrastClass(newrgb); + let hexvalue = newcls === 'dark' ? '000000' : 'FFFFFF'; + if (!rec.get('text')) { + rec.set('text', hexvalue); + } else if (oldValue) { + let oldrgb = Proxmox.Utils.hexToRGB(oldValue); + let oldcls = Proxmox.Utils.getTextContrastClass(oldrgb); + let oldvalue = oldcls === 'dark' ? '000000' : 'FFFFFF'; + if (rec.get('text') === oldvalue) { + rec.set('text', hexvalue); + } + } + } + me.fieldChange(field, newValue, oldValue); + }, + + fieldChange: function (field, newValue, oldValue) { + let me = this; + let view = me.getView(); + let rec = field.getWidgetRecord(); + if (!rec) { + return; + } + let column = field.getWidgetColumn(); + rec.set(column.dataIndex, newValue); + view.checkChange(); + }, + }, + + tbar: [ + { + text: gettext('Add'), + handler: 'addLine', + }, + { + xtype: 'proxmoxButton', + text: gettext('Remove'), + handler: 'removeSelection', + disabled: true, + }, + ], + + columns: [ + { + header: 'Tag', + dataIndex: 'tag', + xtype: 'widgetcolumn', + onWidgetAttach: function (col, widget, rec) { + widget.getStore().setData(PVE.UIOptions.tagList.map((v) => ({ tag: v }))); + }, + widget: { + xtype: 'combobox', + isFormField: false, + maskRe: PVE.Utils.tagCharRegex, + allowBlank: false, + queryMode: 'local', + displayField: 'tag', + valueField: 'tag', + store: {}, + listeners: { + change: 'tagChange', + }, + }, + flex: 1, + }, + { + header: gettext('Background'), + xtype: 'widgetcolumn', + flex: 1, + dataIndex: 'color', + widget: { + xtype: 'pveColorPicker', + isFormField: false, + listeners: { + change: 'backgroundChange', + }, + }, + }, + { + header: gettext('Text'), + xtype: 'widgetcolumn', + flex: 1, + dataIndex: 'text', + widget: { + xtype: 'pveColorPicker', + allowBlank: true, + isFormField: false, + listeners: { + change: 'fieldChange', + }, + }, + }, + ], + + store: { + listeners: { + update: function () { + this.commitChanges(); + }, + }, + }, + + initComponent: function () { + let me = this; + me.callParent(); + me.initField(); + }, +}); +Ext.define('PVE.form.ListField', { + extend: 'Ext.container.Container', + alias: 'widget.pveListField', + + mixins: ['Ext.form.field.Field'], + + // override for column header + fieldTitle: gettext('Item'), + + // will be applied to the textfields + maskRe: undefined, + + allowBlank: true, + selectAll: false, + isFormField: true, + deleteEmpty: false, + config: { + deleteEmpty: false, + }, + + setValue: function (list) { + let me = this; + list = Ext.isArray(list) ? list : (list ?? '').split(';').filter((t) => t !== ''); + + let store = me.lookup('grid').getStore(); + if (list.length > 0) { + store.setData(list.map((item) => ({ item }))); + } else { + store.removeAll(); + } + me.checkChange(); + return me; + }, + + getValue: function () { + let me = this; + let values = []; + me.lookup('grid') + .getStore() + .each((rec) => { + if (rec.data.item) { + values.push(rec.data.item); + } + }); + return values.join(';'); + }, + + getErrors: function (value) { + let me = this; + let empty = false; + me.lookup('grid') + .getStore() + .each((rec) => { + if (!rec.data.item) { + empty = true; + } + }); + if (empty) { + return [gettext('Tag must not be empty.')]; + } + return []; + }, + + // override framework function to implement deleteEmpty behaviour + getSubmitData: function () { + let me = this, + data = null, + val; + if (!me.disabled && me.submitValue) { + val = me.getValue(); + if (val !== null && val !== '') { + data = {}; + data[me.getName()] = val; + } else if (me.getDeleteEmpty()) { + data = {}; + data.delete = me.getName(); + } + } + return data; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + addLine: function () { + let me = this; + me.lookup('grid').getStore().add({ + item: '', + }); + }, + + removeSelection: function (field) { + let me = this; + let view = me.getView(); + let grid = me.lookup('grid'); + + let record = field.getWidgetRecord(); + if (record === undefined) { + // this is sometimes called before a record/column is initialized + return; + } + + grid.getStore().remove(record); + view.checkChange(); + view.validate(); + }, + + itemChange: function (field, newValue) { + let rec = field.getWidgetRecord(); + if (!rec) { + return; + } + let column = field.getWidgetColumn(); + rec.set(column.dataIndex, newValue); + let list = field.up('pveListField'); + list.checkChange(); + list.validate(); + }, + + control: { + 'grid button': { + click: 'removeSelection', + }, + }, + }, + + items: [ + { + xtype: 'grid', + reference: 'grid', + + viewConfig: { + deferEmptyText: false, + }, + + store: { + listeners: { + update: function () { + this.commitChanges(); + }, + }, + }, + }, + { + xtype: 'button', + text: gettext('Add'), + iconCls: 'fa fa-plus-circle', + handler: 'addLine', + margin: '5 0 0 0', + }, + ], + + initComponent: function () { + let me = this; + + for (const [key, value] of Object.entries(me.gridConfig ?? {})) { + me.items[0][key] = value; + } + + me.items[0].columns = [ + { + header: me.fieldTtitle, + dataIndex: 'item', + xtype: 'widgetcolumn', + widget: { + xtype: 'textfield', + isFormField: false, + maskRe: me.maskRe, + allowBlank: false, + queryMode: 'local', + listeners: { + change: 'itemChange', + }, + }, + flex: 1, + }, + { + xtype: 'widgetcolumn', + width: 40, + widget: { + xtype: 'button', + iconCls: 'fa fa-trash-o', + }, + }, + ]; + + me.callParent(); + me.initField(); + }, +}); +Ext.define('Proxmox.form.Tag', { + extend: 'Ext.Component', + alias: 'widget.pveTag', + + mode: 'editable', + + tag: '', + cls: 'pve-edit-tag', + + tpl: [ + '', + '{tag}', + '', + ], + + focusable: true, + getFocusEl: function () { + return Ext.get(this.tagEl()); + }, + + onFocus: function () { + this.selectText(); + }, + + // contains tags not to show in the picker and not allowing to set + filter: [], + + updateFilter: function (tags) { + this.filter = tags; + }, + + onClick: function (event) { + let me = this; + if (event.target.tagName === 'I' && !event.target.classList.contains('handle')) { + if (me.mode === 'editable') { + me.destroy(); + return; + } + } else if (event.target.tagName !== 'SPAN' || me.mode !== 'editable') { + return; + } + me.selectText(); + }, + + selectText: function (collapseToEnd) { + let me = this; + let tagEl = me.tagEl(); + tagEl.contentEditable = true; + let range = document.createRange(); + range.selectNodeContents(tagEl); + if (collapseToEnd) { + range.collapse(false); + } + let sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + + me.showPicker(); + }, + + showPicker: function () { + let me = this; + if (!me.picker) { + me.picker = Ext.widget({ + xtype: 'boundlist', + minWidth: 70, + scrollable: true, + floating: true, + hidden: true, + userCls: 'proxmox-tags-full', + displayField: 'tag', + itemTpl: [ + '{[Proxmox.Utils.getTagElement(values.tag, PVE.UIOptions.tagOverrides)]}', + ], + store: [], + listeners: { + select: function (picker, rec) { + me.tagEl().innerHTML = rec.data.tag; + me.setTag(rec.data.tag, true); + me.selectText(true); + me.setColor(rec.data.tag); + me.picker.hide(); + }, + }, + }); + } + me.picker.getStore()?.clearFilter(); + let taglist = PVE.UIOptions.tagList + .filter((v) => !me.filter.includes(v)) + .map((v) => ({ tag: v })); + if (taglist.length < 1) { + return; + } + me.picker.getStore().setData(taglist); + me.picker.showBy(me, 'tl-bl'); + me.picker.setMaxHeight(200); + }, + + setMode: function (mode) { + let me = this; + let tagEl = me.tagEl(); + if (tagEl) { + tagEl.contentEditable = mode === 'editable'; + } + me.removeCls(me.mode); + me.addCls(mode); + me.mode = mode; + if (me.mode !== 'editable') { + me.picker?.hide(); + } + }, + + onKeyPress: function (event) { + let me = this; + let key = event.browserEvent.key; + switch (key) { + case 'Enter': + case 'Escape': + me.fireEvent('keypress', key); + break; + case 'ArrowLeft': + case 'ArrowRight': + case 'Backspace': + case 'Delete': + return; + default: + if (key.match(PVE.Utils.tagCharRegex)) { + return; + } + me.setTag(me.tagEl().innerHTML); + } + event.browserEvent.preventDefault(); + event.browserEvent.stopPropagation(); + }, + + // for pasting text + beforeInput: function (event) { + let me = this; + me.updateLayout(); + let tag = event.event.data ?? event.event.dataTransfer?.getData('text/plain'); + if (!tag) { + return; + } + if (tag.match(PVE.Utils.tagCharRegex) === null) { + event.event.preventDefault(); + event.event.stopPropagation(); + } + }, + + onInput: function (event) { + let me = this; + me.picker.getStore().filter({ + property: 'tag', + value: me.tagEl().innerHTML, + anyMatch: true, + }); + me.setTag(me.tagEl().innerHTML); + }, + + lostFocus: function (list, event) { + let me = this; + me.picker?.hide(); + window.getSelection().removeAllRanges(); + }, + + setColor: function (tag) { + let me = this; + let rgb = PVE.UIOptions.tagOverrides[tag] ?? Proxmox.Utils.stringToRGB(tag); + + let cls = Proxmox.Utils.getTextContrastClass(rgb); + let color = Proxmox.Utils.rgbToCss(rgb); + me.setUserCls(`proxmox-tag-${cls}`); + me.setStyle('background-color', color); + if (rgb.length > 3) { + let fgcolor = Proxmox.Utils.rgbToCss([rgb[3], rgb[4], rgb[5]]); + + me.setStyle('color', fgcolor); + } else { + me.setStyle('color'); + } + }, + + setTag: function (tag) { + let me = this; + let oldtag = me.tag; + me.tag = tag; + + clearTimeout(me.colorTimeout); + me.colorTimeout = setTimeout(() => me.setColor(tag), 200); + + me.updateLayout(); + if (oldtag !== tag) { + me.fireEvent('change', me, tag, oldtag); + } + }, + + tagEl: function () { + return this.el?.dom?.getElementsByTagName('span')?.[0]; + }, + + listeners: { + click: 'onClick', + focusleave: 'lostFocus', + keydown: 'onKeyPress', + beforeInput: 'beforeInput', + input: 'onInput', + element: 'el', + scope: 'this', + }, + + initComponent: function () { + let me = this; + + me.data = { + tag: me.tag, + }; + + me.setTag(me.tag); + me.setColor(me.tag); + me.setMode(me.mode ?? 'normal'); + me.callParent(); + }, + + destroy: function () { + let me = this; + if (me.picker) { + Ext.destroy(me.picker); + } + clearTimeout(me.colorTimeout); + me.callParent(); + }, +}); +Ext.define('PVE.panel.TagEditContainer', { + extend: 'Ext.container.Container', + alias: 'widget.pveTagEditContainer', + + layout: { + type: 'hbox', + align: 'middle', + }, + + // set to false to hide the 'no tags' field and the edit button + canEdit: true, + editOnly: false, + + controller: { + xclass: 'Ext.app.ViewController', + + loadTags: function (tagstring = '', force = false) { + let me = this; + let view = me.getView(); + + if (me.oldTags === tagstring && !force) { + return; + } + + view.suspendLayout = true; + me.forEachTag((tag) => { + view.remove(tag); + }); + me.getViewModel().set('tagCount', 0); + let newtags = tagstring.split(/[;, ]/).filter((t) => !!t) || []; + newtags.forEach((tag) => { + me.addTag(tag); + }); + view.suspendLayout = false; + view.updateLayout(); + if (!force) { + me.oldTags = tagstring; + } + me.tagsChanged(); + }, + + onRender: function (v) { + let me = this; + let view = me.getView(); + view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags()); + + view.dragzone = Ext.create('Ext.dd.DragZone', v.getEl(), { + getDragData: function (e) { + let source = e.getTarget('.handle'); + if (!source) { + return undefined; + } + let sourceId = source.parentNode.id; + let cmp = Ext.getCmp(sourceId); + let ddel = document.createElement('div'); + ddel.classList.add('proxmox-tags-full'); + ddel.innerHTML = Proxmox.Utils.getTagElement( + cmp.tag, + PVE.UIOptions.tagOverrides, + ); + let repairXY = Ext.fly(source).getXY(); + cmp.setDisabled(true); + ddel.id = Ext.id(); + return { + ddel, + repairXY, + sourceId, + }; + }, + onMouseUp: function (target, e, id) { + let cmp = Ext.getCmp(this.dragData.sourceId); + if (cmp && !cmp.isDestroyed) { + cmp.setDisabled(false); + } + }, + getRepairXY: function () { + return this.dragData.repairXY; + }, + beforeInvalidDrop: function (target, e, id) { + let cmp = Ext.getCmp(this.dragData.sourceId); + if (cmp && !cmp.isDestroyed) { + cmp.setDisabled(false); + } + }, + }); + view.dropzone = Ext.create('Ext.dd.DropZone', v.getEl(), { + getTargetFromEvent: function (e) { + return e.getTarget('.proxmox-tag-dark,.proxmox-tag-light'); + }, + getIndicator: function () { + if (!view.indicator) { + view.indicator = Ext.create('Ext.Component', { + floating: true, + html: '', + hidden: true, + shadow: false, + }); + } + return view.indicator; + }, + onContainerOver: function () { + this.getIndicator().setVisible(false); + }, + notifyOut: function () { + this.getIndicator().setVisible(false); + }, + onNodeOver: function (target, dd, e, data) { + let indicator = this.getIndicator(); + indicator.setVisible(true); + indicator.alignTo(Ext.getCmp(target.id), 't50-bl', [-1, -2]); + return this.dropAllowed; + }, + onNodeDrop: function (target, dd, e, data) { + this.getIndicator().setVisible(false); + let sourceCmp = Ext.getCmp(data.sourceId); + if (!sourceCmp) { + return; + } + sourceCmp.setDisabled(false); + let targetCmp = Ext.getCmp(target.id); + view.remove(sourceCmp, { destroy: false }); + view.insert(view.items.indexOf(targetCmp), sourceCmp); + me.tagsChanged(); + }, + }); + }, + + forEachTag: function (func) { + let me = this; + let view = me.getView(); + view.items.each((field) => { + if (field.getXType() === 'pveTag') { + func(field); + } + return true; + }); + }, + + toggleEdit: function (cancel) { + let me = this; + let vm = me.getViewModel(); + let view = me.getView(); + let editMode = !vm.get('editMode'); + vm.set('editMode', editMode); + + // get a current tag list for editing + if (editMode) { + PVE.UIOptions.update(); + } + + me.forEachTag((tag) => { + tag.setMode(editMode ? 'editable' : 'normal'); + }); + + if (!vm.get('editMode')) { + let tags = []; + if (cancel) { + me.loadTags(me.oldTags, true); + } else { + let toRemove = []; + me.forEachTag((cmp) => { + if (cmp.isVisible() && cmp.tag) { + tags.push(cmp.tag); + } else { + toRemove.push(cmp); + } + }); + toRemove.forEach((cmp) => view.remove(cmp)); + tags = tags.join(','); + if (me.oldTags !== tags) { + me.oldTags = tags; + me.loadTags(tags, true); + me.getView().fireEvent('change', tags); + } + } + } + me.getView().updateLayout(); + }, + + tagsChanged: function () { + let me = this; + let tags = []; + me.forEachTag((cmp) => { + if (cmp.tag) { + tags.push(cmp.tag); + } + }); + me.getViewModel().set('isDirty', me.oldTags !== tags.join(',')); + me.forEachTag((cmp) => { + cmp.updateFilter(tags); + }); + }, + + addTag: function (tag, isNew) { + let me = this; + let view = me.getView(); + let vm = me.getViewModel(); + let index = view.items.length - 5; + if (PVE.UIOptions.shouldSortTags() && !isNew) { + index = view.items.findIndexBy((tagField) => { + if (tagField.reference === 'noTagsField') { + return false; + } + if (tagField.xtype !== 'pveTag') { + return true; + } + let a = tagField.tag.toLowerCase(); + let b = tag.toLowerCase(); + return a > b ? true : a < b ? false : tagField.tag.localeCompare(tag) > 0; + }, 1); + } + let tagField = view.insert(index, { + xtype: 'pveTag', + tag, + mode: vm.get('editMode') ? 'editable' : 'normal', + listeners: { + change: 'tagsChanged', + destroy: function () { + vm.set('tagCount', vm.get('tagCount') - 1); + me.tagsChanged(); + }, + keypress: function (key) { + if (vm.get('hideFinishButtons')) { + return; + } + if (key === 'Enter') { + me.editClick(); + } else if (key === 'Escape') { + me.cancelClick(); + } + }, + }, + }); + + if (isNew) { + me.tagsChanged(); + tagField.selectText(); + } + + vm.set('tagCount', vm.get('tagCount') + 1); + }, + + addTagClick: function (event) { + let me = this; + me.lookup('noTagsField').setVisible(false); + me.addTag('', true); + }, + + cancelClick: function () { + this.toggleEdit(true); + }, + + editClick: function () { + this.toggleEdit(false); + }, + + init: function (view) { + let me = this; + if (view.tags) { + me.loadTags(view.tags); + } + me.getViewModel().set('canEdit', view.canEdit); + me.getViewModel().set('editOnly', view.editOnly); + + me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => { + let vm = me.getViewModel(); + view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags()); + me.loadTags(me.oldTags, !vm.get('editMode')); // refresh tag colors and order + }); + + if (view.editOnly) { + me.toggleEdit(); + } + }, + }, + + getTags: function () { + let me = this; + let controller = me.getController(); + let tags = []; + controller.forEachTag((cmp) => { + if (cmp.tag.length) { + tags.push(cmp.tag); + } + }); + + return tags; + }, + + viewModel: { + data: { + tagCount: 0, + editMode: false, + canEdit: true, + isDirty: false, + editOnly: true, + }, + + formulas: { + hideNoTags: function (get) { + return get('tagCount') !== 0 || !get('canEdit'); + }, + hideEditBtn: function (get) { + return get('editMode') || !get('canEdit'); + }, + hideFinishButtons: function (get) { + return !get('editMode') || get('editOnly'); + }, + }, + }, + + loadTags: function () { + return this.getController().loadTags(...arguments); + }, + + items: [ + { + xtype: 'box', + reference: 'noTagsField', + bind: { + hidden: '{hideNoTags}', + }, + html: gettext('No Tags'), + style: { + opacity: 0.5, + }, + }, + { + xtype: 'button', + iconCls: 'fa fa-plus', + tooltip: gettext('Add Tag'), + bind: { + hidden: '{!editMode}', + }, + hidden: true, + margin: '0 8 0 5', + ui: 'default-toolbar', + handler: 'addTagClick', + }, + { + xtype: 'tbseparator', + ui: 'horizontal', + bind: { + hidden: '{hideFinishButtons}', + }, + hidden: true, + }, + { + xtype: 'button', + iconCls: 'fa fa-times', + tooltip: gettext('Cancel Edit'), + bind: { + hidden: '{hideFinishButtons}', + }, + hidden: true, + margin: '0 5 0 0', + ui: 'default-toolbar', + handler: 'cancelClick', + }, + { + xtype: 'button', + iconCls: 'fa fa-check', + tooltip: gettext('Finish Edit'), + bind: { + hidden: '{hideFinishButtons}', + disabled: '{!isDirty}', + }, + hidden: true, + handler: 'editClick', + }, + { + xtype: 'box', + cls: 'pve-tag-inline-button', + html: ``, + bind: { + hidden: '{hideEditBtn}', + }, + listeners: { + click: 'editClick', + element: 'el', + }, + }, + ], + + listeners: { + render: 'onRender', + }, + + destroy: function () { + let me = this; + Ext.destroy(me.dragzone); + Ext.destroy(me.dropzone); + Ext.destroy(me.indicator); + me.callParent(); + }, +}); +// mostly copied from ExtJS FileButton, but added 'multiple' at the relevant +// places so we have a file picker where one can select multiple files +// changes are marked with an 'pmx:' comment +Ext.define('PVE.form.MultiFileButton', { + extend: 'Ext.form.field.FileButton', + alias: 'widget.pveMultiFileButton', + + afterTpl: [ + 'accept="{accept}"', + 'tabindex="{tabIndex}"', + '>', + ], + + createFileInput: function (isTemporary) { + var me = this, + fileInputEl, + listeners; + + fileInputEl = me.fileInputEl = me.el.createChild( + { + name: me.inputName || me.id, + multiple: true, // pmx: added multiple option + id: !isTemporary ? me.id + '-fileInputEl' : undefined, + cls: me.inputCls + (me.getInherited().rtl ? ' ' + Ext.baseCSSPrefix + 'rtl' : ''), + tag: 'input', + type: 'file', + size: 1, + unselectable: 'on', + }, + me.afterInputGuard, + ); // Nothing special happens outside of IE/Edge + + // This is our focusEl + fileInputEl.dom.setAttribute('data-componentid', me.id); + + if (me.tabIndex !== null) { + me.setTabIndex(me.tabIndex); + } + + if (me.accept) { + fileInputEl.dom.setAttribute('accept', me.accept); + } + + // We place focus and blur listeners on fileInputEl to activate Button's + // focus and blur style treatment + listeners = { + scope: me, + change: me.fireChange, + mousedown: me.handlePrompt, + keydown: me.handlePrompt, + focus: me.onFileFocus, + blur: me.onFileBlur, + }; + + if (me.useTabGuards) { + listeners.keydown = me.onFileInputKeydown; + } + + fileInputEl.on(listeners); + }, +}); +Ext.define('PVE.form.TagFieldSet', { + extend: 'Ext.form.FieldSet', + alias: 'widget.pveTagFieldSet', + mixins: ['Ext.form.field.Field'], + + title: gettext('Tags'), + padding: '0 5 5 5', + + getValue: function () { + let me = this; + let tags = me + .down('pveTagEditContainer') + .getTags() + .filter((t) => t !== ''); + return tags.join(';'); + }, + + setValue: function (value) { + let me = this; + value ??= []; + if (!Ext.isArray(value)) { + value = value.split(/[;, ]/).filter((t) => t !== ''); + } + me.down('pveTagEditContainer').loadTags(value.join(';')); + }, + + getErrors: function (value) { + value ??= []; + if (!Ext.isArray(value)) { + value = value.split(/[;, ]/).filter((t) => t !== ''); + } + if (value.some((t) => !t.match(PVE.Utils.tagCharRegex))) { + return [gettext('Tags contain invalid characters.')]; + } + return []; + }, + + getSubmitData: function () { + let me = this; + let value = me.getValue(); + if (me.disabled || !me.submitValue || value === '') { + return null; + } + let data = {}; + data[me.getName()] = value; + return data; + }, + + layout: 'fit', + + items: [ + { + xtype: 'pveTagEditContainer', + userCls: 'proxmox-tags-full proxmox-tag-fieldset', + editOnly: true, + allowBlank: true, + layout: 'column', + scrollable: true, + }, + ], + + initComponent: function () { + let me = this; + me.callParent(); + me.initField(); + }, +}); +Ext.define('PVE.form.IsoSelector', { + extend: 'Ext.container.Container', + alias: 'widget.pveIsoSelector', + mixins: ['Ext.form.field.Field', 'Proxmox.Mixin.CBind'], + + layout: { + type: 'vbox', + align: 'stretch', + }, + + nodename: undefined, + insideWizard: false, + labelWidth: undefined, + labelAlign: 'right', + + cbindData: function () { + let me = this; + return { + nodename: me.nodename, + insideWizard: me.insideWizard, + }; + }, + + getValue: function () { + return this.lookup('file').getValue(); + }, + + setValue: function (value) { + let me = this; + if (!value) { + me.lookup('file').reset(); + return; + } + var match = value.match(/^([^:]+):/); + if (match) { + me.lookup('storage').setValue(match[1]); + me.lookup('file').setValue(value); + } + }, + + getErrors: function () { + let me = this; + me.lookup('storage').validate(); + let file = me.lookup('file'); + file.validate(); + let value = file.getValue(); + if (!value || !value.length) { + return ['']; // for validation + } + return []; + }, + + setNodename: function (nodename) { + let me = this; + me.lookup('storage').setNodename(nodename); + me.lookup('file').setStorage(undefined, nodename); + }, + + setDisabled: function (disabled) { + let me = this; + me.lookup('storage').setDisabled(disabled); + me.lookup('file').setDisabled(disabled); + return me.callParent([disabled]); + }, + + referenceHolder: true, + + items: [ + { + xtype: 'pveStorageSelector', + reference: 'storage', + isFormField: false, + fieldLabel: gettext('Storage'), + storageContent: 'iso', + allowBlank: false, + cbind: { + nodename: '{nodename}', + autoSelect: '{insideWizard}', + insideWizard: '{insideWizard}', + disabled: '{disabled}', + labelWidth: '{labelWidth}', + labelAlign: '{labelAlign}', + }, + listeners: { + change: function (f, value) { + let me = this; + let selector = me.up('pveIsoSelector'); + selector.lookup('file').setStorage(value); + selector.checkChange(); + }, + }, + }, + { + xtype: 'pveFileSelector', + reference: 'file', + isFormField: false, + storageContent: 'iso', + fieldLabel: gettext('ISO image'), + labelAlign: 'right', + cbind: { + nodename: '{nodename}', + disabled: '{disabled}', + labelWidth: '{labelWidth}', + labelAlign: '{labelAlign}', + }, + allowBlank: false, + listeners: { + change: function () { + this.up('pveIsoSelector').checkChange(); + }, + }, + }, + ], +}); +Ext.define('PVE.grid.BackupView', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveBackupView'], + + onlineHelp: 'chapter_vzdump', + + stateful: true, + stateId: 'grid-guest-backup', + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var vmtype = me.pveSelNode.data.type; + if (!vmtype) { + throw 'no VM type specified'; + } + + var vmtypeFilter; + if (vmtype === 'lxc' || vmtype === 'openvz') { + vmtypeFilter = function (item) { + return PVE.Utils.volume_is_lxc_backup(item.data); + }; + } else if (vmtype === 'qemu') { + vmtypeFilter = function (item) { + return PVE.Utils.volume_is_qemu_backup(item.data); + }; + } else { + throw "unsupported VM type '" + vmtype + "'"; + } + + let vmname = me.pveSelNode.data.name; + + var searchFilter = { + property: 'volid', + value: '', + anyMatch: true, + caseSensitive: false, + }; + + var vmidFilter = { + property: 'vmid', + value: vmid, + exactMatch: true, + }; + + me.store = Ext.create('Ext.data.Store', { + model: 'pve-storage-content', + sorters: [ + { + property: 'vmid', + direction: 'ASC', + }, + { + property: 'vdate', + direction: 'DESC', + }, + ], + filters: [vmtypeFilter, searchFilter, vmidFilter], + }); + + let updateFilter = function () { + me.store.filter([vmtypeFilter, searchFilter, vmidFilter]); + }; + + const reload = Ext.Function.createBuffered((options) => { + if (me.store) { + me.store.load(options); + } + }, 100); + + let isPBS = false; + var setStorage = function (storage) { + var url = '/api2/json/nodes/' + nodename + '/storage/' + storage + '/content'; + url += '?content=backup'; + + me.store.setProxy({ + type: 'proxmox', + url: url, + }); + + Proxmox.Utils.monStoreErrors(me.view, me.store, true); + + reload(); + }; + + let file_restore_btn; + + var storagesel = Ext.create('PVE.form.StorageSelector', { + nodename: nodename, + fieldLabel: gettext('Storage'), + labelAlign: 'right', + storageContent: 'backup', + allowBlank: false, + listeners: { + change: function (f, value) { + let storage = f.getStore().findRecord('storage', value, 0, false, true, true); + if (storage) { + isPBS = storage.data.type === 'pbs'; + me.getColumns().forEach((column) => { + let id = column.dataIndex; + if (id === 'verification' || id === 'encrypted') { + column.setHidden(!isPBS); + } + }); + } else { + isPBS = false; + } + setStorage(value); + if (file_restore_btn) { + file_restore_btn.setHidden(!isPBS); + } + }, + }, + }); + + var storagefilter = Ext.create('Ext.form.field.Text', { + fieldLabel: gettext('Search'), + labelWidth: 50, + labelAlign: 'right', + enableKeyEvents: true, + value: searchFilter.value, + listeners: { + buffer: 500, + keyup: function (field) { + me.store.clearFilter(true); + searchFilter.value = field.getValue(); + updateFilter(); + }, + }, + }); + + var vmidfilterCB = Ext.create('Ext.form.field.Checkbox', { + boxLabel: gettext('Filter VMID'), + value: '1', + listeners: { + change: function (cb, value) { + vmidFilter.value = value ? vmid : ''; + vmidFilter.exactMatch = !!value; + updateFilter(); + }, + }, + }); + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var backup_btn = Ext.create('Ext.button.Button', { + text: gettext('Backup now'), + handler: function () { + var win = Ext.create('PVE.window.Backup', { + nodename: nodename, + vmid: vmid, + vmtype: vmtype, + vmname: vmname, + storage: storagesel.getValue(), + listeners: { + close: function () { + reload(); + }, + }, + }); + win.show(); + }, + }); + + var restore_btn = Ext.create('Proxmox.button.Button', { + text: gettext('Restore'), + disabled: true, + selModel: sm, + enableFn: function (rec) { + return !!rec; + }, + handler: function (b, e, rec) { + let win = Ext.create('PVE.window.Restore', { + nodename: nodename, + vmid: vmid, + vmname: vmname, + volid: rec.data.volid, + volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec), + vmtype: vmtype, + isPBS: isPBS, + }); + win.show(); + win.on('destroy', reload); + }, + }); + + let delete_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + dangerous: true, + delay: 5, + enableFn: (rec) => !rec?.data?.protected, + confirmMsg: ({ data }) => { + let msg = Ext.String.format( + gettext('Are you sure you want to remove entry {0}'), + `'${data.volid}'`, + ); + return msg + ' ' + gettext('This will permanently erase all data.'); + }, + getUrl: ({ data }) => + `/nodes/${nodename}/storage/${storagesel.getValue()}/content/${data.volid}`, + callback: () => reload(), + }); + + let config_btn = Ext.create('Proxmox.button.Button', { + text: gettext('Show Configuration'), + disabled: true, + selModel: sm, + enableFn: (rec) => !!rec, + handler: function (b, e, rec) { + let storage = storagesel.getValue(); + if (!storage) { + return; + } + Ext.create('PVE.window.BackupConfig', { + volume: rec.data.volid, + pveSelNode: me.pveSelNode, + autoShow: true, + }); + }, + }); + + // declared above so that the storage selector can change this buttons hidden state + file_restore_btn = Ext.create('Proxmox.button.Button', { + text: gettext('File Restore'), + disabled: true, + selModel: sm, + enableFn: (rec) => !!rec && isPBS, + hidden: !isPBS, + handler: function (b, e, rec) { + let storage = storagesel.getValue(); + let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format); + Ext.create('Proxmox.window.FileBrowser', { + title: gettext('File Restore') + ' - ' + rec.data.text, + listURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/list`, + downloadURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/download`, + extraParams: { + volume: rec.data.volid, + }, + archive: isVMArchive ? 'all' : undefined, + autoShow: true, + }); + }, + }); + + Ext.apply(me, { + selModel: sm, + tbar: { + overflowHandler: 'scroller', + items: [ + backup_btn, + '-', + restore_btn, + file_restore_btn, + config_btn, + { + xtype: 'proxmoxButton', + text: gettext('Edit Notes'), + disabled: true, + handler: function () { + let volid = sm.getSelection()[0].data.volid; + var storage = storagesel.getValue(); + Ext.create('Proxmox.window.Edit', { + autoLoad: true, + width: 600, + height: 400, + resizable: true, + title: gettext('Notes'), + url: `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`, + layout: 'fit', + items: [ + { + xtype: 'textarea', + layout: 'fit', + name: 'notes', + height: '100%', + }, + ], + listeners: { + destroy: () => reload(), + }, + }).show(); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Change Protection'), + disabled: true, + handler: function (button, event, record) { + let volid = record.data.volid, + storage = storagesel.getValue(); + let url = `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`; + Proxmox.Utils.API2Request({ + url: url, + method: 'PUT', + waitMsgTarget: me, + params: { + protected: record.data.protected ? 0 : 1, + }, + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + success: () => { + reload({ + callback: () => + sm.fireEvent('selectionchange', sm, [record]), + }); + }, + }); + }, + }, + '-', + delete_btn, + '->', + storagesel, + '-', + vmidfilterCB, + storagefilter, + ], + }, + columns: [ + { + header: gettext('Name'), + flex: 2, + sortable: true, + renderer: PVE.Utils.render_storage_content, + dataIndex: 'volid', + }, + { + header: gettext('Notes'), + dataIndex: 'notes', + flex: 1, + renderer: Ext.htmlEncode, + }, + { + header: ``, + tooltip: gettext('Protected'), + width: 30, + renderer: (v) => + v ? `` : '', + sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0), + dataIndex: 'protected', + }, + { + header: gettext('Date'), + width: 150, + dataIndex: 'vdate', + }, + { + header: gettext('Format'), + width: 100, + dataIndex: 'format', + }, + { + header: gettext('Size'), + width: 100, + renderer: Proxmox.Utils.format_size, + dataIndex: 'size', + }, + { + header: 'VMID', + dataIndex: 'vmid', + hidden: true, + }, + { + header: gettext('Encrypted'), + dataIndex: 'encrypted', + renderer: PVE.Utils.render_backup_encryption, + }, + { + // TRANSLATORS: The state of the verification task + header: gettext('Verify State'), + dataIndex: 'verification', + renderer: PVE.Utils.render_backup_verification, + }, + ], + }); + + me.callParent(); + }, +}); +Ext.define('PVE.FirewallAliasEdit', { + extend: 'Proxmox.window.Edit', + + base_url: undefined, + + alias_name: undefined, + + width: 400, + + initComponent: function () { + let me = this; + + me.isCreate = me.alias_name === undefined; + + if (me.isCreate) { + me.url = '/api2/extjs' + me.base_url; + me.method = 'POST'; + } else { + me.url = '/api2/extjs' + me.base_url + '/' + me.alias_name; + me.method = 'PUT'; + } + + let ipanel = Ext.create('Proxmox.panel.InputPanel', { + isCreate: me.isCreate, + items: [ + { + xtype: 'textfield', + name: me.isCreate ? 'name' : 'rename', + fieldLabel: gettext('Name'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'cidr', + fieldLabel: gettext('IP/CIDR'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'comment', + fieldLabel: gettext('Comment'), + }, + ], + }); + + Ext.apply(me, { + subject: gettext('Alias'), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + let values = response.result.data; + values.rename = values.name; + ipanel.setValues(values); + }, + }); + } + }, +}); + +Ext.define('pve-fw-aliases', { + extend: 'Ext.data.Model', + + fields: ['name', 'cidr', 'comment', 'digest'], + idProperty: 'name', +}); + +Ext.define('PVE.FirewallAliases', { + extend: 'Ext.grid.Panel', + alias: ['widget.pveFirewallAliases'], + + onlineHelp: 'pve_firewall_ip_aliases', + + stateful: true, + stateId: 'grid-firewall-aliases', + + base_url: undefined, + + title: gettext('Alias'), + + initComponent: function () { + let me = this; + + if (!me.base_url) { + throw 'missing base_url configuration'; + } + + let store = new Ext.data.Store({ + model: 'pve-fw-aliases', + proxy: { + type: 'proxmox', + url: '/api2/json' + me.base_url, + }, + sorters: { + property: 'name', + direction: 'ASC', + }, + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let caps = Ext.state.Manager.get('GuiCap'); + let canEdit = + !!caps.vms['VM.Config.Network'] || + !!caps.dc['Sys.Modify'] || + !!caps.nodes['Sys.Modify']; + + let reload = function () { + let oldrec = sm.getSelection()[0]; + store.load(function (records, operation, success) { + if (oldrec) { + let rec = store.findRecord('name', oldrec.data.name, 0, false, true, true); + if (rec) { + sm.select(rec); + } + } + }); + }; + + let run_editor = function () { + let rec = me.getSelectionModel().getSelection()[0]; + if (!rec || !canEdit) { + return; + } + let win = Ext.create('PVE.FirewallAliasEdit', { + base_url: me.base_url, + alias_name: rec.data.name, + }); + win.show(); + win.on('destroy', reload); + }; + + me.editBtn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + enableFn: (rec) => canEdit, + handler: run_editor, + }); + + me.addBtn = Ext.create('Ext.Button', { + text: gettext('Add'), + disabled: + !caps.vms['VM.Config.Network'] && + !caps.dc['Sys.Modify'] && + !caps.nodes['Sys.Modify'], + handler: function () { + var win = Ext.create('PVE.FirewallAliasEdit', { + base_url: me.base_url, + }); + win.on('destroy', reload); + win.show(); + }, + }); + + me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + disabled: true, + selModel: sm, + enableFn: (rec) => + !!caps.vms['VM.Config.Network'] || + !!caps.dc['Sys.Modify'] || + !!caps.nodes['Sys.Modify'], + baseurl: me.base_url + '/', + callback: reload, + }); + + Ext.apply(me, { + store: store, + tbar: [me.addBtn, me.removeBtn, me.editBtn], + selModel: sm, + columns: [ + { + header: gettext('Name'), + dataIndex: 'name', + flex: 1, + }, + { + header: gettext('IP/CIDR'), + dataIndex: 'cidr', + flex: 1, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 3, + }, + ], + listeners: { + itemdblclick: run_editor, + }, + }); + + me.callParent(); + me.on('activate', reload); + }, +}); +Ext.define('PVE.FirewallOptions', { + extend: 'Proxmox.grid.ObjectGrid', + alias: ['widget.pveFirewallOptions'], + + fwtype: undefined, // 'dc', 'node', 'vm' or 'vnet' + + base_url: undefined, + + initComponent: function () { + var me = this; + + if (!['dc', 'node', 'vm', 'vnet'].includes(me.fwtype)) { + throw 'unknown firewall option type'; + } + + if (me.fwtype === 'node') { + me.cwidth1 = 250; + } + + let caps = Ext.state.Manager.get('GuiCap'); + let canEdit = + caps.vms['VM.Config.Network'] || caps.dc['Sys.Modify'] || caps.nodes['Sys.Modify']; + + me.rows = {}; + + var add_boolean_row = function (name, text, defaultValue) { + me.add_boolean_row(name, text, { defaultValue: defaultValue }); + }; + var add_integer_row = function (name, text, minValue, labelWidth) { + me.add_integer_row(name, text, { + minValue: minValue, + deleteEmpty: true, + labelWidth: labelWidth, + renderer: function (value) { + if (value === undefined) { + return Proxmox.Utils.defaultText; + } + + return value; + }, + }); + }; + + var add_log_row = function (name, labelWidth) { + me.rows[name] = { + header: name, + required: true, + defaultValue: 'nolog', + editor: { + xtype: 'proxmoxWindowEdit', + subject: name, + fieldDefaults: { labelWidth: labelWidth || 100 }, + items: { + xtype: 'pveFirewallLogLevels', + name: name, + fieldLabel: name, + }, + }, + }; + }; + + if (me.fwtype === 'node') { + me.rows.enable = { + required: true, + defaultValue: 1, + header: gettext('Firewall'), + renderer: Proxmox.Utils.format_boolean, + editor: { + xtype: 'pveFirewallEnableEdit', + defaultValue: 1, + }, + }; + add_boolean_row('nosmurfs', gettext('SMURFS filter'), 1); + add_boolean_row('tcpflags', gettext('TCP flags filter'), 0); + add_boolean_row('ndp', 'NDP', 1); + add_integer_row('nf_conntrack_max', 'nf_conntrack_max', 32768, 120); + add_integer_row( + 'nf_conntrack_tcp_timeout_established', + 'nf_conntrack_tcp_timeout_established', + 7875, + 250, + ); + add_log_row('log_level_in'); + add_log_row('log_level_out'); + add_log_row('log_level_forward'); + add_log_row('tcp_flags_log_level', 120); + add_log_row('smurf_log_level'); + add_boolean_row('nftables', gettext('nftables (tech preview)'), 0); + } else if (me.fwtype === 'vm') { + me.rows.enable = { + required: true, + defaultValue: 0, + header: gettext('Firewall'), + renderer: Proxmox.Utils.format_boolean, + editor: { + xtype: 'pveFirewallEnableEdit', + defaultValue: 0, + }, + }; + add_boolean_row('dhcp', 'DHCP', 1); + add_boolean_row('ndp', 'NDP', 1); + add_boolean_row('radv', gettext('Router Advertisement'), 0); + add_boolean_row('macfilter', gettext('MAC filter'), 1); + add_boolean_row('ipfilter', gettext('IP filter'), 0); + add_log_row('log_level_in'); + add_log_row('log_level_out'); + } else if (me.fwtype === 'dc') { + add_boolean_row('enable', gettext('Firewall'), 0); + add_boolean_row('ebtables', 'ebtables', 1); + me.rows.log_ratelimit = { + header: gettext('Log rate limit'), + required: true, + defaultValue: gettext('Default') + ' (enable=1,rate1/second,burst=5)', + editor: { + xtype: 'pveFirewallLograteEdit', + defaultValue: 'enable=1', + }, + }; + } else if (me.fwtype === 'vnet') { + add_boolean_row('enable', gettext('Firewall'), 0); + add_log_row('log_level_forward'); + } + + if (me.fwtype === 'dc' || me.fwtype === 'vm') { + me.rows.policy_in = { + header: gettext('Input Policy'), + required: true, + defaultValue: 'DROP', + editor: { + xtype: 'proxmoxWindowEdit', + subject: gettext('Input Policy'), + items: { + xtype: 'pveFirewallPolicySelector', + name: 'policy_in', + value: 'DROP', + fieldLabel: gettext('Input Policy'), + }, + }, + }; + + me.rows.policy_out = { + header: gettext('Output Policy'), + required: true, + defaultValue: 'ACCEPT', + editor: { + xtype: 'proxmoxWindowEdit', + subject: gettext('Output Policy'), + items: { + xtype: 'pveFirewallPolicySelector', + name: 'policy_out', + value: 'ACCEPT', + fieldLabel: gettext('Output Policy'), + }, + }, + }; + } + + if (me.fwtype === 'vnet' || me.fwtype === 'dc') { + me.rows.policy_forward = { + header: gettext('Forward Policy'), + required: true, + defaultValue: 'ACCEPT', + editor: { + xtype: 'proxmoxWindowEdit', + subject: gettext('Forward Policy'), + items: { + xtype: 'pveFirewallPolicySelector', + name: 'policy_forward', + value: 'ACCEPT', + fieldLabel: gettext('Forward Policy'), + comboItems: [ + ['ACCEPT', 'ACCEPT'], + ['DROP', 'DROP'], + ], + }, + }, + }; + } + + var edit_btn = new Ext.Button({ + text: gettext('Edit'), + disabled: true, + handler: function () { + me.run_editor(); + }, + }); + + var set_button_status = function () { + var sm = me.getSelectionModel(); + var rec = sm.getSelection()[0]; + + if (!rec) { + edit_btn.disable(); + return; + } + var rowdef = me.rows[rec.data.key]; + if (canEdit) { + edit_btn.setDisabled(!rowdef.editor); + } + }; + + Ext.apply(me, { + tbar: [edit_btn], + listeners: { + itemdblclick: () => { + if (canEdit) { + me.run_editor(); + } + }, + selectionchange: set_button_status, + }, + }); + + if (me.base_url) { + me.applyUrl(me.base_url); + } else { + me.rstore = Ext.create('Proxmox.data.ObjectStore', { + interval: me.interval, + extraParams: me.extraParams, + rows: me.rows, + }); + } + + me.callParent(); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + me.on('deactivate', me.rstore.stopUpdate); + }, + applyUrl: function (url) { + let me = this; + + Ext.apply(me, { + url: '/api2/json' + url, + editorConfig: { + url: '/api2/extjs/' + url, + }, + }); + }, + setBaseUrl: function (url) { + let me = this; + + me.base_url = url; + + me.applyUrl(url); + + me.rstore.getProxy().setConfig('url', `/api2/extjs/${url}`); + me.rstore.reload(); + }, +}); + +Ext.define('PVE.FirewallLogLevels', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveFirewallLogLevels'], + + name: 'log', + fieldLabel: gettext('Log level'), + value: 'nolog', + comboItems: [ + ['nolog', 'nolog'], + ['emerg', 'emerg'], + ['alert', 'alert'], + ['crit', 'crit'], + ['err', 'err'], + ['warning', 'warning'], + ['notice', 'notice'], + ['info', 'info'], + ['debug', 'debug'], + ], +}); +Ext.define('PVE.form.FWMacroSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.pveFWMacroSelector', + + allowBlank: true, + autoSelect: false, + valueField: 'macro', + displayField: 'macro', + + listConfig: { + columns: [ + { + header: gettext('Macro'), + dataIndex: 'macro', + hideable: false, + width: 100, + }, + { + header: gettext('Description'), + renderer: Ext.String.htmlEncode, + flex: 1, + dataIndex: 'descr', + }, + ], + }, + initComponent: function () { + var me = this; + + var store = Ext.create('Ext.data.Store', { + autoLoad: true, + fields: ['macro', 'descr'], + idProperty: 'macro', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/firewall/macros', + }, + sorters: { + property: 'macro', + direction: 'ASC', + }, + }); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.form.ICMPTypeSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.pveICMPTypeSelector', + + allowBlank: true, + autoSelect: false, + valueField: 'name', + displayField: 'name', + + listConfig: { + columns: [ + { + header: gettext('Type'), + dataIndex: 'type', + hideable: false, + sortable: false, + width: 50, + }, + { + header: gettext('Name'), + dataIndex: 'name', + hideable: false, + sortable: false, + flex: 1, + }, + ], + }, + setName: function (value) { + this.name = value; + }, +}); + +let ICMP_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', { + field: ['type', 'name'], + data: [ + { type: 'any', name: 'any' }, + { type: '0', name: 'echo-reply' }, + { type: '3', name: 'destination-unreachable' }, + { type: '3/0', name: 'network-unreachable' }, + { type: '3/1', name: 'host-unreachable' }, + { type: '3/2', name: 'protocol-unreachable' }, + { type: '3/3', name: 'port-unreachable' }, + { type: '3/4', name: 'fragmentation-needed' }, + { type: '3/5', name: 'source-route-failed' }, + { type: '3/6', name: 'network-unknown' }, + { type: '3/7', name: 'host-unknown' }, + { type: '3/9', name: 'network-prohibited' }, + { type: '3/10', name: 'host-prohibited' }, + { type: '3/11', name: 'TOS-network-unreachable' }, + { type: '3/12', name: 'TOS-host-unreachable' }, + { type: '3/13', name: 'communication-prohibited' }, + { type: '3/14', name: 'host-precedence-violation' }, + { type: '3/15', name: 'precedence-cutoff' }, + { type: '4', name: 'source-quench' }, + { type: '5', name: 'redirect' }, + { type: '5/0', name: 'network-redirect' }, + { type: '5/1', name: 'host-redirect' }, + { type: '5/2', name: 'TOS-network-redirect' }, + { type: '5/3', name: 'TOS-host-redirect' }, + { type: '8', name: 'echo-request' }, + { type: '9', name: 'router-advertisement' }, + { type: '10', name: 'router-solicitation' }, + { type: '11', name: 'time-exceeded' }, + { type: '11/0', name: 'ttl-zero-during-transit' }, + { type: '11/1', name: 'ttl-zero-during-reassembly' }, + { type: '12', name: 'parameter-problem' }, + { type: '12/0', name: 'ip-header-bad' }, + { type: '12/1', name: 'required-option-missing' }, + { type: '13', name: 'timestamp-request' }, + { type: '14', name: 'timestamp-reply' }, + { type: '17', name: 'address-mask-request' }, + { type: '18', name: 'address-mask-reply' }, + ], +}); +let ICMPV6_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', { + field: ['type', 'name'], + data: [ + { type: '1', name: 'destination-unreachable' }, + { type: '1/0', name: 'no-route' }, + { type: '1/1', name: 'communication-prohibited' }, + { type: '1/2', name: 'beyond-scope' }, + { type: '1/3', name: 'address-unreachable' }, + { type: '1/4', name: 'port-unreachable' }, + { type: '1/5', name: 'failed-policy' }, + { type: '1/6', name: 'reject-route' }, + { type: '2', name: 'packet-too-big' }, + { type: '3', name: 'time-exceeded' }, + { type: '3/0', name: 'ttl-zero-during-transit' }, + { type: '3/1', name: 'ttl-zero-during-reassembly' }, + { type: '4', name: 'parameter-problem' }, + { type: '4/0', name: 'bad-header' }, + { type: '4/1', name: 'unknown-header-type' }, + { type: '4/2', name: 'unknown-option' }, + { type: '128', name: 'echo-request' }, + { type: '129', name: 'echo-reply' }, + { type: '133', name: 'router-solicitation' }, + { type: '134', name: 'router-advertisement' }, + { type: '135', name: 'neighbour-solicitation' }, + { type: '136', name: 'neighbour-advertisement' }, + { type: '137', name: 'redirect' }, + ], +}); + +let DEFAULT_ALLOWED_DIRECTIONS = ['in', 'out']; + +let ALLOWED_DIRECTIONS = { + dc: ['in', 'out', 'forward'], + node: ['in', 'out', 'forward'], + group: ['in', 'out', 'forward'], + vm: ['in', 'out'], + vnet: ['forward'], +}; + +let DEFAULT_ALLOWED_ACTIONS = ['ACCEPT', 'REJECT', 'DROP']; + +let ALLOWED_ACTIONS = { + in: ['ACCEPT', 'REJECT', 'DROP'], + out: ['ACCEPT', 'REJECT', 'DROP'], + forward: ['ACCEPT', 'DROP'], +}; + +Ext.define('PVE.FirewallRulePanel', { + extend: 'Proxmox.panel.InputPanel', + + allow_iface: false, + + list_refs_url: undefined, + + firewall_type: undefined, + action_selector: undefined, + forward_warning: undefined, + + onGetValues: function (values) { + var _me = this; + + // hack: editable ComboGrid returns nothing when empty, so we need to set '' + // Also, disabled text fields return nothing, so we need to set '' + + Ext.Array.each( + ['source', 'dest', 'macro', 'proto', 'sport', 'dport', 'icmp-type', 'log'], + function (key) { + if (values[key] === undefined) { + values[key] = ''; + } + }, + ); + + delete values.modified_marker; + + return values; + }, + + setValidActions: function (type) { + let me = this; + + let allowed_actions = ALLOWED_ACTIONS[type] ?? DEFAULT_ALLOWED_ACTIONS; + me.action_selector.setComboItems(allowed_actions.map((action) => [action, action])); + }, + + setForwardWarning: function (type) { + let me = this; + me.forward_warning.setHidden(type !== 'forward'); + }, + + onSetValues: function (values) { + let me = this; + + if (values.type) { + me.setValidActions(values.type); + me.setForwardWarning(values.type); + } + + return values; + }, + + initComponent: function () { + var me = this; + + if (!me.list_refs_url) { + throw 'no list_refs_url specified'; + } + + let allowed_directions = ALLOWED_DIRECTIONS[me.firewall_type] ?? DEFAULT_ALLOWED_DIRECTIONS; + + me.action_selector = Ext.create('Proxmox.form.KVComboBox', { + xtype: 'proxmoxKVComboBox', + name: 'action', + value: 'ACCEPT', + comboItems: DEFAULT_ALLOWED_ACTIONS.map((action) => [action, action]), + fieldLabel: gettext('Action'), + allowBlank: false, + }); + + me.forward_warning = Ext.create('Proxmox.form.field.DisplayEdit', { + userCls: 'pmx-hint', + value: gettext( + 'Forward rules only take effect when the nftables firewall is activated in the host options', + ), + hidden: true, + }); + + me.column1 = [ + { + // hack: we use this field to mark the form 'dirty' when the + // record has errors- so that the user can safe the unmodified + // form again. + xtype: 'hiddenfield', + name: 'modified_marker', + value: '', + }, + { + xtype: 'proxmoxKVComboBox', + name: 'type', + value: allowed_directions[0], + comboItems: allowed_directions.map((dir) => [dir, dir]), + fieldLabel: gettext('Direction'), + allowBlank: false, + listeners: { + change: function (f, value) { + me.setValidActions(value); + me.setForwardWarning(value); + }, + }, + }, + me.action_selector, + ]; + + if (me.allow_iface) { + me.column1.push({ + xtype: 'proxmoxtextfield', + name: 'iface', + deleteEmpty: !me.isCreate, + value: '', + fieldLabel: gettext('Interface'), + }); + } else { + me.column1.push({ + xtype: 'displayfield', + fieldLabel: '', + value: '', + }); + } + + me.column1.push( + { + xtype: 'displayfield', + fieldLabel: '', + height: 7, + value: '', + }, + { + xtype: 'pveIPRefSelector', + name: 'source', + autoSelect: false, + editable: true, + base_url: me.list_refs_url, + fieldLabel: gettext('Source'), + maxLength: 512, + maxLengthText: gettext('Too long, consider using IP sets.'), + }, + { + xtype: 'pveIPRefSelector', + name: 'dest', + autoSelect: false, + editable: true, + base_url: me.list_refs_url, + fieldLabel: gettext('Destination'), + maxLength: 512, + maxLengthText: gettext('Too long, consider using IP sets.'), + }, + ); + + me.column2 = [ + { + xtype: 'proxmoxcheckbox', + name: 'enable', + checked: false, + uncheckedValue: 0, + fieldLabel: gettext('Enable'), + }, + { + xtype: 'pveFWMacroSelector', + name: 'macro', + fieldLabel: gettext('Macro'), + editable: true, + allowBlank: true, + listeners: { + change: function (f, value) { + if (value === null) { + me.down('field[name=proto]').setDisabled(false); + me.down('field[name=sport]').setDisabled(false); + me.down('field[name=dport]').setDisabled(false); + } else { + me.down('field[name=proto]').setDisabled(true); + me.down('field[name=proto]').setValue(''); + me.down('field[name=sport]').setDisabled(true); + me.down('field[name=sport]').setValue(''); + me.down('field[name=dport]').setDisabled(true); + me.down('field[name=dport]').setValue(''); + } + }, + }, + }, + { + xtype: 'pveIPProtocolSelector', + name: 'proto', + autoSelect: false, + editable: true, + value: '', + fieldLabel: gettext('Protocol'), + listeners: { + change: function (f, value) { + if (value === 'icmp' || value === 'icmpv6' || value === 'ipv6-icmp') { + me.down('field[name=dport]').setHidden(true); + me.down('field[name=dport]').setDisabled(true); + if (value === 'icmp') { + me.down('#icmpv4-type').setHidden(false); + me.down('#icmpv4-type').setDisabled(false); + me.down('#icmpv6-type').setHidden(true); + me.down('#icmpv6-type').setDisabled(true); + } else { + me.down('#icmpv6-type').setHidden(false); + me.down('#icmpv6-type').setDisabled(false); + me.down('#icmpv4-type').setHidden(true); + me.down('#icmpv4-type').setDisabled(true); + } + } else { + me.down('#icmpv4-type').setHidden(true); + me.down('#icmpv4-type').setDisabled(true); + me.down('#icmpv6-type').setHidden(true); + me.down('#icmpv6-type').setDisabled(true); + me.down('field[name=dport]').setHidden(false); + me.down('field[name=dport]').setDisabled(false); + } + }, + }, + }, + { + xtype: 'displayfield', + fieldLabel: '', + height: 7, + value: '', + }, + { + xtype: 'textfield', + name: 'sport', + value: '', + fieldLabel: gettext('Source port'), + }, + { + xtype: 'textfield', + name: 'dport', + value: '', + fieldLabel: gettext('Dest. port'), + }, + { + xtype: 'pveICMPTypeSelector', + name: 'icmp-type', + id: 'icmpv4-type', + autoSelect: false, + editable: true, + hidden: true, + disabled: true, + value: '', + fieldLabel: gettext('ICMP type'), + store: ICMP_TYPE_NAMES_STORE, + }, + { + xtype: 'pveICMPTypeSelector', + name: 'icmp-type', + id: 'icmpv6-type', + autoSelect: false, + editable: true, + hidden: true, + disabled: true, + value: '', + fieldLabel: gettext('ICMP type'), + store: ICMPV6_TYPE_NAMES_STORE, + }, + ]; + + me.advancedColumn1 = [ + { + xtype: 'pveFirewallLogLevels', + }, + ]; + + me.columnB = [ + { + xtype: 'textfield', + name: 'comment', + value: '', + fieldLabel: gettext('Comment'), + }, + me.forward_warning, + ]; + + me.callParent(); + + if (me.isCreate) { + // on create we never change the values, so we need to trigger this + // manually + me.setValidActions(me.getValues().type); + me.setForwardWarning(me.getValues().type); + } + }, +}); + +Ext.define('PVE.FirewallRuleEdit', { + extend: 'Proxmox.window.Edit', + + base_url: undefined, + list_refs_url: undefined, + + allow_iface: false, + + firewall_type: undefined, + + initComponent: function () { + var me = this; + + if (!me.base_url) { + throw 'no base_url specified'; + } + if (!me.list_refs_url) { + throw 'no list_refs_url specified'; + } + + me.isCreate = me.rule_pos === undefined; + + if (me.isCreate) { + me.url = '/api2/extjs' + me.base_url; + me.method = 'POST'; + } else { + me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString(); + me.method = 'PUT'; + } + + var ipanel = Ext.create('PVE.FirewallRulePanel', { + isCreate: me.isCreate, + list_refs_url: me.list_refs_url, + allow_iface: me.allow_iface, + rule_pos: me.rule_pos, + firewall_type: me.firewall_type, + }); + + Ext.apply(me, { + subject: gettext('Rule'), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + ipanel.setValues(values); + // set icmp-type again after protocol has been set + if (values['icmp-type'] !== undefined) { + ipanel.setValues({ 'icmp-type': values['icmp-type'] }); + } + if (values.errors) { + let field = me.query('[isFormField][name=modified_marker]')[0]; + field.setValue(1); + Ext.Function.defer(function () { + var form = ipanel.up('form').getForm(); + form.markInvalid(values.errors); + }, 100); + } + }, + }); + } else if (me.rec) { + ipanel.setValues(me.rec.data); + } + }, +}); + +Ext.define('PVE.FirewallGroupRuleEdit', { + extend: 'Proxmox.window.Edit', + + base_url: undefined, + + allow_iface: false, + + initComponent: function () { + var me = this; + + me.isCreate = me.rule_pos === undefined; + + if (me.isCreate) { + me.url = '/api2/extjs' + me.base_url; + me.method = 'POST'; + } else { + me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString(); + me.method = 'PUT'; + } + + var column1 = [ + { + xtype: 'hiddenfield', + name: 'type', + value: 'group', + }, + { + xtype: 'pveSecurityGroupsSelector', + name: 'action', + value: '', + fieldLabel: gettext('Security Group'), + allowBlank: false, + }, + ]; + + if (me.allow_iface) { + column1.push({ + xtype: 'proxmoxtextfield', + name: 'iface', + deleteEmpty: !me.isCreate, + value: '', + fieldLabel: gettext('Interface'), + }); + } + + var ipanel = Ext.create('Proxmox.panel.InputPanel', { + isCreate: me.isCreate, + column1: column1, + column2: [ + { + xtype: 'proxmoxcheckbox', + name: 'enable', + checked: false, + uncheckedValue: 0, + fieldLabel: gettext('Enable'), + }, + ], + columnB: [ + { + xtype: 'textfield', + name: 'comment', + value: '', + fieldLabel: gettext('Comment'), + }, + ], + }); + + Ext.apply(me, { + subject: gettext('Rule'), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + ipanel.setValues(values); + }, + }); + } + }, +}); + +Ext.define( + 'PVE.FirewallRules', + { + extend: 'Ext.grid.Panel', + alias: 'widget.pveFirewallRules', + + onlineHelp: 'chapter_pve_firewall', + emptyText: gettext('No firewall rule configured here.'), + + stateful: true, + stateId: 'grid-firewall-rules', + + base_url: undefined, + list_refs_url: undefined, + + addBtn: undefined, + removeBtn: undefined, + editBtn: undefined, + groupBtn: undefined, + + tbar_prefix: undefined, + + allow_groups: true, + allow_iface: false, + + firewall_type: undefined, + + setBaseUrl: function (url) { + var me = this; + + me.base_url = url; + + if (url === undefined) { + me.addBtn.setDisabled(true); + if (me.groupBtn) { + me.groupBtn.setDisabled(true); + } + me.store.removeAll(); + } else { + if (me.canEdit) { + me.addBtn.setDisabled(false); + if (me.groupBtn) { + me.groupBtn.setDisabled(false); + } + } + me.removeBtn.baseurl = url + '/'; + + me.store.setProxy({ + type: 'proxmox', + url: '/api2/json' + url, + }); + + me.store.load(); + } + }, + + moveRule: function (from, to) { + var me = this; + + if (!me.base_url) { + return; + } + + Proxmox.Utils.API2Request({ + url: me.base_url + '/' + from, + method: 'PUT', + params: { moveto: to }, + waitMsgTarget: me, + failure: function (response, options) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: function () { + me.store.load(); + }, + }); + }, + + updateRule: function (rule) { + var me = this; + + if (!me.base_url) { + return; + } + + rule.enable = rule.enable ? 1 : 0; + + var pos = rule.pos; + delete rule.pos; + delete rule.errors; + + Proxmox.Utils.API2Request({ + url: me.base_url + '/' + pos.toString(), + method: 'PUT', + params: rule, + waitMsgTarget: me, + failure: function (response, options) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: function () { + me.store.load(); + }, + }); + }, + + initComponent: function () { + var me = this; + + if (!me.list_refs_url) { + throw 'no list_refs_url specified'; + } + + var store = Ext.create('Ext.data.Store', { + model: 'pve-fw-rule', + }); + + var reload = function () { + store.load(); + }; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + me.caps = Ext.state.Manager.get('GuiCap'); + me.canEdit = + !!me.caps.vms['VM.Config.Network'] || + !!me.caps.dc['Sys.Modify'] || + !!me.caps.nodes['Sys.Modify']; + + var run_editor = function () { + var rec = sm.getSelection()[0]; + if (!rec || !me.canEdit) { + return; + } + var type = rec.data.type; + + var editor; + if (type === 'in' || type === 'out' || type === 'forward') { + editor = 'PVE.FirewallRuleEdit'; + } else if (type === 'group') { + editor = 'PVE.FirewallGroupRuleEdit'; + } else { + return; + } + + var win = Ext.create(editor, { + firewall_type: me.firewall_type, + digest: rec.data.digest, + allow_iface: me.allow_iface, + base_url: me.base_url, + list_refs_url: me.list_refs_url, + rule_pos: rec.data.pos, + }); + + win.show(); + win.on('destroy', reload); + }; + + me.editBtn = Ext.create('Proxmox.button.Button', { + text: gettext('Edit'), + disabled: true, + enableFn: (rec) => me.canEdit, + selModel: sm, + handler: run_editor, + }); + + me.addBtn = Ext.create('Ext.Button', { + text: gettext('Add'), + disabled: true, + handler: function () { + var win = Ext.create('PVE.FirewallRuleEdit', { + firewall_type: me.firewall_type, + allow_iface: me.allow_iface, + base_url: me.base_url, + list_refs_url: me.list_refs_url, + }); + win.on('destroy', reload); + win.show(); + }, + }); + + var run_copy_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let type = rec.data.type; + if (!(type === 'in' || type === 'out' || type === 'forward')) { + return; + } + + let win = Ext.create('PVE.FirewallRuleEdit', { + firewall_type: me.firewall_type, + allow_iface: me.allow_iface, + base_url: me.base_url, + list_refs_url: me.list_refs_url, + rec: rec, + }); + win.show(); + win.on('destroy', reload); + }; + + me.copyBtn = Ext.create('Proxmox.button.Button', { + text: gettext('Copy'), + selModel: sm, + enableFn: ({ data }) => + (data.type === 'in' || data.type === 'out' || data.type === 'forward') && + me.canEdit, + disabled: true, + handler: run_copy_editor, + }); + + if (me.allow_groups) { + me.groupBtn = Ext.create('Ext.Button', { + text: gettext('Insert') + ': ' + gettext('Security Group'), + disabled: true, + handler: function () { + var win = Ext.create('PVE.FirewallGroupRuleEdit', { + allow_iface: me.allow_iface, + base_url: me.base_url, + }); + win.on('destroy', reload); + win.show(); + }, + }); + } + + me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + enableFn: (rec) => me.canEdit, + selModel: sm, + baseurl: me.base_url + '/', + confirmMsg: false, + getRecordName: function (rec) { + var rule = rec.data; + return rule.pos.toString() + '?digest=' + encodeURIComponent(rule.digest); + }, + callback: function () { + me.store.load(); + }, + }); + + let tbar = me.tbar_prefix ? [me.tbar_prefix] : []; + tbar.push(me.addBtn, me.copyBtn); + if (me.groupBtn) { + tbar.push(me.groupBtn); + } + tbar.push(me.removeBtn, me.editBtn); + + let render_errors = function (name, value, metaData, record) { + let errors = record.data.errors; + if (errors && errors[name]) { + metaData.tdCls = 'proxmox-invalid-row'; + let html = Ext.htmlEncode(`

${Ext.htmlEncode(errors[name])}`); + metaData.tdAttr = + 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' + html + '"'; + } + return Ext.htmlEncode(value); + }; + + let columns = [ + { + // similar to xtype: 'rownumberer', + dataIndex: 'pos', + resizable: false, + minWidth: 65, + maxWidth: 83, + flex: 1, + sortable: false, + hideable: false, + menuDisabled: true, + renderer: function (value, metaData, record, rowIdx, colIdx) { + metaData.tdCls = Ext.baseCSSPrefix + 'grid-cell-special'; + let dragHandle = + ""; + if (value >= 0) { + return dragHandle + value; + } + return dragHandle; + }, + }, + { + xtype: 'checkcolumn', + header: gettext('On'), + dataIndex: 'enable', + listeners: { + checkchange: function (column, recordIndex, checked) { + var record = me.getStore().getData().items[recordIndex]; + record.commit(); + var data = {}; + Ext.Array.forEach(record.getFields(), function (field) { + data[field.name] = record.get(field.name); + }); + if (!me.allow_iface || !data.iface) { + delete data.iface; + } + me.updateRule(data); + }, + }, + width: 40, + }, + { + header: gettext('Type'), + dataIndex: 'type', + renderer: function (value, metaData, record) { + return render_errors('type', value, metaData, record); + }, + minWidth: 60, + maxWidth: 80, + flex: 2, + }, + { + header: gettext('Action'), + dataIndex: 'action', + renderer: function (value, metaData, record) { + return render_errors('action', value, metaData, record); + }, + minWidth: 80, + maxWidth: 200, + flex: 2, + }, + { + header: gettext('Macro'), + dataIndex: 'macro', + renderer: function (value, metaData, record) { + return render_errors('macro', value, metaData, record); + }, + minWidth: 80, + flex: 2, + }, + ]; + + if (me.allow_iface) { + columns.push({ + header: gettext('Interface'), + dataIndex: 'iface', + renderer: function (value, metaData, record) { + return render_errors('iface', value, metaData, record); + }, + minWidth: 80, + flex: 2, + }); + } + + columns.push( + { + header: gettext('Protocol'), + dataIndex: 'proto', + renderer: function (value, metaData, record) { + return render_errors('proto', value, metaData, record); + }, + width: 75, + }, + { + header: gettext('Source'), + dataIndex: 'source', + renderer: function (value, metaData, record) { + return render_errors('source', value, metaData, record); + }, + minWidth: 100, + flex: 2, + }, + { + header: gettext('S.Port'), + dataIndex: 'sport', + renderer: function (value, metaData, record) { + return render_errors('sport', value, metaData, record); + }, + width: 75, + }, + { + header: gettext('Destination'), + dataIndex: 'dest', + renderer: function (value, metaData, record) { + return render_errors('dest', value, metaData, record); + }, + minWidth: 100, + flex: 2, + }, + { + header: gettext('D.Port'), + dataIndex: 'dport', + renderer: function (value, metaData, record) { + return render_errors('dport', value, metaData, record); + }, + width: 75, + }, + { + header: gettext('Log level'), + dataIndex: 'log', + renderer: function (value, metaData, record) { + return render_errors('log', value, metaData, record); + }, + width: 100, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + flex: 10, + minWidth: 75, + renderer: function (value, metaData, record) { + let comment = render_errors('comment', value, metaData, record) || ''; + if (comment.length * 12 > metaData.column.cellWidth) { + comment = `${comment}`; + } + return comment; + }, + }, + ); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: tbar, + viewConfig: { + plugins: [ + { + ptype: 'gridviewdragdrop', + dragGroup: 'FWRuleDDGroup', + dropGroup: 'FWRuleDDGroup', + }, + ], + listeners: { + beforedrop: function (node, data, dropRec, dropPosition) { + if (!dropRec) { + return false; // empty view + } + let moveto = dropRec.get('pos'); + if (dropPosition === 'after') { + moveto++; + } + let pos = data.records[0].get('pos'); + me.moveRule(pos, moveto); + return 0; + }, + itemdblclick: run_editor, + }, + }, + sortableColumns: false, + columns: columns, + }); + + me.callParent(); + + if (me.base_url) { + me.setBaseUrl(me.base_url); // load + } + }, + }, + function () { + Ext.define('pve-fw-rule', { + extend: 'Ext.data.Model', + fields: [ + { name: 'enable', type: 'boolean' }, + 'type', + 'action', + 'macro', + 'source', + 'dest', + 'proto', + 'iface', + 'dport', + 'sport', + 'comment', + 'pos', + 'digest', + 'errors', + ], + idProperty: 'pos', + }); + }, +); +Ext.define('PVE.pool.AddVM', { + extend: 'Proxmox.window.Edit', + + width: 800, + height: 600, + resizable: true, + + isAdd: true, + isCreate: true, + + extraRequestParams: { + 'allow-move': 1, + }, + + initComponent: function () { + var me = this; + + if (!me.pool) { + throw 'no pool specified'; + } + + me.url = '/pools/'; + me.method = 'PUT'; + me.extraRequestParams.poolid = me.pool; + + var vmsField = Ext.create('Ext.form.field.Text', { + name: 'vms', + hidden: true, + allowBlank: false, + }); + + let basicFilter = (data) => + (data.type === 'lxc' || data.type === 'qemu') && data.pool !== me.pool; + + var vmStore = Ext.create('Ext.data.Store', { + model: 'PVEResources', + sorters: [ + { + property: 'vmid', + direction: 'ASC', + }, + ], + filters: [(item) => basicFilter(item.data)], + }); + + var vmGrid = Ext.create('widget.grid', { + store: vmStore, + border: true, + height: 480, + scrollable: true, + selModel: { + selType: 'checkboxmodel', + mode: 'SIMPLE', + listeners: { + selectionchange: function (model, selected, opts) { + var selectedVms = []; + selected.forEach(function (vm) { + selectedVms.push(vm.data.vmid); + }); + vmsField.setValue(selectedVms); + }, + }, + }, + tbar: [ + '->', + gettext('Filter') + ':', + ' ', + { + xtype: 'textfield', + width: 200, + enableKeyEvents: true, + emptyText: gettext('Name, Node, VMID'), + submitValue: false, + listeners: { + keyup: { + buffer: 350, + fn: function (field) { + let needle = field.getValue().toLocaleLowerCase(); + if (needle?.length === 0) { + this.triggers.clear.setVisible(false); + } + let matchesNeedle = (v) => v?.toLocaleLowerCase().includes(needle); + vmStore.clearFilter(true); + vmStore.filter([ + { + filterFn: ({ data }) => + basicFilter(data) && + (matchesNeedle(data.vmid.toString()) || + matchesNeedle(data.name) || + matchesNeedle(data.node)), + }, + ]); + }, + }, + change: function (field, newValue, oldValue) { + if (newValue !== this.originalValue) { + this.triggers.clear.setVisible(true); + } + }, + }, + triggers: { + clear: { + cls: 'pmx-clear-trigger', + weight: -1, + hidden: true, + handler: function () { + this.triggers.clear.setVisible(false); + this.setValue(this.originalValue); + vmStore.clearFilter(true); + vmStore.filter([ + { + filterFn: ({ data }) => basicFilter(data), + }, + ]); + }, + }, + }, + }, + ], + columns: [ + { + header: 'ID', + dataIndex: 'vmid', + width: 60, + }, + { + header: gettext('Node'), + dataIndex: 'node', + }, + { + header: gettext('Current Pool'), + dataIndex: 'pool', + }, + { + header: gettext('Status'), + dataIndex: 'uptime', + renderer: (v) => (v ? Proxmox.Utils.runningText : Proxmox.Utils.stoppedText), + }, + { + header: gettext('Name'), + dataIndex: 'name', + flex: 1, + }, + { + header: gettext('Type'), + dataIndex: 'type', + }, + ], + }); + + Ext.apply(me, { + subject: gettext('Virtual Machine'), + items: [ + vmsField, + vmGrid, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'Selected guests who are already part of a pool will be removed from it first.', + ), + }, + ], + }); + + me.callParent(); + vmStore.load(); + }, +}); + +Ext.define('PVE.pool.AddStorage', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + if (!me.pool) { + throw 'no pool specified'; + } + + me.isCreate = true; + me.isAdd = true; + me.url = '/pools/'; + me.method = 'PUT'; + me.extraRequestParams.poolid = me.pool; + + Ext.apply(me, { + subject: gettext('Storage'), + width: 350, + items: [ + { + xtype: 'pveStorageSelector', + name: 'storage', + nodename: 'localhost', + autoSelect: false, + value: '', + fieldLabel: gettext('Storage'), + }, + ], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.grid.PoolMembers', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pvePoolMembers'], + + stateful: true, + stateId: 'grid-pool-members', + + initComponent: function () { + var me = this; + + if (!me.pool) { + throw 'no pool specified'; + } + + me.rstore = Ext.create('Proxmox.data.UpdateStore', { + interval: 10000, + model: 'PVEResources', + proxy: { + type: 'proxmox', + root: 'data[0].members', + url: `/api2/json/pools/?poolid=${me.pool}`, + }, + autoStart: true, + }); + + let store = Ext.create('Proxmox.data.DiffStore', { + rstore: me.rstore, + sorters: [ + { + property: 'type', + direction: 'ASC', + }, + ], + }); + + var coldef = PVE.data.ResourceStore.defaultColumns().filter( + (c) => c.dataIndex !== 'tags' && c.dataIndex !== 'lock', + ); + + const reload = function () { + me.rstore.load(); + }; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var remove_btn = new Proxmox.button.Button({ + text: gettext('Remove'), + disabled: true, + selModel: sm, + confirmMsg: function (rec) { + return Ext.String.format( + gettext('Are you sure you want to remove entry {0}'), + "'" + rec.data.id + "'", + ); + }, + handler: function (btn, event, rec) { + var params = { delete: 1, poolid: me.pool }; + if (rec.data.type === 'storage') { + params.storage = rec.data.storage; + } else if ( + rec.data.type === 'qemu' || + rec.data.type === 'lxc' || + rec.data.type === 'openvz' + ) { + params.vms = rec.data.vmid; + } else { + throw 'unknown resource type'; + } + + Proxmox.Utils.API2Request({ + url: '/pools/', + method: 'PUT', + params: params, + waitMsgTarget: me, + callback: function () { + reload(); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + items: [ + { + text: gettext('Virtual Machine'), + iconCls: 'fa fa-desktop', + handler: function () { + var win = Ext.create('PVE.pool.AddVM', { pool: me.pool }); + win.on('destroy', reload); + win.show(); + }, + }, + { + text: gettext('Storage'), + iconCls: 'fa fa-hdd-o', + handler: function () { + var win = Ext.create('PVE.pool.AddStorage', { pool: me.pool }); + win.on('destroy', reload); + win.show(); + }, + }, + ], + }), + }, + remove_btn, + ], + viewConfig: { + stripeRows: true, + }, + columns: coldef, + listeners: { + itemcontextmenu: PVE.Utils.createCmdMenu, + itemdblclick: function (v, record) { + var ws = me.up('pveStdWorkspace'); + ws.selectById(record.data.id); + }, + activate: reload, + destroy: () => me.rstore.stopUpdate(), + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.window.ReplicaEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveReplicaEdit', + + subject: gettext('Replication Job'), + + url: '/cluster/replication', + method: 'POST', + + initComponent: function () { + var me = this; + + var vmid = me.pveSelNode.data.vmid; + var nodename = me.pveSelNode.data.node; + + var items = []; + + items.push({ + xtype: me.isCreate && !vmid ? 'pveGuestIDSelector' : 'displayfield', + name: 'guest', + fieldLabel: 'CT/VM ID', + value: vmid || '', + }); + + items.push( + { + xtype: me.isCreate ? 'pveNodeSelector' : 'displayfield', + name: 'target', + disallowedNodes: [nodename], + allowBlank: false, + onlineValidator: true, + fieldLabel: gettext('Target'), + }, + { + xtype: 'pveCalendarEvent', + fieldLabel: gettext('Schedule'), + emptyText: '*/15 - ' + Ext.String.format(gettext('Every {0} minutes'), 15), + name: 'schedule', + }, + { + xtype: 'numberfield', + fieldLabel: gettext('Rate limit') + ' (MB/s)', + step: 1, + minValue: 1, + emptyText: gettext('unlimited'), + name: 'rate', + }, + { + xtype: 'textfield', + fieldLabel: gettext('Comment'), + name: 'comment', + }, + { + xtype: 'proxmoxcheckbox', + name: 'enabled', + defaultValue: 'on', + checked: true, + fieldLabel: gettext('Enabled'), + }, + ); + + me.items = [ + { + xtype: 'inputpanel', + itemId: 'ipanel', + onlineHelp: 'pvesr_schedule_time_format', + + onGetValues: function (values) { + let win = this.up('window'); + + values.disable = values.enabled ? 0 : 1; + delete values.enabled; + + PVE.Utils.delete_if_default(values, 'rate', '', win.isCreate); + PVE.Utils.delete_if_default(values, 'disable', 0, win.isCreate); + PVE.Utils.delete_if_default(values, 'schedule', '*/15', win.isCreate); + PVE.Utils.delete_if_default(values, 'comment', '', win.isCreate); + + if (win.isCreate) { + values.type = 'local'; + let vm = vmid || values.guest; + let id = -1; + if (win.highestids[vm] !== undefined) { + id = win.highestids[vm]; + } + id++; + values.id = vm + '-' + id.toString(); + delete values.guest; + } + return values; + }, + items: items, + }, + ]; + + me.callParent(); + + if (me.isCreate) { + me.load({ + success: function (response) { + var jobs = response.result.data; + var highestids = {}; + Ext.Array.forEach(jobs, function (job) { + var match = /^([0-9]+)-([0-9]+)$/.exec(job.id); + if (match) { + let jobVMID = parseInt(match[1], 10); + let id = parseInt(match[2], 10); + if (highestids[jobVMID] === undefined || highestids[jobVMID] < id) { + highestids[jobVMID] = id; + } + } + }); + me.highestids = highestids; + }, + }); + } else { + me.load({ + success: function (response, options) { + response.result.data.enabled = !response.result.data.disable; + me.setValues(response.result.data); + me.digest = response.result.data.digest; + }, + }); + } + }, +}); + +/* callback is a function and string */ +Ext.define( + 'PVE.grid.ReplicaView', + { + extend: 'Ext.grid.Panel', + xtype: 'pveReplicaView', + + onlineHelp: 'chapter_pvesr', + + stateful: true, + stateId: 'grid-pve-replication-status', + + controller: { + xclass: 'Ext.app.ViewController', + + addJob: function (button, event, rec) { + let me = this; + let view = me.getView(); + Ext.create('PVE.window.ReplicaEdit', { + isCreate: true, + method: 'POST', + pveSelNode: view.pveSelNode, + listeners: { + destroy: () => me.reload(), + }, + autoShow: true, + }); + }, + + editJob: function (button, event, { data }) { + let me = this; + let view = me.getView(); + Ext.create('PVE.window.ReplicaEdit', { + url: `/cluster/replication/${data.id}`, + method: 'PUT', + pveSelNode: view.pveSelNode, + listeners: { + destroy: () => me.reload(), + }, + autoShow: true, + }); + }, + + scheduleJobNow: function (button, event, rec) { + let me = this; + let view = me.getView(); + Proxmox.Utils.API2Request({ + url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/schedule_now`, + method: 'POST', + waitMsgTarget: view, + callback: () => me.reload(), + failure: (response, opts) => + Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, + + showLog: function (button, event, rec) { + let me = this; + let view = this.getView(); + + let logView = Ext.create('Proxmox.panel.LogView', { + border: false, + url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/log`, + }); + let task = Ext.TaskManager.newTask({ + run: () => logView.requestUpdate(), + interval: 1000, + }); + let win = Ext.create('Ext.window.Window', { + items: [logView], + layout: 'fit', + width: 800, + height: 400, + modal: true, + title: gettext('Replication Log'), + listeners: { + destroy: function () { + task.stop(); + me.reload(); + }, + }, + }); + task.start(); + win.show(); + }, + + reload: function () { + this.getView().rstore.load(); + }, + + dblClick: function (grid, record, item) { + this.editJob(undefined, undefined, record); + }, + + // currently replication is for cluster only, so disable the whole component for non-cluster + checkPrerequisites: function () { + let view = this.getView(); + if (PVE.Utils.isStandaloneNode()) { + view.mask(gettext('Replication needs at least two nodes'), ['pve-static-mask']); + } + }, + + control: { + '#': { + itemdblclick: 'dblClick', + afterlayout: 'checkPrerequisites', + }, + }, + }, + + tbar: [ + { + text: gettext('Add'), + itemId: 'addButton', + handler: 'addJob', + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + itemId: 'editButton', + handler: 'editJob', + disabled: true, + }, + { + xtype: 'proxmoxStdRemoveButton', + itemId: 'removeButton', + baseurl: '/api2/extjs/cluster/replication/', + dangerous: true, + callback: 'reload', + }, + { + xtype: 'proxmoxButton', + text: gettext('Log'), + itemId: 'logButton', + handler: 'showLog', + disabled: true, + }, + { + xtype: 'proxmoxButton', + text: gettext('Schedule now'), + itemId: 'scheduleNowButton', + handler: 'scheduleJobNow', + disabled: true, + }, + ], + + initComponent: function () { + var me = this; + var mode = ''; + var url = '/cluster/replication'; + + me.nodename = me.pveSelNode.data.node; + me.vmid = me.pveSelNode.data.vmid; + + me.columns = [ + { + header: gettext('Enabled'), + width: 80, + dataIndex: 'enabled', + align: 'center', + renderer: Proxmox.Utils.renderEnabledIcon, + sortable: true, + }, + { + text: 'ID', + dataIndex: 'id', + width: 60, + hidden: true, + }, + { + text: gettext('Guest'), + dataIndex: 'guest', + width: 75, + }, + { + text: gettext('Job'), + dataIndex: 'jobnum', + width: 60, + }, + { + text: gettext('Target'), + dataIndex: 'target', + }, + ]; + + if (!me.nodename) { + mode = 'dc'; + me.stateId = 'grid-pve-replication-dc'; + } else if (!me.vmid) { + mode = 'node'; + url = `/nodes/${me.nodename}/replication`; + } else { + mode = 'vm'; + url = `/nodes/${me.nodename}/replication?guest=${me.vmid}`; + } + + if (mode !== 'dc') { + me.columns.push( + { + text: gettext('Status'), + dataIndex: 'state', + minWidth: 160, + flex: 1, + renderer: function (value, metadata, record) { + if (record.data.pid) { + metadata.tdCls = 'x-grid-row-loading'; + return ''; + } + + let icons = [], + states = []; + + if (record.data.remove_job) { + icons.push( + '', + ); + states.push(gettext('Removal Scheduled')); + } + if (record.data.error) { + icons.push( + '', + ); + states.push(record.data.error); + } + if (icons.length === 0) { + icons.push(''); + states.push(gettext('OK')); + } + + return icons.join(',') + ' ' + states.join(','); + }, + }, + { + text: gettext('Last Sync'), + dataIndex: 'last_sync', + width: 150, + renderer: function (value, metadata, record) { + if (!value) { + return '-'; + } + if (record.data.pid) { + return gettext('syncing'); + } + return Proxmox.Utils.render_timestamp(value); + }, + }, + { + text: gettext('Duration'), + dataIndex: 'duration', + width: 60, + renderer: Proxmox.Utils.render_duration, + }, + { + text: gettext('Next Sync'), + dataIndex: 'next_sync', + width: 150, + renderer: function (value) { + if (!value) { + return '-'; + } + + let now = new Date(), + next = new Date(value * 1000); + if (next < now) { + return gettext('pending'); + } + return Proxmox.Utils.render_timestamp(value); + }, + }, + ); + } + + me.columns.push( + { + text: gettext('Schedule'), + width: 75, + dataIndex: 'schedule', + }, + { + text: gettext('Rate limit'), + dataIndex: 'rate', + renderer: function (value) { + if (!value) { + return gettext('unlimited'); + } + + return value.toString() + ' MB/s'; + }, + hidden: true, + }, + { + text: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.htmlEncode, + }, + ); + + me.rstore = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'pve-replica-' + me.nodename + me.vmid, + model: mode === 'dc' ? 'pve-replication' : 'pve-replication-state', + interval: 3000, + proxy: { + type: 'proxmox', + url: '/api2/json' + url, + }, + }); + + me.store = Ext.create('Proxmox.data.DiffStore', { + rstore: me.rstore, + sorters: [ + { + property: 'guest', + }, + { + property: 'jobnum', + }, + ], + }); + + me.callParent(); + + // we cannot access the log and scheduleNow button + // in the datacenter, because + // we do not know where/if the jobs runs + if (mode === 'dc') { + me.down('#logButton').setHidden(true); + me.down('#scheduleNowButton').setHidden(true); + } + + // if we set the warning mask, we do not want to load + // or set the mask on store errors + if (PVE.Utils.isStandaloneNode()) { + return; + } + + Proxmox.Utils.monStoreErrors(me, me.rstore); + + me.on('destroy', me.rstore.stopUpdate); + me.rstore.startUpdate(); + }, + }, + function () { + Ext.define('pve-replication', { + extend: 'Ext.data.Model', + fields: [ + 'id', + 'target', + 'comment', + 'rate', + 'type', + { name: 'guest', type: 'integer' }, + { name: 'jobnum', type: 'integer' }, + { name: 'schedule', defaultValue: '*/15' }, + { name: 'disable', defaultValue: '' }, + { + name: 'enabled', + calculate: function (data) { + return !data.disable; + }, + }, + ], + }); + + Ext.define('pve-replication-state', { + extend: 'pve-replication', + fields: [ + 'last_sync', + 'next_sync', + 'error', + 'duration', + 'state', + 'fail_count', + 'remove_job', + 'pid', + ], + }); + }, +); +Ext.define('PVE.grid.ResourceGrid', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveResourceGrid'], + + border: false, + defaultSorter: { + property: 'type', + direction: 'ASC', + }, + userCls: 'proxmox-tags-full', + initComponent: function () { + let me = this; + + let rstore = PVE.data.ResourceStore; + + let store = Ext.create('Ext.data.Store', { + model: 'PVEResources', + sorters: me.defaultSorter, + proxy: { + type: 'memory', + }, + }); + + let textfilter = ''; + let textfilterMatch = function (item) { + for (const field of ['name', 'storage', 'node', 'type', 'text']) { + let v = item.data[field]; + if (v && v.toLowerCase().indexOf(textfilter) >= 0) { + return true; + } + } + return false; + }; + + let updateGrid = function () { + var filterfn = me.viewFilter ? me.viewFilter.filterfn : null; + + store.suspendEvents(); + + let nodeidx = {}; + let gather_child_nodes; + gather_child_nodes = function (node) { + if (!node || !node.childNodes) { + return; + } + for (let child of node.childNodes) { + let orgNode = rstore.data.get(child.data.realId ?? child.data.id); + if (orgNode) { + if ( + (!filterfn || filterfn(child)) && + (!textfilter || textfilterMatch(child)) + ) { + nodeidx[child.data.id] = orgNode; + } + } + gather_child_nodes(child); + } + }; + gather_child_nodes(me.pveSelNode); + + // remove vanished items + let rmlist = []; + store.each((olditem) => { + if (!nodeidx[olditem.data.id]) { + rmlist.push(olditem); + } + }); + if (rmlist.length) { + store.remove(rmlist); + } + + // add new items + let addlist = []; + for (const [_key, item] of Object.entries(nodeidx)) { + // getById() use find(), which is slow (ExtJS4 DP5) + let olditem = store.data.get(item.data.id); + if (!olditem) { + addlist.push(item); + continue; + } + let changes = false; + for (let field of PVE.data.ResourceStore.fieldNames) { + if (field !== 'id' && item.data[field] !== olditem.data[field]) { + changes = true; + olditem.beginEdit(); + olditem.set(field, item.data[field]); + } + } + if (changes) { + olditem.endEdit(true); + olditem.commit(true); + } + } + if (addlist.length) { + store.add(addlist); + } + store.sort(); + store.resumeEvents(); + store.fireEvent('refresh', store); + }; + + Ext.apply(me, { + store: store, + stateful: true, + stateId: 'grid-resource', + tbar: [ + '->', + gettext('Search') + ':', + ' ', + { + xtype: 'textfield', + width: 200, + value: textfilter, + enableKeyEvents: true, + listeners: { + buffer: 500, + keyup: function (field, e) { + textfilter = field.getValue().toLowerCase(); + updateGrid(); + }, + }, + }, + ], + viewConfig: { + stripeRows: true, + }, + listeners: { + itemcontextmenu: PVE.Utils.createCmdMenu, + itemdblclick: function (v, record) { + var ws = me.up('pveStdWorkspace'); + ws.selectById(record.data.id); + }, + afterrender: function () { + updateGrid(); + }, + }, + columns: rstore.defaultColumns(), + }); + me.callParent(); + me.mon(rstore, 'load', () => updateGrid()); + }, +}); +/* + * Base class for all the multitab config panels + * + * How to use this: + * + * You create a subclass of this, and then define your wanted tabs + * as items like this: + * + * items: [{ + * title: "myTitle", + * xytpe: "somextype", + * iconCls: 'fa fa-icon', + * groups: ['somegroup'], + * expandedOnInit: true, + * itemId: 'someId' + * }] + * + * this has to be in the declarative syntax, else we + * cannot save them for later + * (so no Ext.create or Ext.apply of an item in the subclass) + * + * the groups array expects the itemids of the items + * which are the parents, which have to come before they + * are used + * + * if you want following the tree: + * + * Option1 + * Option2 + * -> SubOption1 + * -> SubSubOption1 + * + * the suboption1 group array has to look like this: + * groups: ['itemid-of-option2'] + * + * and of subsuboption1: + * groups: ['itemid-of-option2', 'itemid-of-suboption1'] + * + * setting the expandedOnInit determines if the item/group is expanded + * initially (false by default) + */ +Ext.define('PVE.panel.Config', { + extend: 'Ext.panel.Panel', + alias: 'widget.pvePanelConfig', + + showSearch: true, // add a resource grid with a search button as first tab + viewFilter: undefined, // a filter to pass to that resource grid + + tbarSpacing: true, // if true, adds a spacer after the title in tbar + + dockedItems: [ + { + // this is needed for the overflow handler + xtype: 'toolbar', + overflowHandler: 'scroller', + dock: 'left', + style: { + padding: 0, + margin: 0, + }, + cls: 'pve-toolbar-bg', + items: { + xtype: 'treelist', + itemId: 'menu', + ui: 'pve-nav', + expanderOnly: true, + expanderFirst: false, + animation: false, + singleExpand: false, + listeners: { + selectionchange: function (treeList, selection) { + if (!selection) { + return; + } + let view = this.up('panel'); + view.suspendLayout = true; + view.activateCard(selection.data.id); + view.suspendLayout = false; + view.updateLayout(); + }, + itemclick: function (treelist, info) { + var olditem = treelist.getSelection(); + var newitem = info.node; + + // when clicking on the expand arrow, we don't select items, but still want the original behaviour + if (info.select === false) { + return; + } + + // click on a different, open item then leave it open, else toggle the clicked item + if (olditem.data.id !== newitem.data.id && newitem.data.expanded === true) { + info.toggle = false; + } else { + info.toggle = true; + } + }, + }, + }, + }, + { + xtype: 'toolbar', + itemId: 'toolbar', + dock: 'top', + height: 36, + overflowHandler: 'scroller', + }, + ], + + firstItem: '', + layout: 'card', + border: 0, + + // used for automated test + selectById: function (cardid) { + var me = this; + + var root = me.store.getRoot(); + var selection = root.findChild('id', cardid, true); + + if (selection) { + selection.expand(); + let menu = me.down('#menu'); + menu.setSelection(selection); + return cardid; + } + return ''; + }, + + activateCard: function (cardid) { + var me = this; + if (me.savedItems[cardid]) { + let curcard = me.getLayout().getActiveItem(); + let newcard = me.add(me.savedItems[cardid]); + me.helpButton.setOnlineHelp(newcard.onlineHelp || me.onlineHelp); + if (curcard) { + me.setActiveItem(cardid); + me.remove(curcard, true); + + // trigger state change + + let ncard = cardid; + // Note: '' is alias for first tab. + // First tab can be 'search' or something else + if (cardid === me.firstItem) { + ncard = ''; + } + if (me.hstateid) { + me.sp.set(me.hstateid, { value: ncard }); + } + } + } + }, + + initComponent: function () { + var me = this; + + var stateid = me.hstateid; + + me.sp = Ext.state.Manager.getProvider(); + + var activeTab; // leaving this undefined means items[0] will be the default tab + + if (stateid) { + let state = me.sp.get(stateid); + if (state && state.value) { + // if this tab does not exist, it chooses the first + activeTab = state.value; + } + } + + // get title + var title = me.title || me.pveSelNode.data.text; + me.title = undefined; + + // create toolbar + var tbar = me.tbar || []; + me.tbar = undefined; + + if (!me.onlineHelp) { + // use the onlineHelp property indirection to enforce checking reference validity + let typeToOnlineHelp = { + 'type/lxc': { onlineHelp: 'chapter_pct' }, + 'type/node': { onlineHelp: 'chapter_system_administration' }, + 'type/pool': { onlineHelp: 'pveum_pools' }, + 'type/qemu': { onlineHelp: 'chapter_virtual_machines' }, + 'type/sdn': { onlineHelp: 'chapter_pvesdn' }, + 'type/network': { onlineHelp: 'chapter_pvesdn' }, + 'type/storage': { onlineHelp: 'chapter_storage' }, + }; + me.onlineHelp = typeToOnlineHelp[me.pveSelNode.data.id]?.onlineHelp; + } + + if (me.tbarSpacing) { + tbar.unshift('->'); + } + tbar.unshift({ + xtype: 'tbtext', + text: title, + baseCls: 'x-panel-header-text', + }); + + me.helpButton = Ext.create('Proxmox.button.Help', { + hidden: false, + listenToGlobalEvent: false, + onlineHelp: me.onlineHelp || undefined, + }); + + tbar.push(me.helpButton); + + me.dockedItems[1].items = tbar; + + // include search tab + me.items = me.items || []; + if (me.showSearch) { + me.items.unshift({ + xtype: 'pveResourceGrid', + itemId: 'search', + title: gettext('Search'), + iconCls: 'fa fa-search', + pveSelNode: me.pveSelNode, + }); + } + + me.savedItems = {}; + if (me.items[0]) { + me.firstItem = me.items[0].itemId; + } + + me.store = Ext.create('Ext.data.TreeStore', { + root: { + expanded: true, + }, + }); + var root = me.store.getRoot(); + me.insertNodes(me.items); + + delete me.items; + me.defaults = me.defaults || {}; + Ext.apply(me.defaults, { + pveSelNode: me.pveSelNode, + viewFilter: me.viewFilter, + workspace: me.workspace, + border: 0, + }); + + me.callParent(); + + var menu = me.down('#menu'); + var selection = root.findChild('id', activeTab, true) || root.firstChild; + var node = selection; + while (node !== root) { + node.expand(); + node = node.parentNode; + } + menu.setStore(me.store); + menu.setSelection(selection); + + // on a state change, + // select the new item + var statechange = function (sp, key, state) { + // it the state change is for this panel + if (stateid && key === stateid && state) { + // get active item + let acard = me.getLayout().getActiveItem().itemId; + // get the itemid of the new value + let ncard = state.value || me.firstItem; + if (ncard && acard !== ncard) { + // select the chosen item + menu.setSelection(root.findChild('id', ncard, true) || root.firstChild); + } + } + }; + + if (stateid) { + me.mon(me.sp, 'statechange', statechange); + } + }, + + insertNodes: function (items) { + var me = this; + var root = me.store.getRoot(); + + items.forEach(function (item) { + var treeitem = Ext.create('Ext.data.TreeModel', { + id: item.itemId, + text: item.title, + iconCls: item.iconCls, + leaf: true, + expanded: item.expandedOnInit, + }); + item.header = false; + if (me.savedItems[item.itemId] !== undefined) { + throw 'itemId already exists, please use another'; + } + me.savedItems[item.itemId] = item; + + var group; + var curnode = root; + + // get/create the group items + while (Ext.isArray(item.groups) && item.groups.length > 0) { + group = item.groups.shift(); + + let child = curnode.findChild('id', group); + if (child === null) { + // did not find the group item + // so add it where we are + break; + } + curnode = child; + } + + // insert the item + + // lets see if it already exists + var node = curnode.findChild('id', item.itemId); + + if (node === null) { + curnode.appendChild(treeitem); + } else { + // should not happen! + throw 'id already exists'; + } + }); + }, +}); +/* + * Input panel for advanced backup options intended to be used as part of an edit/create window. + */ +Ext.define('PVE.panel.BackupAdvancedOptions', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveBackupAdvancedOptionsPanel', + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: function () { + let me = this; + me.isCreate = !!me.isCreate; + return {}; + }, + + viewModel: { + data: {}, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + toggleFleecing: function (cb, value) { + let me = this; + me.lookup('fleecingStorage').setDisabled(!value); + }, + + control: { + 'proxmoxcheckbox[reference=fleecingEnabled]': { + change: 'toggleFleecing', + }, + }, + }, + + onGetValues: function (formValues) { + let me = this; + if (me.needMask) { + // isMasked() may not yet be true if not rendered once + return {}; + } + + if (!formValues.id && me.isCreate) { + formValues.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); + } + + let options = {}; + + if (!me.isCreate) { + options.delete = []; // to avoid having to check this all the time + } + const deletePropertyOnEdit = me.isCreate + ? () => { + /* no-op on create */ + } + : (key) => options.delete.push(key); + + let fleecing = {}, + fleecingOptions = ['fleecing-enabled', 'fleecing-storage']; + let performance = {}, + performanceOptions = ['max-workers', 'pbs-entries-max']; + + for (const [key, value] of Object.entries(formValues)) { + if (performanceOptions.includes(key)) { + performance[key] = value; + // deleteEmpty is not currently implemented for pveBandwidthField + } else if (key === 'bwlimit' && value === '') { + deletePropertyOnEdit('bwlimit'); + } else if (key === 'delete') { + if (Array.isArray(value)) { + value + .filter((opt) => !performanceOptions.includes(opt)) + .forEach((opt) => deletePropertyOnEdit(opt)); + } else if (!performanceOptions.includes(formValues.delete)) { + deletePropertyOnEdit(value); + } + } else if (fleecingOptions.includes(key)) { + let fleecingKey = key.slice('fleecing-'.length); + fleecing[fleecingKey] = value; + } else { + options[key] = value; + } + } + + if (Object.keys(performance).length > 0) { + options.performance = PVE.Parser.printPropertyString(performance); + } else { + deletePropertyOnEdit('performance'); + } + + if (Object.keys(fleecing).length > 0) { + options.fleecing = PVE.Parser.printPropertyString(fleecing); + } else { + deletePropertyOnEdit('fleecing'); + } + + if (me.isCreate) { + delete options.delete; + } + + return options; + }, + + onSetValues: function (values) { + if (values.fleecing) { + for (const [key, value] of Object.entries(values.fleecing)) { + values[`fleecing-${key}`] = value; + } + delete values.fleecing; + } + if (values['pbs-change-detection-mode'] === '__default__') { + delete values['pbs-change-detection-mode']; + } + return values; + }, + + updateCompression: function (value, disabled) { + this.lookup('zstdThreadCount').setDisabled(disabled || value !== 'zstd'); + }, + + items: [ + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'pmxDisplayEditField', + vtype: 'ConfigId', + fieldLabel: gettext('Job ID'), + emptyText: gettext('Autogenerate'), + name: 'id', + allowBlank: true, + cbind: { + editable: '{isCreate}', + }, + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: gettext('Can be used in notification matchers to match this job.'), + }, + }, + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'pveBandwidthField', + name: 'bwlimit', + fieldLabel: gettext('Bandwidth Limit'), + emptyText: gettext('Fallback'), + backendUnit: 'KiB', + allowZero: true, + emptyValue: '', + autoEl: { + tag: 'div', + 'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), 0), + }, + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: `${gettext('Limit I/O bandwidth.')} ${Ext.String.format(gettext('Schema default: {0}'), 0)}`, + }, + }, + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'proxmoxintegerfield', + name: 'zstd', + reference: 'zstdThreadCount', + fieldLabel: gettext('Zstd Threads'), + fieldStyle: 'text-align: right', + emptyText: gettext('Fallback'), + minValue: 0, + cbind: { + deleteEmpty: '{!isCreate}', + }, + autoEl: { + tag: 'div', + 'data-qtip': gettext('With 0, half of the available cores are used'), + }, + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: `${gettext('Threads used for zstd compression (non-PBS).')} ${Ext.String.format(gettext('Schema default: {0}'), 1)}`, + }, + }, + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'proxmoxintegerfield', + name: 'max-workers', + minValue: 1, + maxValue: 256, + fieldLabel: gettext('IO-Workers'), + fieldStyle: 'text-align: right', + emptyText: gettext('Fallback'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: `${gettext('I/O workers in the QEMU process (VMs only).')} ${Ext.String.format(gettext('Schema default: {0}'), 16)}`, + }, + }, + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'proxmoxcheckbox', + name: 'fleecing-enabled', + reference: 'fleecingEnabled', + fieldLabel: gettext('Fleecing'), + uncheckedValue: 0, + value: 0, + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: gettext( + 'Backup write cache that can reduce IO pressure inside guests (VMs only).', + ), + }, + }, + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'pveStorageSelector', + name: 'fleecing-storage', + fieldLabel: gettext('Fleecing Storage'), + reference: 'fleecingStorage', + clusterView: true, + storageContent: 'images', + allowBlank: false, + disabled: true, + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: gettext( + 'Prefer a fast and local storage, ideally with support for discard and thin-provisioning or sparse files.', + ), + }, + }, + { + // It's part of the 'performance' property string, so have a field to preserve the + // value, but don't expose it. It's a rather niche setting and difficult to + // convey/understand what it does. + xtype: 'proxmoxintegerfield', + name: 'pbs-entries-max', + hidden: true, + fieldLabel: 'TODO', + fieldStyle: 'text-align: right', + emptyText: 'TODO', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Repeat missed'), + name: 'repeat-missed', + uncheckedValue: 0, + defaultValue: 0, + cbind: { + deleteDefaultValue: '{!isCreate}', + }, + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: gettext( + "Run jobs as soon as possible if they couldn't start on schedule, for example, due to the node being offline.", + ), + }, + }, + { + xtype: 'pveTwoColumnContainer', + startColumn: { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('PBS change detection mode'), + name: 'pbs-change-detection-mode', + deleteEmpty: true, + value: '__default__', + comboItems: [ + ['__default__', 'Default'], + ['data', 'Data'], + ['metadata', 'Metadata'], + ], + }, + endFlex: 2, + endColumn: { + xtype: 'displayfield', + value: gettext( + 'Mode to detect file changes and switch archive encoding format for container backups.', + ), + }, + }, + { + xtype: 'component', + padding: '5 1', + html: `${gettext('Note')}: ${gettext( + "The node-specific 'vzdump.conf' or, if this is not set, the default from the config schema is used to determine fallback values.", + )}`, + }, + ], +}); +/* + * Input panel for notification options of backup jobs. + */ +Ext.define('PVE.panel.BackupNotificationOptions', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveBackupNotificationOptionsPanel', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'chapter_notifications', + + cbindData: function () { + let me = this; + me.isCreate = !!me.isCreate; + return {}; + }, + + viewModel: { + data: { + notificationMode: undefined, + }, + formulas: { + showMailtoFields: (get) => { + let mode = get('notificationMode'); + return mode['notification-mode'] === 'legacy-sendmail'; + }, + }, + }, + + onSetValues: function (values) { + let me = this; + + let mode = values['notification-mode'] ?? 'auto'; + let mailto = values.mailto; + + let mappedMode = 'legacy-sendmail'; + + // The 'auto' mode is a bit annoying and confusing, so we try + // to map it to the equivalent behavior. + if ((mode === 'auto' && !mailto) || mode === 'notification-system') { + mappedMode = 'notification-system'; + } + + me.getViewModel().set('notificationMode', { 'notification-mode': mappedMode }); + + values['notification-mode'] = mappedMode; + return values; + }, + + items: [ + { + xtype: 'radiogroup', + height: '15px', + layout: { + type: 'vbox', + }, + bind: { + value: '{notificationMode}', + }, + items: [ + { + xtype: 'radiofield', + name: 'notification-mode', + inputValue: 'notification-system', + boxLabel: gettext('Use global notification settings'), + cbind: { + checked: '{isCreate}', + }, + }, + { + xtype: 'radiofield', + name: 'notification-mode', + inputValue: 'legacy-sendmail', + boxLabel: gettext('Use sendmail to send an email (legacy)'), + }, + ], + }, + { + xtype: 'textfield', + fieldLabel: gettext('Recipients'), + emptyText: 'test@example.com, ...', + name: 'mailto', + padding: '0 0 0 50', + disabled: true, + bind: { + disabled: '{!showMailtoFields}', + }, + }, + { + xtype: 'pveEmailNotificationSelector', + fieldLabel: gettext('When'), + name: 'mailnotification', + padding: '0 0 0 50', + disabled: true, + value: 'always', + cbind: { + deleteEmpty: '{!isCreate}', + }, + bind: { + disabled: '{!showMailtoFields}', + }, + }, + ], +}); +/* + * Input panel for prune settings with a keep-all option intended to be used as + * part of an edit/create window. + */ +Ext.define('PVE.panel.BackupJobPrune', { + extend: 'Proxmox.panel.PruneInputPanel', + xtype: 'pveBackupJobPrunePanel', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'vzdump_retention', + + onGetValues: function (formValues) { + if (this.needMask) { + // isMasked() may not yet be true if not rendered once + return {}; + } else if (this.isCreate && !this.rendered) { + return this.keepAllDefaultForCreate ? { 'prune-backups': 'keep-all=1' } : {}; + } + + let options = { delete: [] }; + + if ('max-protected-backups' in formValues) { + options['max-protected-backups'] = formValues['max-protected-backups']; + } else if (this.hasMaxProtected) { + options.delete.push('max-protected-backups'); + } + + delete formValues['max-protected-backups']; + delete formValues.delete; + + let retention = PVE.Parser.printPropertyString(formValues); + if (retention === '') { + options.delete.push('prune-backups'); + } else { + options['prune-backups'] = retention; + } + + if (this.isCreate) { + delete options.delete; + } + + return options; + }, + + updateComponents: function () { + let me = this; + + let keepAll = me.down('proxmoxcheckbox[name=keep-all]').getValue(); + let anyValue = false; + me.query('pmxPruneKeepField').forEach((field) => { + anyValue = anyValue || field.getValue() !== null; + field.setDisabled(keepAll); + }); + me.down('component[name=no-keeps-hint]').setHidden(anyValue || keepAll); + }, + + listeners: { + afterrender: function (panel) { + if (panel.needMask) { + panel.down('component[name=no-keeps-hint]').setHtml(''); + panel.mask(gettext('Backup content type not available for this storage.')); + } else if (panel.isCreate && panel.keepAllDefaultForCreate) { + panel.down('proxmoxcheckbox[name=keep-all]').setValue(true); + } + panel.down('component[name=pbs-hint]').setHidden(!panel.showPBSHint); + + let maxProtected = panel.down('proxmoxintegerfield[name=max-protected-backups]'); + maxProtected.setDisabled(!panel.hasMaxProtected); + maxProtected.setHidden(!panel.hasMaxProtected); + + panel.query('pmxPruneKeepField').forEach((field) => { + field.on('change', panel.updateComponents, panel); + }); + panel.updateComponents(); + }, + }, + + columnT: { + xtype: 'proxmoxcheckbox', + name: 'keep-all', + boxLabel: gettext('Keep all backups'), + listeners: { + change: function (field, newValue) { + let panel = field.up('pveBackupJobPrunePanel'); + panel.updateComponents(); + }, + }, + }, + + columnB: [ + { + xtype: 'component', + userCls: 'pmx-hint', + name: 'no-keeps-hint', + hidden: true, + padding: '5 1', + cbind: { + html: '{fallbackHintHtml}', + }, + }, + { + xtype: 'component', + userCls: 'pmx-hint', + name: 'pbs-hint', + hidden: true, + padding: '5 1', + html: gettext( + "It's preferred to configure backup retention directly on the Proxmox Backup Server.", + ), + }, + { + xtype: 'proxmoxintegerfield', + name: 'max-protected-backups', + fieldLabel: gettext('Maximum Protected'), + minValue: -1, + hidden: true, + disabled: true, + emptyText: 'unlimited with Datastore.Allocate privilege, 5 otherwise', + deleteEmpty: true, + autoEl: { + tag: 'div', + 'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), -1), + }, + }, + ], +}); +Ext.define('PVE.widget.HealthWidget', { + extend: 'Ext.Component', + alias: 'widget.pveHealthWidget', + + data: { + iconCls: PVE.Utils.get_health_icon(undefined, true), + text: '', + title: '', + }, + + style: { + 'text-align': 'center', + }, + + tpl: ['

{title}

', '', '

', '{text}'], + + updateHealth: function (data) { + var me = this; + me.update(Ext.apply(me.data, data)); + }, + + initComponent: function () { + var me = this; + + if (me.title) { + me.config.data.title = me.title; + } + + me.callParent(); + }, +}); +Ext.define('pve-fw-ipsets', { + extend: 'Ext.data.Model', + fields: ['name', 'comment', 'digest'], + idProperty: 'name', +}); + +Ext.define('PVE.IPSetList', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveIPSetList', + + stateful: true, + stateId: 'grid-firewall-ipsetlist', + + ipset_panel: undefined, + + base_url: undefined, + + addBtn: undefined, + removeBtn: undefined, + editBtn: undefined, + + initComponent: function () { + var me = this; + + if (typeof me.ipset_panel === 'undefined') { + throw 'no rule panel specified'; + } + + if (typeof me.ipset_panel === 'undefined') { + throw 'no base_url specified'; + } + + var store = new Ext.data.Store({ + model: 'pve-fw-ipsets', + proxy: { + type: 'proxmox', + url: '/api2/json' + me.base_url, + }, + sorters: { + property: 'name', + direction: 'ASC', + }, + }); + + var caps = Ext.state.Manager.get('GuiCap'); + let canEdit = + !!caps.vms['VM.Config.Network'] || + !!caps.dc['Sys.Modify'] || + !!caps.nodes['Sys.Modify']; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var reload = function () { + var oldrec = sm.getSelection()[0]; + store.load(function (records, operation, success) { + if (oldrec) { + let rec = store.findRecord('name', oldrec.data.name, 0, false, true, true); + if (rec) { + sm.select(rec); + } + } + }); + }; + + var run_editor = function () { + var rec = sm.getSelection()[0]; + if (!rec || !canEdit) { + return; + } + var win = Ext.create('Proxmox.window.Edit', { + subject: "IPSet '" + rec.data.name + "'", + url: me.base_url, + method: 'POST', + digest: rec.data.digest, + items: [ + { + xtype: 'hiddenfield', + name: 'rename', + value: rec.data.name, + }, + { + xtype: 'textfield', + name: 'name', + value: rec.data.name, + fieldLabel: gettext('Name'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'comment', + value: rec.data.comment, + fieldLabel: gettext('Comment'), + }, + ], + }); + win.show(); + win.on('destroy', reload); + }; + + me.editBtn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + enableFn: (rec) => canEdit, + selModel: sm, + handler: run_editor, + }); + + me.addBtn = new Proxmox.button.Button({ + text: gettext('Create'), + handler: function () { + sm.deselectAll(); + var win = Ext.create('Proxmox.window.Edit', { + subject: 'IPSet', + url: me.base_url, + method: 'POST', + items: [ + { + xtype: 'textfield', + name: 'name', + value: '', + fieldLabel: gettext('Name'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'comment', + value: '', + fieldLabel: gettext('Comment'), + }, + ], + }); + win.show(); + win.on('destroy', reload); + }, + }); + + me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + enableFn: (rec) => canEdit, + selModel: sm, + baseurl: me.base_url + '/', + callback: reload, + }); + + Ext.apply(me, { + store: store, + tbar: ['IPSet:', me.addBtn, me.removeBtn, me.editBtn], + selModel: sm, + columns: [ + { + header: 'IPSet', + dataIndex: 'name', + minWidth: 150, + flex: 1, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 4, + }, + ], + listeners: { + itemdblclick: run_editor, + select: function (_, rec) { + var url = me.base_url + '/' + rec.data.name; + me.ipset_panel.setBaseUrl(url); + }, + deselect: function () { + me.ipset_panel.setBaseUrl(undefined); + }, + show: reload, + }, + }); + + if (!canEdit) { + me.addBtn.setDisabled(true); + } + + me.callParent(); + + store.load(); + }, +}); + +Ext.define('PVE.IPSetCidrEdit', { + extend: 'Proxmox.window.Edit', + + cidr: undefined, + + initComponent: function () { + var me = this; + + me.isCreate = me.cidr === undefined; + + if (me.isCreate) { + me.url = '/api2/extjs' + me.base_url; + me.method = 'POST'; + } else { + me.url = '/api2/extjs' + me.base_url + '/' + me.cidr; + me.method = 'PUT'; + } + + var column1 = []; + + if (me.isCreate) { + if (!me.list_refs_url) { + throw 'no alias_base_url specified'; + } + + column1.push({ + xtype: 'pveIPRefSelector', + name: 'cidr', + ref_type: 'alias', + autoSelect: false, + editable: true, + base_url: me.list_refs_url, + allowBlank: false, + fieldLabel: gettext('IP/CIDR'), + }); + } else { + column1.push({ + xtype: 'displayfield', + name: 'cidr', + value: '', + fieldLabel: gettext('IP/CIDR'), + }); + } + + var ipanel = Ext.create('Proxmox.panel.InputPanel', { + isCreate: me.isCreate, + column1: column1, + column2: [ + { + xtype: 'proxmoxcheckbox', + name: 'nomatch', + checked: false, + uncheckedValue: 0, + fieldLabel: 'nomatch', + }, + ], + columnB: [ + { + xtype: 'textfield', + name: 'comment', + value: '', + fieldLabel: gettext('Comment'), + }, + ], + }); + + Ext.apply(me, { + subject: gettext('IP/CIDR'), + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + ipanel.setValues(values); + }, + }); + } + }, +}); + +Ext.define( + 'PVE.IPSetGrid', + { + extend: 'Ext.grid.Panel', + alias: 'widget.pveIPSetGrid', + + stateful: true, + stateId: 'grid-firewall-ipsets', + + base_url: undefined, + list_refs_url: undefined, + + addBtn: undefined, + removeBtn: undefined, + editBtn: undefined, + + setBaseUrl: function (url) { + var me = this; + + me.base_url = url; + + if (url === undefined) { + me.addBtn.setDisabled(true); + me.store.removeAll(); + } else { + if (me.canEdit) { + me.addBtn.setDisabled(false); + } + me.removeBtn.baseurl = url + '/'; + me.store.setProxy({ + type: 'proxmox', + url: '/api2/json' + url, + }); + + me.store.load(); + } + }, + + initComponent: function () { + var me = this; + + if (!me.list_refs_url) { + throw 'no1 list_refs_url specified'; + } + + var store = new Ext.data.Store({ + model: 'pve-ipset', + }); + + var reload = function () { + store.load(); + }; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + me.caps = Ext.state.Manager.get('GuiCap'); + me.canEdit = + !!me.caps.vms['VM.Config.Network'] || + !!me.caps.dc['Sys.Modify'] || + !!me.caps.nodes['Sys.Modify']; + + var run_editor = function () { + var rec = sm.getSelection()[0]; + if (!rec || !me.canEdit) { + return; + } + var win = Ext.create('PVE.IPSetCidrEdit', { + base_url: me.base_url, + cidr: rec.data.cidr, + }); + win.show(); + win.on('destroy', reload); + }; + + me.editBtn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + enableFn: (rec) => me.canEdit, + selModel: sm, + handler: run_editor, + }); + + me.addBtn = new Proxmox.button.Button({ + text: gettext('Add'), + disabled: true, + enableFn: (rec) => me.canEdit, + handler: function () { + if (!me.base_url) { + return; + } + var win = Ext.create('PVE.IPSetCidrEdit', { + base_url: me.base_url, + list_refs_url: me.list_refs_url, + }); + win.show(); + win.on('destroy', reload); + }, + }); + + me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + disabled: true, + enableFn: (rec) => me.canEdit, + selModel: sm, + baseurl: me.base_url + '/', + callback: reload, + }); + + var render_errors = function (value, metaData, record) { + var errors = record.data.errors; + if (errors) { + let msg = errors.cidr || errors.nomatch; + if (msg) { + metaData.tdCls = 'proxmox-invalid-row'; + let html = Ext.htmlEncode(`

${Ext.htmlEncode(msg)}

`); + metaData.tdAttr = `data-qwidth=600 data-qtitle="ERROR" data-qtip="${html}"`; + } + } + return Ext.htmlEncode(value); + }; + + Ext.apply(me, { + tbar: ['IP/CIDR:', me.addBtn, me.removeBtn, me.editBtn], + store: store, + selModel: sm, + listeners: { + itemdblclick: run_editor, + }, + columns: [ + { + xtype: 'rownumberer', + // cannot use width on instantiation as rownumberer hard-wires that in the + // constructor to avoid being overridden by applyDefaults + minWidth: 40, + }, + { + header: gettext('IP/CIDR'), + dataIndex: 'cidr', + minWidth: 150, + flex: 1, + renderer: function (value, metaData, record) { + value = render_errors(value, metaData, record); + if (record.data.nomatch) { + return '! ' + value; + } + return value; + }, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + flex: 3, + renderer: function (value) { + return Ext.util.Format.htmlEncode(value); + }, + }, + ], + }); + + me.callParent(); + + if (me.base_url) { + me.setBaseUrl(me.base_url); // load + } + }, + }, + function () { + Ext.define('pve-ipset', { + extend: 'Ext.data.Model', + fields: [{ name: 'nomatch', type: 'boolean' }, 'cidr', 'comment', 'errors'], + idProperty: 'cidr', + }); + }, +); + +Ext.define('PVE.IPSet', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveIPSet', + + title: 'IPSet', + + onlineHelp: 'pve_firewall_ip_sets', + + list_refs_url: undefined, + + initComponent: function () { + var me = this; + + if (!me.list_refs_url) { + throw 'no list_refs_url specified'; + } + + var ipset_panel = Ext.createWidget('pveIPSetGrid', { + region: 'center', + list_refs_url: me.list_refs_url, + border: false, + }); + + var ipset_list = Ext.createWidget('pveIPSetList', { + region: 'west', + ipset_panel: ipset_panel, + base_url: me.base_url, + width: '50%', + border: false, + split: true, + }); + + Ext.apply(me, { + layout: 'border', + items: [ipset_list, ipset_panel], + listeners: { + show: function () { + ipset_list.fireEvent('show', ipset_list); + }, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.window.IPInfo', { + extend: 'Ext.window.Window', + width: 600, + title: gettext('Network Information'), + height: 300, + layout: { + type: 'fit', + }, + modal: true, + items: [ + { + xtype: 'grid', + store: {}, + emptyText: gettext('No network information'), + viewConfig: { + enableTextSelection: true, + }, + columns: [ + { + dataIndex: 'name', + text: gettext('Name'), + renderer: Ext.htmlEncode, + flex: 3, + }, + { + dataIndex: 'hardware-address', + text: gettext('MAC address'), + renderer: Ext.htmlEncode, + width: 140, + }, + { + dataIndex: 'ip-addresses', + text: gettext('IP address'), + align: 'right', + flex: 4, + renderer: function (val) { + if (!Ext.isArray(val)) { + return ''; + } + var ips = []; + val.forEach(function (ip) { + var addr = ip['ip-address']; + var pref = ip.prefix; + if (addr && pref) { + ips.push(Ext.htmlEncode(addr + '/' + pref)); + } + }); + return ips.join('
'); + }, + }, + ], + }, + ], +}); + +Ext.define('PVE.panel.IPViewBase', { + extend: 'Ext.container.Container', + xtype: 'pveIPViewBase', + + layout: { + type: 'hbox', + align: 'top', + }, + + nics: [], + + items: [ + { + xtype: 'box', + html: ' IPs', + }, + { + xtype: 'container', + flex: 1, + layout: { + type: 'vbox', + align: 'right', + pack: 'end', + }, + items: [ + { + xtype: 'label', + flex: 1, + itemId: 'ipBox', + style: { + 'text-align': 'right', + }, + }, + { + xtype: 'button', + itemId: 'moreBtn', + hidden: true, + ui: 'default-toolbar', + handler: function (btn) { + let view = this.up('pveIPViewBase'); + + var win = Ext.create('PVE.window.IPInfo'); + win.down('grid').getStore().setData(view.nics); + win.show(); + }, + text: gettext('More'), + }, + ], + }, + ], + + getDefaultIps: function (nics) { + var _me = this; + var ips = []; + nics.forEach(function (nic) { + if ( + nic['hardware-address'] && + nic['hardware-address'] !== '00:00:00:00:00:00' && + nic['hardware-address'] !== '0:0:0:0:0:0' + ) { + let nic_ips = nic['ip-addresses'] || []; + nic_ips.forEach(function (ip) { + var p = ip['ip-address']; + // show 2 ips at maximum + if (ips.length < 2) { + ips.push(Ext.htmlEncode(p)); + } + }); + } + }); + + return ips; + }, + + createUpdateStore: function (nodename, vmid) { + // implement me in sub-class + }, + + startIPStore: function (store, records, success) { + // implement me in sub-class + }, + + updateStatus: function (unsuccessful, defaultText) { + // implement me in sub-class + }, + + initComponent: function () { + var me = this; + + if (!me.rstore) { + throw 'rstore not given'; + } + + if (!me.pveSelNode) { + throw 'pveSelNode not given'; + } + + me.callParent(); + + let { node, vmid } = me.pveSelNode.data; + me.createUpdateStore(node, vmid); + + me.on('destroy', me.ipStore.stopUpdate, me.ipStore); + + // if we already have info about the vm, use it immediately + if (me.rstore.getCount()) { + me.startIPStore(me.rstore, me.rstore.getData(), false); + } + + me.mon(me.rstore, 'load', me.startIPStore, me); + }, +}); + +Ext.define('PVE.panel.IPViewQEMU', { + extend: 'PVE.panel.IPViewBase', + xtype: 'pveIPViewQEMU', + + createUpdateStore: function (nodename, vmid) { + let me = this; + + me.ipStore = Ext.create('Proxmox.data.UpdateStore', { + interval: 10000, + storeid: `pve-qemu-agent-${vmid}`, + method: 'POST', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${nodename}/qemu/${vmid}/agent/network-get-interfaces`, + }, + }); + + me.mon(me.ipStore, 'load', function (_store, records, success) { + me.nics = records?.[0]?.data.result; + me.updateStatus(!success); + }); + }, + + updateStatus: function (unsuccessful, defaultText) { + let me = this; + + let text = defaultText || gettext('No network information'); + let more = false; + if (unsuccessful) { + text = gettext('Guest Agent not running'); + } else if (me.agent && me.running) { + if (Ext.isArray(me.nics) && me.nics.length) { + more = true; + let ips = me.getDefaultIps(me.nics); + if (ips.length !== 0) { + text = ips.join('
'); + } + } else if (me.nics && me.nics.error) { + let msg = gettext('Cannot get info from Guest Agent
Error: {0}'); + text = Ext.String.format(msg, Ext.htmlEncode(me.nics.error.desc)); + } + } else if (me.agent) { + text = gettext('Guest Agent not running'); + } else { + text = gettext('No Guest Agent configured'); + } + + me.down('#ipBox').update(text); + me.down('#moreBtn').setVisible(more); + }, + + startIPStore: function (store, records, success) { + let me = this; + + let agentRec = store.getById('agent'); + let state = store.getById('status'); + + me.agent = agentRec && agentRec.data.value === 1; + me.running = state && state.data.value === 'running'; + + let caps = Ext.state.Manager.get('GuiCap'); + if (!caps.vms['VM.GuestAgent.Audit']) { + me.updateStatus( + false, + Ext.String.format(gettext("Requires '{0}' Privileges"), 'VM.GuestAgent.Audit'), + ); + return; + } + + if (me.agent && me.running && me.ipStore.isStopped) { + me.ipStore.startUpdate(); + } else if (me.ipStore.isStopped) { + me.updateStatus(); + } + }, +}); + +Ext.define('PVE.panel.IPViewLXC', { + extend: 'PVE.panel.IPViewBase', + xtype: 'pveIPViewLXC', + + createUpdateStore: function (nodename, vmid) { + let me = this; + + me.ipStore = Ext.create('Proxmox.data.UpdateStore', { + interval: 10000, + storeid: `lxc-interfaces-${vmid}`, + method: 'GET', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${nodename}/lxc/${vmid}/interfaces`, + }, + }); + + me.mon(me.ipStore, 'load', function (_store, records, success) { + me.nics = records?.map((r) => r.data); + me.updateStatus(!success); + }); + }, + + updateStatus: function (_unsuccessful, defaultText) { + let me = this; + + let text = defaultText || gettext('No network information'); + let more = false; + if (Ext.isArray(me.nics) && me.nics.length) { + more = true; + let ips = me.getDefaultIps(me.nics); + if (ips.length !== 0) { + text = ips.join('
'); + } + } + me.down('#ipBox').update(text); + me.down('#moreBtn').setVisible(more); + }, + + startIPStore: function (store, records, success) { + let me = this; + + let state = store.getById('status'); + me.running = state && state.data.value === 'running'; + + var caps = Ext.state.Manager.get('GuiCap'); + + if (!caps.vms['VM.Audit']) { + me.updateStatus( + false, + Ext.String.format(gettext("Requires '{0}' Privileges"), 'VM.Audit'), + ); + return; + } + + if (me.running && me.ipStore.isStopped) { + me.ipStore.startUpdate(); + } else if (me.ipStore.isStopped) { + me.updateStatus(); + } + }, +}); +/* + * This is a running chart widget you add time datapoints to it, and we only + * show the last x of it used for ceph performance charts + */ +Ext.define('PVE.widget.RunningChart', { + extend: 'Ext.container.Container', + alias: 'widget.pveRunningChart', + + layout: { + type: 'hbox', + align: 'center', + }, + items: [ + { + width: 80, + xtype: 'box', + itemId: 'title', + data: { + title: '', + }, + tpl: '

{title}:

', + }, + { + flex: 1, + xtype: 'cartesian', + height: '100%', + itemId: 'chart', + border: false, + axes: [ + { + type: 'numeric', + position: 'left', + hidden: true, + minimum: 0, + }, + { + type: 'numeric', + position: 'bottom', + hidden: true, + }, + ], + + store: { + trackRemoved: false, + data: {}, + }, + + sprites: [ + { + id: 'valueSprite', + type: 'text', + text: '0 B/s', + textAlign: 'end', + textBaseline: 'middle', + fontSize: 14, + }, + ], + + series: [ + { + type: 'line', + xField: 'time', + yField: 'val', + fill: 'true', + colors: ['#cfcfcf'], + tooltip: { + trackMouse: true, + renderer: function (tooltip, record, ctx) { + if (!record || !record.data) { + return; + } + const view = this.getChart(); + const date = new Date(record.data.time); + const value = view.up().renderer(record.data.val); + const line1 = `${view.up().title}: ${value}`; + const line2 = Ext.Date.format(date, 'H:i:s'); + tooltip.setHtml(`${line1}
${line2}`); + }, + }, + style: { + lineWidth: 1.5, + opacity: 0.6, + }, + marker: { + opacity: 0, + scaling: 0.01, + fx: { + duration: 200, + easing: 'easeOut', + }, + }, + highlightCfg: { + opacity: 1, + scaling: 1.5, + }, + }, + ], + }, + ], + + // the renderer for the tooltip and last value, default just the value + renderer: Ext.identityFn, + + // show the last x seconds default is 5 minutes + timeFrame: 5 * 60, + + checkThemeColors: function () { + let me = this; + let rootStyle = getComputedStyle(document.documentElement); + + // get color + let background = rootStyle.getPropertyValue('--pwt-panel-background').trim() || '#ffffff'; + let text = rootStyle.getPropertyValue('--pwt-text-color').trim() || '#000000'; + + // set the colors + me.chart.setBackground(background); + me.chart.valuesprite.setAttributes({ fillStyle: text }, true); + me.chart.redraw(); + }, + + addDataPoint: function (value, time) { + let view = this.chart; + let panel = view.up(); + let now = new Date().getTime(); + let begin = new Date(now - 1000 * panel.timeFrame).getTime(); + + view.store.add({ + time: time || now, + val: value || 0, + }); + + // delete all old records when we have 20 times more datapoints + // than seconds in our timeframe (so even a subsecond graph does + // not trigger this often) + // + // records in the store do not take much space, but like this, + // we prevent a memory leak when someone has the site open for a long time + // with minimal graphical glitches + if (view.store.count() > panel.timeFrame * 20) { + let oldData = view.store.getData().createFiltered(function (item) { + return item.data.time < begin; + }); + + view.store.remove(oldData.getRange()); + } + + view.timeaxis.setMinimum(begin); + view.timeaxis.setMaximum(now); + view.valuesprite.setText(panel.renderer(value || 0).toString()); + view.valuesprite.setAttributes( + { + x: view.getWidth() - 15, + y: view.getHeight() / 2, + }, + true, + ); + view.redraw(); + }, + + setTitle: function (title) { + this.title = title; + let titlebox = this.getComponent('title'); + titlebox.update({ title: title }); + }, + + initComponent: function () { + var me = this; + me.callParent(); + + if (me.title) { + me.getComponent('title').update({ title: me.title }); + } + me.chart = me.getComponent('chart'); + me.chart.timeaxis = me.chart.getAxes()[1]; + me.chart.valuesprite = me.chart.getSurface('chart').get('valueSprite'); + if (me.color) { + me.chart.series[0].setStyle({ + fill: me.color, + stroke: me.color, + }); + } + + me.checkThemeColors(); + + // switch colors on media query changes + me.mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); + me.themeListener = (e) => { + me.checkThemeColors(); + }; + me.mediaQueryList.addEventListener('change', me.themeListener); + }, + + doDestroy: function () { + let me = this; + + me.mediaQueryList.removeEventListener('change', me.themeListener); + + me.callParent(); + }, +}); +/* + * This class describes the bottom panel + */ +Ext.define('PVE.panel.StatusPanel', { + extend: 'Ext.tab.Panel', + alias: 'widget.pveStatusPanel', + + //title: "Logs", + //tabPosition: 'bottom', + + initComponent: function () { + var me = this; + + var stateid = 'ltab'; + var sp = Ext.state.Manager.getProvider(); + + var state = sp.get(stateid); + if (state && state.value) { + me.activeTab = state.value; + } + + Ext.apply(me, { + listeners: { + tabchange: function () { + var atab = me.getActiveTab().itemId; + let tabstate = { value: atab }; + sp.set(stateid, tabstate); + }, + }, + items: [ + { + itemId: 'tasks', + title: gettext('Tasks'), + xtype: 'pveClusterTasks', + }, + { + itemId: 'clog', + title: gettext('Cluster log'), + xtype: 'pveClusterLog', + }, + ], + }); + + me.callParent(); + + me.items.get(0).fireEvent('show', me.items.get(0)); + + var statechange = function (_, key, newstate) { + if (key === stateid) { + let atab = me.getActiveTab().itemId; + let ntab = newstate.value; + if (newstate && ntab && atab !== ntab) { + me.setActiveTab(ntab); + } + } + }; + + sp.on('statechange', statechange); + me.on('destroy', function () { + sp.un('statechange', statechange); + }); + }, +}); +Ext.define('PVE.panel.GuestStatusView', { + extend: 'Proxmox.panel.StatusView', + alias: 'widget.pveGuestStatusView', + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: function (initialConfig) { + var me = this; + return { + isQemu: me.pveSelNode.data.type === 'qemu', + isLxc: me.pveSelNode.data.type === 'lxc', + }; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + if (view.pveSelNode.data.type !== 'lxc') { + return; + } + + const nodename = view.pveSelNode.data.node; + const vmid = view.pveSelNode.data.vmid; + + Proxmox.Utils.API2Request({ + url: `/api2/extjs/nodes/${nodename}/lxc/${vmid}/config`, + waitMsgTargetView: view, + method: 'GET', + success: ({ result }) => { + view.down('#unprivileged').updateValue( + Proxmox.Utils.format_boolean(result.data.unprivileged), + ); + view.ostype = Ext.htmlEncode(result.data.ostype); + }, + }); + }, + }, + + layout: { + type: 'vbox', + align: 'stretch', + }, + + defaults: { + xtype: 'pmxInfoWidget', + padding: '2 25', + }, + items: [ + { + xtype: 'box', + height: 20, + }, + { + itemId: 'status', + title: gettext('Status'), + iconCls: 'fa fa-info fa-fw', + printBar: false, + multiField: true, + renderer: function (record) { + var _me = this; + var text = record.data.status; + var qmpstatus = record.data.qmpstatus; + if (qmpstatus && qmpstatus !== record.data.status) { + text += ' (' + qmpstatus + ')'; + } + return text; + }, + }, + { + itemId: 'hamanaged', + iconCls: 'fa fa-heartbeat fa-fw', + title: gettext('HA State'), + printBar: false, + textField: 'ha', + renderer: PVE.Utils.format_ha, + }, + { + itemId: 'node', + iconCls: 'fa fa-building fa-fw', + title: gettext('Node'), + cbind: { + text: '{pveSelNode.data.node}', + }, + printBar: false, + }, + { + itemId: 'unprivileged', + iconCls: 'fa fa-lock fa-fw', + title: gettext('Unprivileged'), + printBar: false, + cbind: { + hidden: '{isQemu}', + }, + }, + { + xtype: 'box', + height: 10, + }, + { + itemId: 'cpu', + iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', + title: gettext('CPU usage'), + valueField: 'cpu', + maxField: 'cpus', + renderer: Proxmox.Utils.render_cpu_usage, + // in this specific api call + // we already have the correct value for the usage + calculate: Ext.identityFn, + }, + { + itemId: 'memory', + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + title: gettext('Memory usage'), + valueField: 'mem', + maxField: 'maxmem', + warningThreshold: 0.9, + criticalThreshold: 0.975, + }, + { + itemId: 'memory-host', + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + title: gettext('Host memory usage'), + valueField: 'memhost', + printBar: false, + renderer: function (used, max) { + return Proxmox.Utils.render_size(used); + }, + cbind: { + hidden: '{isLxc}', + disabled: '{isLxc}', + }, + }, + { + itemId: 'swap', + iconCls: 'fa fa-refresh fa-fw', + title: gettext('SWAP usage'), + valueField: 'swap', + maxField: 'maxswap', + cbind: { + hidden: '{isQemu}', + disabled: '{isQemu}', + }, + }, + { + itemId: 'rootfs', + iconCls: 'fa fa-hdd-o fa-fw', + title: gettext('Bootdisk size'), + valueField: 'disk', + maxField: 'maxdisk', + printBar: false, + renderer: function (used, max) { + var me = this; + me.setPrintBar(used > 0); + if (used === 0) { + return Proxmox.Utils.render_size(max); + } else { + return Proxmox.Utils.render_size_usage(used, max); + } + }, + }, + { + xtype: 'box', + height: 10, + }, + { + cbind: { + xtype: (get) => (get('isQemu') ? 'pveIPViewQEMU' : 'pveIPViewLXC'), + rstore: '{rstore}', + pveSelNode: '{pveSelNode}', + }, + }, + ], + + updateTitle: function () { + var me = this; + var uptime = me.getRecordValue('uptime'); + + var text = ''; + if (Number(uptime) > 0) { + text = + ' (' + gettext('Uptime') + ': ' + Proxmox.Utils.format_duration_long(uptime) + ')'; + } + + let title = `
${me.getRecordValue('name') + text}
`; + + if (me.pveSelNode.data.type === 'lxc' && me.ostype && me.ostype !== 'unmanaged') { + // Manual mappings for distros with special casing + const namemap = { + archlinux: 'Arch Linux', + nixos: 'NixOS', + opensuse: 'openSUSE', + centos: 'CentOS', + }; + + const distro = namemap[me.ostype] ?? Ext.String.capitalize(me.ostype); + title += `
 ${distro}
`; + } + + me.setTitle(title); + }, +}); +Ext.define('PVE.guest.Summary', { + extend: 'Ext.panel.Panel', + xtype: 'pveGuestSummary', + + scrollable: true, + bodyPadding: 5, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + if (!me.workspace) { + throw 'no workspace specified'; + } + + if (!me.statusStore) { + throw 'no status storage specified'; + } + + var type = me.pveSelNode.data.type; + var template = !!me.pveSelNode.data.template; + var rstore = me.statusStore; + + let hideMemhostStateKey = 'pve-vm-hide-memhost'; + let sp = Ext.state.Manager.getProvider(); + + let memoryStats = { + fields: ['maxmem', 'mem'], + fieldTitles: [gettext('Total'), gettext('Used')], + }; + if (type === 'qemu') { + memoryStats.fields.push({ + type: 'line', + fill: false, + yField: 'memhost', + title: gettext('Host Memory Usage'), + hidden: sp.get(hideMemhostStateKey, true), + style: { + lineWidth: 2.5, + opacity: 1, + }, + }); + } + + var items = [ + { + xtype: template ? 'pveTemplateStatusView' : 'pveGuestStatusView', + flex: 1, + padding: template ? '5' : '0 5 0 0', + itemId: 'gueststatus', + pveSelNode: me.pveSelNode, + rstore: rstore, + }, + { + xtype: 'pmxNotesView', + flex: 1, + padding: template ? '5' : '0 0 0 5', + itemId: 'notesview', + pveSelNode: me.pveSelNode, + }, + ]; + + var rrdstore; + if (!template) { + // in non-template mode put the two panels always together + items = [ + { + xtype: 'container', + height: 300, + layout: { + type: 'hbox', + align: 'stretch', + }, + items: items, + }, + ]; + + rrdstore = Ext.create('Proxmox.data.RRDStore', { + rrdurl: `/api2/json/nodes/${nodename}/${type}/${vmid}/rrddata`, + model: 'pve-rrd-guest', + }); + + items.push( + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Usage'), + pveSelNode: me.pveSelNode, + fields: ['cpu'], + fieldTitles: [gettext('CPU usage')], + unit: 'percent', + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory Usage'), + pveSelNode: me.pveSelNode, + fields: memoryStats.fields, + fieldTitles: memoryStats.fieldTitles, + colors: ['#94ae0a', '#115fa6', '#c4c0c0'], + unit: 'bytes', + powerOfTwo: true, + store: rrdstore, + onLegendChange: function (_legend, record, _, seriesIndex) { + if (seriesIndex === 2) { + // third data series is clicked -> hostmem + sp.set(hideMemhostStateKey, record.data.disabled); + } + }, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Network Traffic'), + pveSelNode: me.pveSelNode, + fields: ['netin', 'netout'], + fieldTitles: [gettext('Incoming'), gettext('Outgoing')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Disk IO'), + pveSelNode: me.pveSelNode, + fields: ['diskread', 'diskwrite'], + fieldTitles: [gettext('Reads'), gettext('Writes')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Pressure Stall'), + pveSelNode: me.pveSelNode, + fieldTitles: ['Some', 'Full'], + fields: ['pressurecpusome', 'pressurecpufull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('IO Pressure Stall'), + pveSelNode: me.pveSelNode, + fieldTitles: ['Some', 'Full'], + fields: ['pressureiosome', 'pressureiofull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory Pressure Stall'), + pveSelNode: me.pveSelNode, + fieldTitles: ['Some', 'Full'], + fields: ['pressurememorysome', 'pressurememoryfull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + ); + } + + Ext.apply(me, { + tbar: ['->', { xtype: 'proxmoxRRDTypeSelector' }], + items: [ + { + xtype: 'container', + itemId: 'itemcontainer', + layout: { + type: 'column', + }, + minWidth: 700, + defaults: { + minHeight: 360, + padding: 5, + }, + items: items, + listeners: { + resize: function (container) { + Proxmox.Utils.updateColumns(container); + }, + }, + }, + ], + }); + + me.callParent(); + if (!template) { + rrdstore.startUpdate(); + me.on('destroy', rrdstore.stopUpdate); + } + me.mon(sp, 'statechange', function (provider, key, value) { + if (key !== 'summarycolumns') { + return; + } + Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); + }); + }, +}); +Ext.define('PVE.panel.TemplateStatusView', { + extend: 'Proxmox.panel.StatusView', + alias: 'widget.pveTemplateStatusView', + + layout: { + type: 'vbox', + align: 'stretch', + }, + + defaults: { + xtype: 'pmxInfoWidget', + printBar: false, + padding: '2 25', + }, + items: [ + { + xtype: 'box', + height: 20, + }, + { + itemId: 'hamanaged', + iconCls: 'fa fa-heartbeat fa-fw', + title: gettext('HA State'), + printBar: false, + textField: 'ha', + renderer: PVE.Utils.format_ha, + }, + { + itemId: 'node', + iconCls: 'fa fa-fw fa-building', + title: gettext('Node'), + }, + { + xtype: 'box', + height: 20, + }, + { + itemId: 'cpus', + iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', + title: gettext('Processors'), + textField: 'cpus', + }, + { + itemId: 'memory', + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + title: gettext('Memory'), + textField: 'maxmem', + renderer: Proxmox.Utils.render_size, + }, + { + itemId: 'swap', + iconCls: 'fa fa-refresh fa-fw', + title: gettext('Swap'), + textField: 'maxswap', + renderer: Proxmox.Utils.render_size, + }, + { + itemId: 'disk', + iconCls: 'fa fa-hdd-o fa-fw', + title: gettext('Bootdisk size'), + textField: 'maxdisk', + renderer: Proxmox.Utils.render_size, + }, + { + xtype: 'box', + height: 20, + }, + ], + + initComponent: function () { + var me = this; + + var name = me.pveSelNode.data.name; + if (!name) { + throw 'no name specified'; + } + + me.title = name; + + me.callParent(); + if (me.pveSelNode.data.type !== 'lxc') { + me.remove(me.getComponent('swap')); + } + me.getComponent('node').updateValue(me.pveSelNode.data.node); + }, +}); +Ext.define('PVE.panel.MultiDiskPanel', { + extend: 'Ext.panel.Panel', + + mixins: ['Proxmox.Mixin.CBind'], + + setNodename: function (nodename) { + this.items.each((panel) => panel.setNodename(nodename)); + }, + + border: false, + bodyBorder: false, + + importDisk: false, // allow import panel + + layout: 'card', + + controller: { + xclass: 'Ext.app.ViewController', + + vmconfig: {}, + + onAdd: function () { + this.addDiskChecked(false); + }, + + onImport: function () { + this.addDiskChecked(true); + }, + + addDiskChecked: function (importDisk) { + let me = this; + me.lookup('addButton').setDisabled(true); + me.lookup('addImportButton').setDisabled(true); + me.addDisk(importDisk); + let count = me.lookup('grid').getStore().getCount() + 1; // +1 is from ide2 + me.lookup('addButton').setDisabled(count >= me.maxCount); + me.lookup('addImportButton').setDisabled(count >= me.maxCount); + }, + + getNextFreeDisk: function (vmconfig) { + throw 'implement in subclass'; + }, + + addPanel: function (itemId, vmconfig, nextFreeDisk, importDisk) { + throw 'implement in subclass'; + }, + + // define in subclass + diskSorter: undefined, + + addDisk: function (importDisk) { + let me = this; + let grid = me.lookup('grid'); + let store = grid.getStore(); + + // get free disk id + let vmconfig = me.getVMConfig(true); + let nextFreeDisk = me.getNextFreeDisk(vmconfig); + if (!nextFreeDisk) { + return; + } + + // add store entry + panel + let itemId = 'disk-card-' + ++Ext.idSeed; + let rec = store.add({ + name: nextFreeDisk.confid, + itemId, + })[0]; + + let panel = me.addPanel(itemId, vmconfig, nextFreeDisk, importDisk); + panel.updateVMConfig(vmconfig); + + // we need to setup a validitychange handler, so that we can show + // that a disk has invalid fields + let fields = panel.query('field'); + fields.forEach((el) => + el.on('validitychange', () => { + let valid = fields.every((field) => field.isValid()); + rec.set('valid', valid); + me.checkValidity(); + }), + ); + + store.sort(me.diskSorter); + + // select if the panel added is the only one + if (store.getCount() === 1) { + grid.getSelectionModel().select(0, false); + } + }, + + getBaseVMConfig: function () { + throw 'implement in subclass'; + }, + + getVMConfig: function (all) { + let me = this; + + let vmconfig = me.getBaseVMConfig(); + + me.lookup('grid') + .getStore() + .each((rec) => { + if (all || rec.get('valid')) { + vmconfig[rec.get('name')] = rec.get('itemId'); + } + }); + + return vmconfig; + }, + + checkValidity: function () { + let me = this; + let valid = me.lookup('grid').getStore().findExact('valid', false) === -1; + me.lookup('validationfield').setValue(valid); + }, + + updateVMConfig: function () { + let me = this; + let view = me.getView(); + let grid = me.lookup('grid'); + let store = grid.getStore(); + + let vmconfig = me.getVMConfig(); + + let valid = true; + + store.each((rec) => { + let itemId = rec.get('itemId'); + let name = rec.get('name'); + let panel = view.getComponent(itemId); + if (!panel) { + throw 'unexpected missing panel'; + } + + // copy config for each panel and remote its own id + let panel_vmconfig = Ext.apply({}, vmconfig); + if (panel_vmconfig[name] === itemId) { + delete panel_vmconfig[name]; + } + + if (!rec.get('valid')) { + valid = false; + } + + panel.updateVMConfig(panel_vmconfig); + }); + + me.lookup('validationfield').setValue(valid); + + return vmconfig; + }, + + onChange: function (panel, newVal) { + let me = this; + let store = me.lookup('grid').getStore(); + + let el = store.findRecord('itemId', panel.itemId, 0, false, true, true); + if (el.get('name') === newVal) { + // do not update if there was no change + return; + } + + el.set('name', newVal); + el.commit(); + + store.sort(me.diskSorter); + + // so that it happens after the layouting + setTimeout(function () { + me.updateVMConfig(); + }, 10); + }, + + onRemove: function (tableview, rowIndex, colIndex, item, event, record) { + let me = this; + let grid = me.lookup('grid'); + let store = grid.getStore(); + let removed_idx = store.indexOf(record); + + let selection = grid.getSelection()[0]; + let selected_idx = store.indexOf(selection); + + if (selected_idx === removed_idx) { + let newidx = store.getCount() > removed_idx + 1 ? removed_idx + 1 : removed_idx - 1; + grid.getSelectionModel().select(newidx, false); + } + + store.remove(record); + me.getView().remove(record.get('itemId')); + me.lookup('addButton').setDisabled(false); + me.lookup('addImportButton').setDisabled(false); + me.updateVMConfig(); + me.checkValidity(); + }, + + onSelectionChange: function (grid, selection) { + let me = this; + if (!selection || selection.length < 1) { + return; + } + + me.getView().setActiveItem(selection[0].data.itemId); + }, + + control: { + inputpanel: { + diskidchange: 'onChange', + }, + 'grid[reference=grid]': { + selectionchange: 'onSelectionChange', + }, + }, + + init: function (view) { + let me = this; + me.onAdd(); + me.lookup('grid').getSelectionModel().select(0, false); + }, + }, + + dockedItems: [ + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + dock: 'left', + border: false, + width: 130, + cbind: {}, // for nested cbinds + items: [ + { + xtype: 'grid', + hideHeaders: true, + reference: 'grid', + flex: 1, + emptyText: gettext('No Disks'), + margin: '0 0 5 0', + store: { + fields: ['name', 'itemId', 'valid'], + data: [], + }, + columns: [ + { + dataIndex: 'name', + renderer: function (val, md, rec) { + let warn = ''; + if (!rec.get('valid')) { + warn = ' '; + } + return val + warn; + }, + flex: 1, + }, + { + xtype: 'actioncolumn', + width: 30, + align: 'center', + menuDisabled: true, + items: [ + { + iconCls: 'x-fa fa-trash critical', + tooltip: 'Delete', + handler: 'onRemove', + isActionDisabled: 'deleteDisabled', + }, + ], + }, + ], + }, + { + xtype: 'button', + reference: 'addButton', + text: gettext('Add'), + iconCls: 'fa fa-plus-circle', + handler: 'onAdd', + }, + { + xtype: 'button', + reference: 'addImportButton', + text: gettext('Import'), + iconCls: 'fa fa-cloud-download', + handler: 'onImport', + margin: '5 0 0 0', + cbind: { + disabled: '{!importDisk}', + hidden: '{!importDisk}', + }, + }, + { + // dummy field to control wizard validation + xtype: 'textfield', + hidden: true, + reference: 'validationfield', + submitValue: false, + value: true, + validator: (val) => !!val, + }, + ], + }, + ], +}); +Ext.define('PVE.panel.TagConfig', { + extend: 'PVE.panel.Config', + alias: 'widget.pveTagConfig', + + onlineHelp: 'gui_tags', +}); +/* + * Left Treepanel, containing all the resources we manage in this datacenter: server nodes, server storages, VMs and Containers + */ +Ext.define('PVE.tree.ResourceTree', { + extend: 'Ext.tree.TreePanel', + alias: ['widget.pveResourceTree'], + + userCls: 'proxmox-tags-circle', + + statics: { + typeDefaults: { + node: { + iconCls: 'fa fa-building', + text: gettext('Nodes'), + }, + pool: { + iconCls: 'fa fa-tags', + text: gettext('Resource Pool'), + }, + storage: { + iconCls: 'fa fa-database', + text: gettext('Storage'), + }, + sdn: { + iconCls: 'fa fa-th', + text: gettext('SDN'), + }, + network: { + iconCls: 'fa fa-globe', + text: gettext('Network'), + }, + qemu: { + iconCls: 'fa fa-desktop', + text: gettext('Virtual Machine'), + }, + lxc: { + iconCls: 'fa fa-cube', + text: gettext('LXC Container'), + }, + template: { + iconCls: 'fa fa-file-o', + }, + tag: { + iconCls: 'fa fa-tag', + }, + }, + }, + + columns: [ + { + xtype: 'treecolumn', + flex: 1, + dataIndex: 'text', + renderer: function (val, meta, rec) { + let info = rec.data; + + let text = info.text; + let status = ''; + if (info.type === 'storage') { + let usage = info.disk / info.maxdisk; + if (usage >= 0.0 && usage <= 1.0) { + let barHeight = (usage * 100).toFixed(0); + let remainingHeight = (100 - barHeight).toFixed(0); + status = '
'; + status += `
`; + status += `
`; + status += '
'; + } + } + if (Ext.isNumeric(info.vmid) && info.vmid > 0) { + if (PVE.UIOptions.getTreeSortingValue('sort-field') !== 'vmid') { + text = `${info.name} (${String(info.vmid)})`; + } + } + text = `${status}${text}`; + text += PVE.Utils.renderTags(info.tags, PVE.UIOptions.tagOverrides); + + if (info.id === 'root' && PVE.ClusterName) { + text += ` (${PVE.ClusterName})`; + } + + return (info.renderedText = text); + }, + }, + ], + + useArrows: true, + + // private + getTypeOrder: function (type) { + switch (type) { + case 'lxc': + return 0; + case 'qemu': + return 1; + case 'node': + return 2; + case 'sdn': + return 3; + case 'network': + return 3.5; + case 'storage': + return 4; + default: + return 9; + } + }, + + // private + nodeSortFn: function (node1, node2) { + let me = this; + let n1 = node1.data, + n2 = node2.data; + + if (!n1.groupbyid === !n2.groupbyid) { + let n1IsGuest = n1.type === 'qemu' || n1.type === 'lxc'; + let n2IsGuest = n2.type === 'qemu' || n2.type === 'lxc'; + if (me['group-guest-types'] || !n1IsGuest || !n2IsGuest) { + // first sort (group) by type + let res = me.getTypeOrder(n1.type) - me.getTypeOrder(n2.type); + if (res !== 0) { + return res; + } + } + + // then sort (group) by ID + if (n1IsGuest) { + if (me['group-templates'] && !n1.template !== !n2.template) { + return n1.template ? 1 : -1; // sort templates after regular VMs + } + if (me['sort-field'] === 'vmid') { + if (n1.vmid > n2.vmid) { + // prefer VMID as metric for guests + return 1; + } else if (n1.vmid < n2.vmid) { + return -1; + } + } else { + return n1.name.localeCompare(n2.name); + } + } + // same types but not a guest + return n1.id > n2.id ? 1 : n1.id < n2.id ? -1 : 0; + } else if (n1.groupbyid) { + return -1; + } else if (n2.groupbyid) { + return 1; + } + return 0; // should not happen + }, + + // private: fast binary search + findInsertIndex: function (node, child, start, end) { + let me = this; + + let diff = end - start; + if (diff <= 0) { + return start; + } + let mid = start + (diff >> 1); + + let res = me.nodeSortFn(child, node.childNodes[mid]); + if (res <= 0) { + return me.findInsertIndex(node, child, start, mid); + } else { + return me.findInsertIndex(node, child, mid + 1, end); + } + }, + + setIconCls: function (info) { + let cls = PVE.Utils.get_object_icon_class(info.type, info); + if (cls !== '') { + info.iconCls = cls; + } + }, + + getToolTip: function (info) { + let qtips = []; + if (info.qmpstatus || info.status) { + qtips.push(Ext.String.format(gettext('Status: {0}'), info.qmpstatus || info.status)); + } + if (info.lock) { + qtips.push(Ext.String.format(gettext('Config locked ({0})'), info.lock)); + } + if (info.hastate !== 'unmanaged') { + qtips.push(Ext.String.format(gettext('HA State: {0}'), info.hastate)); + } + if (info.type === 'storage') { + let usage = info.disk / info.maxdisk; + if (usage >= 0.0 && usage <= 1.0) { + qtips.push(Ext.String.format(gettext('Usage: {0}%'), (usage * 100).toFixed(2))); + } + } + + if (qtips.length === 0) { + return undefined; + } + + let tip = qtips.join(', '); + info.tip = tip; + return tip; + }, + + // private + addChildSorted: function (node, info) { + let me = this; + + me.setIconCls(info); + + if (info.groupbyid) { + if (me.viewFilter.groupRenderer) { + info.text = me.viewFilter.groupRenderer(info); + } else if (info.type === 'type') { + let defaults = PVE.tree.ResourceTree.typeDefaults[info.groupbyid]; + if (defaults && defaults.text) { + info.text = defaults.text; + } + } else { + info.text = info.groupbyid; + } + } + let child = Ext.create('PVETree', info); + + if (node.childNodes) { + let pos = me.findInsertIndex(node, child, 0, node.childNodes.length); + node.insertBefore(child, node.childNodes[pos]); + } else { + node.insertBefore(child); + } + + return child; + }, + + // private + groupChild: function (node, info, groups, level) { + let me = this; + + let groupBy = groups[level]; + let v = info[groupBy]; + + if (v) { + let group = node.findChild('groupbyid', v); + if (!group) { + let groupinfo; + if (info.type === groupBy) { + groupinfo = info; + } else { + groupinfo = { + type: groupBy, + id: groupBy + '/' + v, + }; + if (groupBy !== 'type') { + groupinfo[groupBy] = v; + } + } + groupinfo.leaf = false; + groupinfo.groupbyid = v; + group = me.addChildSorted(node, groupinfo); + } + if (info.type === groupBy) { + return group; + } + if (group) { + return me.groupChild(group, info, groups, level + 1); + } + } + + return me.addChildSorted(node, info); + }, + + saveSortingOptions: function () { + let me = this; + let changed = false; + for (const key of ['sort-field', 'group-templates', 'group-guest-types']) { + let newValue = PVE.UIOptions.getTreeSortingValue(key); + if (me[key] !== newValue) { + me[key] = newValue; + changed = true; + } + } + return changed; + }, + + initComponent: function () { + let me = this; + me.saveSortingOptions(); + + let rstore = PVE.data.ResourceStore; + let sp = Ext.state.Manager.getProvider(); + + if (!me.viewFilter) { + me.viewFilter = {}; + } + + let pdata = { + dataIndex: {}, + updateCount: 0, + }; + + let store = Ext.create('Ext.data.TreeStore', { + model: 'PVETree', + root: { + expanded: true, + id: 'root', + text: gettext('Datacenter'), + iconCls: 'fa fa-server', + }, + }); + + let stateid = 'rid'; + + const changedFields = [ + 'disk', + 'maxdisk', + 'vmid', + 'name', + 'type', + 'running', + 'template', + 'status', + 'qmpstatus', + 'hastate', + 'lock', + 'tags', + ]; + + // special case ids from the tag view, since they change the id in the state + let idMapFn = function (id) { + if (!id) { + return undefined; + } + if (id.startsWith('qemu') || id.startsWith('lxc')) { + let [realId, _tag] = id.split('-'); + return realId; + } + return id; + }; + + let findNode = function (rootNode, id) { + if (!id) { + return undefined; + } + let node = rootNode.findChild('id', id, true); + if (!node) { + node = rootNode.findChildBy( + (r) => idMapFn(r.data.id) === idMapFn(id), + undefined, + true, + ); + } + return node; + }; + + let firstUpdate = true; + + let updateTree = function () { + store.suspendEvents(); + + let rootnode; + if (firstUpdate) { + rootnode = Ext.create('PVETree', { + expanded: true, + id: 'root', + text: gettext('Datacenter'), + iconCls: 'fa fa-server', + }); + } else { + rootnode = me.store.getRootNode(); + } + // remember selected node (and all parents) + let sm = me.getSelectionModel(); + let lastsel = sm.getSelection()[0]; + let parents = []; + let sorting_changed = me.saveSortingOptions(); + for (let node = lastsel; node; node = node.parentNode) { + parents.push(node); + } + + let groups = me.viewFilter.groups || []; + // explicitly check for node/template, as those are not always grouping attributes + let attrMoveChecks = me.viewFilter.attrMoveChecks ?? {}; + + // also check for name for when the tree is sorted by name + let moveCheckAttrs = groups.concat(['node', 'template', 'name']); + let filterFn = me.viewFilter.getFilterFn ? me.viewFilter.getFilterFn() : Ext.identityFn; + + let reselect = false; // for disappeared nodes + let index = pdata.dataIndex; + // remove vanished or moved items and update changed items in-place + for (const [key, olditem] of Object.entries(index)) { + // getById() use find(), which is slow (ExtJS4 DP5) + let oldid = olditem.data.id; + let id = idMapFn(olditem.data.id); + let item = rstore.data.get(id); + + let changed = sorting_changed, + moved = sorting_changed; + if (item) { + // test if any grouping attributes changed, catches migrated tree-nodes in server view too + for (const attr of moveCheckAttrs) { + if (attrMoveChecks[attr]) { + if (attrMoveChecks[attr](olditem, item)) { + moved = true; + break; + } + } else if (item.data[attr] !== olditem.data[attr]) { + moved = true; + break; + } + } + + // tree item has been updated + for (const field of changedFields) { + if (item.data[field] !== olditem.data[field]) { + changed = true; + break; + } + } + // FIXME: also test filterfn()? + } + + if (changed) { + olditem.beginEdit(); + let info = olditem.data; + Ext.apply(info, item.data); + if (info.id !== oldid) { + info.id = oldid; + } + me.setIconCls(info); + olditem.commit(); + } + if ((!item || moved) && olditem.isLeaf()) { + delete index[key]; + let parentNode = olditem.parentNode; + // a selected item moved (migration) or disappeared (destroyed), so deselect that + // node now and try to reselect the moved (or its parent) node later + if (lastsel && olditem.data.id === lastsel.data.id) { + reselect = true; + sm.deselect(olditem); + } + // store events are suspended, so remove the item manually + store.remove(olditem); + parentNode.removeChild(olditem, true); + if (parentNode.childNodes.length < 1 && parentNode.parentNode) { + let grandParent = parentNode.parentNode; + grandParent.removeChild(parentNode, true); + } + } + } + + let items = rstore.getData().items.flatMap(me.viewFilter.itemMap ?? Ext.identityFn); + items.forEach(function (item) { + // add new items + let olditem = index[item.data.id]; + if (olditem) { + return; + } + if (filterFn && !filterFn(item)) { + return; + } + let info = Ext.apply({ leaf: true }, item.data); + + let child = me.groupChild(rootnode, info, groups, 0); + if (child) { + index[item.data.id] = child; + } + }); + + store.resumeEvents(); + store.fireEvent('refresh', store); + + let foundChild = findNode(rootnode, lastsel?.data.id); + + // select parent node if original selected node vanished + if (lastsel && !foundChild) { + lastsel = rootnode; + for (const node of parents) { + if (rootnode.findChild('id', node.data.id, true)) { + lastsel = node; + break; + } + } + me.selectById(lastsel.data.id); + } else if (lastsel && reselect) { + me.selectById(lastsel.data.id); + } + + if (firstUpdate) { + me.store.setRoot(rootnode); + firstUpdate = false; + } + + // on first tree load set the selection from the stateful provider + if (!pdata.updateCount) { + rootnode.expand(); + me.applyState(sp.get(stateid)); + } + + pdata.updateCount++; + }; + + sp.on('statechange', (_sp, key, value) => { + if (key === stateid) { + me.applyState(value); + } + }); + + Ext.apply(me, { + allowSelection: true, + store: store, + viewConfig: { + animate: false, // note: animate cause problems with applyState + }, + listeners: { + itemcontextmenu: PVE.Utils.createCmdMenu, + destroy: function () { + rstore.un('load', updateTree); + }, + beforecellmousedown: function (tree, td, cellIndex, record, tr, rowIndex, ev) { + let sm = me.getSelectionModel(); + // disable selection when right clicking except if the record is already selected + me.allowSelection = ev.button !== 2 || sm.isSelected(record); + }, + beforeselect: function (tree, record, index, eopts) { + let allow = me.allowSelection; + me.allowSelection = true; + return allow; + }, + itemdblclick: PVE.Utils.openTreeConsole, + afterrender: function () { + if (me.tip) { + return; + } + let selectors = [ + '.x-tree-node-text > span:not(.proxmox-tag-dark):not(.proxmox-tag-light)', + '.x-tree-icon', + ]; + me.tip = Ext.create('Ext.tip.ToolTip', { + target: me.el, + delegate: selectors.join(', '), + trackMouse: true, + renderTo: Ext.getBody(), + listeners: { + beforeshow: function (tip) { + let rec = me.getView().getRecord(tip.triggerElement); + let tipText = me.getToolTip(rec.data); + if (tipText) { + tip.update(tipText); + return true; + } + return false; + }, + }, + }); + }, + }, + setViewFilter: function (view) { + me.viewFilter = view; + me.clearTree(); + updateTree(); + }, + clearTree: function () { + pdata.updateCount = 0; + let rootnode = me.store.getRootNode(); + rootnode.collapse(); + rootnode.removeAll(); + pdata.dataIndex = {}; + me.getSelectionModel().deselectAll(); + }, + selectExpand: function (node) { + let sm = me.getSelectionModel(); + if (!sm.isSelected(node)) { + sm.select(node); + for (let iter = node; iter; iter = iter.parentNode) { + if (!iter.isExpanded()) { + iter.expand(); + } + } + me.getView().focusRow(node); + } + }, + selectById: function (nodeid) { + let rootnode = me.store.getRootNode(); + let node; + if (nodeid === 'root') { + node = rootnode; + } else { + node = findNode(rootnode, nodeid); + } + if (node) { + me.selectExpand(node); + } + return node; + }, + applyState: function (state) { + if (state && state.value) { + me.selectById(state.value); + } else { + me.getSelectionModel().deselectAll(); + } + }, + }); + + me.callParent(); + + me.getSelectionModel().on('select', (_sm, n) => sp.set(stateid, { value: n.data.id })); + + rstore.on('load', updateTree); + rstore.startUpdate(); + + me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => { + me.store.getRootNode().cascadeBy({ + before: function (node) { + if (node.data.groupbyid) { + node.beginEdit(); + let info = node.data; + me.setIconCls(info); + if (me.viewFilter.groupRenderer) { + info.text = me.viewFilter.groupRenderer(info); + } + node.commit(); + } + return true; + }, + }); + }); + }, +}); +Ext.define('PVE.guest.SnapshotTree', { + extend: 'Ext.tree.Panel', + xtype: 'pveGuestSnapshotTree', + + stateful: true, + stateId: 'grid-snapshots', + + viewModel: { + data: { + // should be 'qemu' or 'lxc' + type: undefined, + nodename: undefined, + vmid: undefined, + vmname: undefined, + snapshotAllowed: false, + rollbackAllowed: false, + snapshotFeature: false, + running: false, + selected: '', + load_delay: 3000, + }, + formulas: { + canSnapshot: (get) => get('snapshotAllowed') && get('snapshotFeature'), + canRollback: (get) => get('rollbackAllowed') && get('isSnapshot'), + canRemove: (get) => get('snapshotAllowed') && get('isSnapshot'), + isSnapshot: (get) => get('selected') && get('selected') !== 'current', + buttonText: (get) => (get('snapshotAllowed') ? gettext('Edit') : gettext('View')), + showMemory: (get) => get('type') === 'qemu', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + newSnapshot: function () { + this.run_editor(false); + }, + + editSnapshot: function () { + this.run_editor(true); + }, + + run_editor: function (edit) { + let me = this; + let vm = me.getViewModel(); + let snapname; + if (edit) { + snapname = vm.get('selected'); + if (!snapname || snapname === 'current') { + return; + } + } + let win = Ext.create('PVE.window.Snapshot', { + nodename: vm.get('nodename'), + vmid: vm.get('vmid'), + vmname: vm.get('vmname'), + viewonly: !vm.get('snapshotAllowed'), + type: vm.get('type'), + isCreate: !edit, + submitText: !edit ? gettext('Take Snapshot') : undefined, + snapname: snapname, + running: vm.get('running'), + }); + win.show(); + me.mon(win, 'destroy', me.reload, me); + }, + + snapshotAction: function (action, method) { + let me = this; + let view = me.getView(); + let vm = me.getViewModel(); + let snapname = vm.get('selected'); + if (!snapname) { + return; + } + + let nodename = vm.get('nodename'); + let type = vm.get('type'); + let vmid = vm.get('vmid'); + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/${type}/${vmid}/snapshot/${snapname}/${action}`, + method: method, + waitMsgTarget: view, + callback: function () { + me.reload(); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, options) { + var upid = response.result.data; + var win = Ext.create('Proxmox.window.TaskProgress', { upid: upid }); + win.show(); + }, + }); + }, + + rollback: function () { + this.snapshotAction('rollback', 'POST'); + }, + remove: function () { + this.snapshotAction('', 'DELETE'); + }, + cancel: function () { + this.load_task.cancel(); + }, + + reload: function () { + let me = this; + let view = me.getView(); + let vm = me.getViewModel(); + let nodename = vm.get('nodename'); + let vmid = vm.get('vmid'); + let type = vm.get('type'); + let load_delay = vm.get('load_delay'); + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/${type}/${vmid}/snapshot`, + method: 'GET', + failure: function (response, opts) { + if (me.destroyed) { + return; + } + Proxmox.Utils.setErrorMask(view, response.htmlStatus); + me.load_task.delay(load_delay); + }, + success: function (response, opts) { + if (me.destroyed) { + // this is in a delayed task, avoid dragons if view has + // been destroyed already and go home. + return; + } + Proxmox.Utils.setErrorMask(view, false); + var digest = 'invalid'; + var idhash = {}; + var root = { name: '__root', expanded: true, children: [] }; + Ext.Array.each(response.result.data, function (item) { + item.leaf = true; + item.children = []; + if (item.name === 'current') { + vm.set('running', !!item.running); + digest = item.digest + item.running; + item.iconCls = PVE.Utils.get_object_icon_class(vm.get('type'), item); + } else { + item.iconCls = 'fa fa-fw fa-history x-fa-tree'; + } + idhash[item.name] = item; + }); + + if (digest !== me.old_digest) { + me.old_digest = digest; + + Ext.Array.each(response.result.data, function (item) { + if (item.parent && idhash[item.parent]) { + let parent_item = idhash[item.parent]; + parent_item.children.push(item); + parent_item.leaf = false; + parent_item.expanded = true; + parent_item.expandable = false; + } else { + root.children.push(item); + } + }); + + me.getView().setRootNode(root); + } + + me.load_task.delay(load_delay); + }, + }); + + // if we do not have the permissions, we don't have to check + // if we can create a snapshot, since the butten stays disabled + if (!vm.get('snapshotAllowed')) { + return; + } + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/${type}/${vmid}/feature`, + params: { feature: 'snapshot' }, + method: 'GET', + success: function (response, options) { + if (me.destroyed) { + // this is in a delayed task, the current view could been + // destroyed already; then we mustn't do viemodel set + return; + } + let res = response.result.data; + vm.set('snapshotFeature', !!res.hasFeature); + }, + }); + }, + + select: function (grid, val) { + let vm = this.getViewModel(); + if (val.length < 1) { + vm.set('selected', ''); + return; + } + vm.set('selected', val[0].data.name); + }, + + init: function (view) { + let me = this; + let vm = me.getViewModel(); + me.load_task = new Ext.util.DelayedTask(me.reload, me); + + if (!view.type) { + throw 'guest type not set'; + } + vm.set('type', view.type); + + if (!view.pveSelNode.data.node) { + throw 'no node name specified'; + } + vm.set('nodename', view.pveSelNode.data.node); + + if (!view.pveSelNode.data.vmid) { + throw 'no VM ID specified'; + } + vm.set('vmid', view.pveSelNode.data.vmid); + + vm.set('vmname', view.pveSelNode.data.name); + + let caps = Ext.state.Manager.get('GuiCap'); + vm.set('snapshotAllowed', !!caps.vms['VM.Snapshot']); + vm.set('rollbackAllowed', !!caps.vms['VM.Snapshot.Rollback']); + + view.getStore().sorters.add({ + property: 'order', + direction: 'ASC', + }); + + me.reload(); + }, + }, + + listeners: { + selectionchange: 'select', + itemdblclick: 'editSnapshot', + beforedestroy: 'cancel', + }, + + layout: 'fit', + rootVisible: false, + animate: false, + sortableColumns: false, + + tbar: [ + { + xtype: 'proxmoxButton', + text: gettext('Take Snapshot'), + disabled: true, + bind: { + disabled: '{!canSnapshot}', + }, + handler: 'newSnapshot', + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Rollback'), + disabled: true, + bind: { + disabled: '{!canRollback}', + }, + confirmMsg: function () { + let view = this.up('treepanel'); + let rec = view.getSelection()[0]; + let vmid = view.getViewModel().get('vmid'); + let vmname = view.getViewModel().get('vmname'); + let message = + PVE.Utils.formatGuestTaskConfirmation('qmrollback', vmid, vmname) + + ` '${rec.data.name}'? ${gettext('Current state will be lost.')}`; + return Ext.htmlEncode(message); + }, + handler: 'rollback', + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + bind: { + text: '{buttonText}', + disabled: '{!isSnapshot}', + }, + disabled: true, + edit: true, + handler: 'editSnapshot', + }, + { + xtype: 'proxmoxButton', + text: gettext('Remove'), + disabled: true, + dangerous: true, + bind: { + disabled: '{!canRemove}', + }, + confirmMsg: function () { + let view = this.up('treepanel'); + let { data } = view.getSelection()[0]; + return Ext.String.format( + gettext('Are you sure you want to remove entry {0}'), + `'${data.name}'`, + ); + }, + handler: 'remove', + }, + { + xtype: 'label', + text: gettext('The current guest configuration does not support taking new snapshots'), + hidden: true, + bind: { + hidden: '{canSnapshot}', + }, + }, + ], + + columnLines: true, + + fields: [ + 'name', + 'description', + 'snapstate', + 'vmstate', + 'running', + { name: 'snaptime', type: 'date', dateFormat: 'timestamp' }, + { + name: 'order', + calculate: function (data) { + return data.snaptime || (data.name === 'current' ? 'ZZZ' : data.snapstate); + }, + }, + ], + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Name'), + dataIndex: 'name', + width: 200, + renderer: (value, _, { data }) => (data.name !== 'current' ? value : gettext('NOW')), + }, + { + text: gettext('RAM'), + hidden: true, + bind: { + hidden: '{!showMemory}', + }, + align: 'center', + resizable: false, + dataIndex: 'vmstate', + width: 50, + renderer: (value, _, { data }) => + data.name !== 'current' ? Proxmox.Utils.format_boolean(value) : '', + }, + { + text: gettext('Date') + '/' + gettext('Status'), + dataIndex: 'snaptime', + width: 150, + renderer: function (value, metaData, record) { + if (record.data.snapstate) { + return record.data.snapstate; + } else if (value) { + return Ext.Date.format(value, 'Y-m-d H:i:s'); + } + return ''; + }, + }, + { + text: gettext('Description'), + dataIndex: 'description', + flex: 1, + renderer: function (value, metaData, record) { + if (record.data.name === 'current') { + return gettext('You are here!'); + } else { + return Ext.String.htmlEncode(value); + } + }, + }, + ], +}); +Ext.define('PVE.tree.ResourceMapTree', { + extend: 'Ext.tree.Panel', + alias: 'widget.pveResourceMapTree', + mixins: ['Proxmox.Mixin.CBind'], + + rootVisible: false, + + emptyText: gettext('No Mapping found'), + + // will be opened on edit + editWindowClass: undefined, + + // The base url of the resource + baseUrl: undefined, + + // icon class to show on the entries + mapIconCls: undefined, + + // if given, should be a function that takes a nodename and returns + // the url for getting the data to check the status + getStatusCheckUrl: undefined, + + // the result of above api call and the nodename is passed and can set the status + checkValidity: undefined, + + // the property that denotes a single map entry for a node + entryIdProperty: undefined, + + cbindData: function (initialConfig) { + let me = this; + const caps = Ext.state.Manager.get('GuiCap'); + me.canConfigure = !!caps.mapping['Mapping.Modify']; + + return {}; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + addMapping: function () { + let me = this; + let view = me.getView(); + Ext.create(view.editWindowClass, { + url: view.baseUrl, + autoShow: true, + listeners: { + destroy: () => me.load(), + }, + }); + }, + + add: function (_grid, _rI, _cI, _item, _e, rec) { + let me = this; + if (rec.data.type !== 'entry') { + return; + } + + me.openMapEditWindow(rec.data.name); + }, + + editDblClick: function () { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || selection.length < 1) { + return; + } + + me.edit(selection[0]); + }, + + editAction: function (_grid, _rI, _cI, _item, _e, rec) { + this.edit(rec); + }, + + edit: function (rec) { + let me = this; + if (rec.data.type === 'map') { + return; + } + + me.openMapEditWindow(rec.data.name, rec.data.node, rec.data.type === 'entry'); + }, + + openMapEditWindow: function (name, nodename, entryOnly) { + let me = this; + let view = me.getView(); + + Ext.create(view.editWindowClass, { + url: `${view.baseUrl}/${name}`, + autoShow: true, + autoLoad: true, + entryOnly, + nodename, + name, + listeners: { + destroy: () => me.load(), + }, + }); + }, + + remove: function (_grid, _rI, _cI, _item, _e, rec) { + let me = this; + let msg, id; + let view = me.getView(); + let confirmMsg; + switch (rec.data.type) { + case 'entry': + msg = gettext("Are you sure you want to remove '{0}'"); + confirmMsg = Ext.String.format(msg, rec.data.name); + break; + case 'node': + msg = gettext("Are you sure you want to remove '{0}' entries for '{1}'"); + confirmMsg = Ext.String.format(msg, rec.data.node, rec.data.name); + break; + case 'map': + msg = gettext("Are you sure you want to remove '{0}' on '{1}' for '{2}'"); + id = rec.data[view.entryIdProperty]; + confirmMsg = Ext.String.format(msg, id, rec.data.node, rec.data.name); + break; + default: + throw 'invalid type'; + } + Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function (btn) { + if (btn === 'yes') { + me.executeRemove(rec.data); + } + }); + }, + + executeRemove: function (data) { + let me = this; + let view = me.getView(); + + let url = `${view.baseUrl}/${data.name}`; + let method = 'PUT'; + let params = { + digest: me.lookup[data.name].digest, + }; + let map = me.lookup[data.name].map; + switch (data.type) { + case 'entry': + method = 'DELETE'; + params = undefined; + break; + case 'node': + params.map = PVE.Parser.filterPropertyStringList( + map, + (e) => e.node !== data.node, + ); + break; + case 'map': + params.map = PVE.Parser.filterPropertyStringList(map, (e) => + Object.entries(e).some(([key, value]) => data[key] !== value), + ); + break; + default: + throw 'invalid type'; + } + if (!params?.map.length) { + method = 'DELETE'; + params = undefined; + } + Proxmox.Utils.API2Request({ + url, + method, + params, + success: function () { + me.load(); + }, + }); + }, + + load: function () { + let me = this; + let view = me.getView(); + Proxmox.Utils.API2Request({ + url: view.baseUrl, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function ({ result: { data } }) { + let lookup = {}; + data.forEach((entry) => { + lookup[entry.id] = Ext.apply({}, entry); + entry.iconCls = 'fa fa-fw fa-folder-o'; + entry.name = entry.id; + entry.text = entry.id; + entry.type = 'entry'; + + let nodes = {}; + for (const map of entry.map) { + let parsed = PVE.Parser.parsePropertyString(map); + parsed.iconCls = view.mapIconCls; + parsed.leaf = true; + parsed.name = entry.id; + parsed.text = parsed[view.entryIdProperty]; + parsed.type = 'map'; + + if (nodes[parsed.node] === undefined) { + nodes[parsed.node] = { + children: [], + expanded: true, + iconCls: 'fa fa-fw fa-building-o', + leaf: false, + name: entry.id, + node: parsed.node, + text: parsed.node, + type: 'node', + }; + } + nodes[parsed.node].children.push(parsed); + } + delete entry.id; + entry.children = Object.values(nodes); + entry.leaf = entry.children.length === 0; + }); + me.lookup = lookup; + if (view.getStatusCheckUrl !== undefined && view.checkValidity !== undefined) { + me.loadStatusData(); + } + view.setRootNode({ + children: data, + }); + let root = view.getRootNode(); + root.expand(); + root.childNodes.forEach((node) => node.expand()); + }, + }); + }, + + nodeLoadingState: {}, + + loadStatusData: function () { + let me = this; + let view = me.getView(); + PVE.data.ResourceStore.getNodes().forEach(({ node }) => { + me.nodeLoadingState[node] = true; + let url = view.getStatusCheckUrl(node); + Proxmox.Utils.API2Request({ + url, + method: 'GET', + failure: function (response) { + me.nodeLoadingState[node] = false; + view.getRootNode()?.cascade(function (rec) { + if (rec.data.node !== node) { + return; + } + + rec.set('valid', 0); + rec.set('errmsg', response.htmlStatus); + rec.commit(); + }); + }, + success: function ({ result: { data } }) { + me.nodeLoadingState[node] = false; + view.checkValidity(data, node); + }, + }); + }); + }, + + renderStatus: function (value, _metadata, record) { + let me = this; + if (record.data.type !== 'map') { + return ''; + } + let iconCls; + let status; + if (value === undefined) { + if (me.nodeLoadingState[record.data.node]) { + iconCls = 'fa-spinner fa-spin'; + status = gettext('Loading...'); + } else { + iconCls = 'fa-question-circle'; + status = gettext('Unknown Node'); + } + } else { + let state = value ? 'good' : 'critical'; + iconCls = PVE.Utils.get_health_icon(state, true); + status = value + ? gettext('Mapping matches host data') + : record.data.errmsg || Proxmox.Utils.unknownText; + } + return ` ${status}`; + }, + + getAddClass: function (v, mD, rec) { + let cls = 'fa fa-plus-circle'; + if ( + rec.data.type !== 'entry' || + rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length + ) { + cls += ' pmx-action-hidden'; + } + return cls; + }, + + isAddDisabled: function (v, r, c, i, rec) { + return ( + rec.data.type !== 'entry' || + rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length + ); + }, + + init: function (view) { + let me = this; + + ['editWindowClass', 'baseUrl', 'mapIconCls', 'entryIdProperty'].forEach((property) => { + if (view[property] === undefined) { + throw `No ${property} defined`; + } + }); + + me.load(); + }, + }, + + store: { + sorters: 'text', + data: {}, + }, + + tbar: [ + { + text: gettext('Add'), + handler: 'addMapping', + cbind: { + disabled: '{!canConfigure}', + }, + }, + ], + + listeners: { + itemdblclick: 'editDblClick', + }, + + initComponent: function () { + let me = this; + + let columns = [...me.columns]; + columns.splice(1, 0, { + xtype: 'actioncolumn', + text: gettext('Actions'), + width: 80, + items: [ + { + getTip: (v, m, { data }) => + Ext.String.format(gettext("Add new host mapping for '{0}'"), data.name), + getClass: 'getAddClass', + isActionDisabled: 'isAddDisabled', + handler: 'add', + }, + { + iconCls: 'fa fa-pencil', + getTip: (v, m, { data }) => + data.type === 'entry' + ? Ext.String.format(gettext("Edit Mapping '{0}'"), data.name) + : Ext.String.format( + gettext("Edit Mapping '{0}' for '{1}'"), + data.name, + data.node, + ), + getClass: (v, m, { data }) => + data.type !== 'map' ? 'fa fa-pencil' : 'pmx-hidden', + isActionDisabled: (v, r, c, i, rec) => rec.data.type === 'map', + handler: 'editAction', + }, + { + iconCls: 'fa fa-trash-o', + getTip: (v, m, { data }) => + data.type === 'entry' + ? Ext.String.format(gettext("Remove '{0}'"), data.name) + : data.type === 'node' + ? Ext.String.format(gettext("Remove mapping for '{0}'"), data.node) + : Ext.String.format(gettext("Remove mapping '{0}'"), data.path), + handler: 'remove', + }, + ], + }); + me.columns = columns; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.DhcpTree', { + extend: 'Ext.tree.Panel', + xtype: 'pveDhcpTree', + + layout: 'fit', + rootVisible: false, + animate: false, + + store: { + sorters: ['ip', 'name'], + }, + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function () { + let me = this; + + Proxmox.Utils.API2Request({ + url: `/cluster/sdn/ipams/pve/status`, + method: 'GET', + success: function (response, opts) { + let root = { + name: '__root', + expanded: true, + children: [], + }; + + let zones = {}; + let vnets = {}; + let subnets = {}; + + response.result.data.forEach((element) => { + element.leaf = true; + + if (!(element.zone in zones)) { + let zone = { + name: element.zone, + type: 'zone', + iconCls: 'fa fa-th', + expanded: true, + children: [], + }; + + zones[element.zone] = zone; + root.children.push(zone); + } + + if (!(element.vnet in vnets)) { + let vnet = { + name: element.vnet, + zone: element.zone, + type: 'vnet', + iconCls: 'fa fa-network-wired x-fa-treepanel', + expanded: true, + children: [], + }; + + vnets[element.vnet] = vnet; + zones[element.zone].children.push(vnet); + } + + if (!(element.subnet in subnets)) { + let subnet = { + name: element.subnet, + zone: element.zone, + vnet: element.vnet, + type: 'subnet', + iconCls: 'x-tree-icon-none', + expanded: true, + children: [], + }; + + subnets[element.subnet] = subnet; + vnets[element.vnet].children.push(subnet); + } + + element.name = element.vmid; // for sorting + element.type = 'mapping'; + element.iconCls = 'x-tree-icon-none'; + subnets[element.subnet].children.push(element); + }); + + me.getView().setRootNode(root); + }, + }); + }, + + init: function (view) { + let me = this; + me.reload(); + }, + + onDelete: function (table, rI, cI, item, e, { data }) { + let me = this; + let view = me.getView(); + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: Ext.String.format( + gettext('Are you sure you want to remove DHCP mapping {0}'), + `${data.mac} / ${data.ip}`, + ), + buttons: Ext.Msg.YESNO, + defaultFocus: 'no', + callback: function (btn) { + if (btn !== 'yes') { + return; + } + + let params = { + zone: data.zone, + mac: data.mac, + ip: data.ip, + }; + + let encodedParams = Ext.Object.toQueryString(params); + + let url = `/cluster/sdn/vnets/${data.vnet}/ips?${encodedParams}`; + + Proxmox.Utils.API2Request({ + url, + method: 'DELETE', + waitMsgTarget: view, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: me.reload.bind(me), + }); + }, + }); + }, + + editAction: function (_grid, _rI, _cI, _item, _e, rec) { + this.edit(rec); + }, + + editDblClick: function () { + let me = this; + + let view = me.getView(); + let selection = view.getSelection(); + + if (!selection || selection.length < 1) { + return; + } + + me.edit(selection[0]); + }, + + edit: function (rec) { + let me = this; + + if (rec.data.type === 'mapping' && !rec.data.gateway) { + me.openEditWindow(rec.data); + } + }, + + openEditWindow: function (data) { + let me = this; + + let extraRequestParams = { + mac: data.mac, + zone: data.zone, + vnet: data.vnet, + }; + + if (data.vmid) { + extraRequestParams.vmid = data.vmid; + } + + Ext.create('PVE.sdn.IpamEdit', { + autoShow: true, + mapping: data, + extraRequestParams, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }, + + listeners: { + itemdblclick: 'editDblClick', + }, + + tbar: [ + { + xtype: 'proxmoxButton', + text: gettext('Reload'), + handler: 'reload', + }, + ], + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Name / VMID'), + dataIndex: 'name', + width: 200, + renderer: function (value, meta, record) { + if (record.get('gateway')) { + return gettext('Gateway'); + } + + return record.get('name') ?? record.get('vmid') ?? ' '; + }, + }, + { + text: gettext('IP Address'), + dataIndex: 'ip', + width: 200, + }, + { + text: 'MAC', + dataIndex: 'mac', + width: 200, + }, + { + text: gettext('Gateway'), + dataIndex: 'gateway', + width: 200, + }, + { + header: gettext('Actions'), + xtype: 'actioncolumn', + dataIndex: 'text', + width: 150, + items: [ + { + handler: function (table, rI, cI, item, e, { data }) { + let me = this; + + Ext.create('PVE.sdn.IpamEdit', { + autoShow: true, + mapping: {}, + isCreate: true, + extraRequestParams: { + vnet: data.name, + zone: data.zone, + }, + listeners: { + destroy: () => { + me.up('pveDhcpTree').controller.reload(); + }, + }, + }); + }, + getTip: (v, m, rec) => gettext('Add'), + getClass: (v, m, { data }) => { + if (data.type === 'vnet') { + return 'fa fa-plus-square'; + } + + return 'pmx-hidden'; + }, + }, + { + handler: 'editAction', + getTip: (v, m, rec) => gettext('Edit'), + getClass: (v, m, { data }) => { + if (data.type === 'mapping' && !data.gateway) { + return 'fa fa-pencil fa-fw'; + } + + return 'pmx-hidden'; + }, + }, + { + handler: 'onDelete', + getTip: (v, m, rec) => gettext('Delete'), + getClass: (v, m, { data }) => { + if (data.type === 'mapping' && !data.gateway) { + return 'fa critical fa-trash-o'; + } + + return 'pmx-hidden'; + }, + }, + ], + }, + ], +}); +Ext.define('PVE.window.Backup', { + extend: 'Ext.window.Window', + + resizable: false, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + if (!me.vmtype) { + throw 'no VM type specified'; + } + + let compressionSelector = Ext.create('PVE.form.BackupCompressionSelector', { + name: 'compress', + value: 'zstd', + fieldLabel: gettext('Compression'), + }); + + let modeSelector = Ext.create('PVE.form.BackupModeSelector', { + fieldLabel: gettext('Mode'), + value: 'snapshot', + name: 'mode', + }); + + let mailtoField = Ext.create('Ext.form.field.Text', { + fieldLabel: gettext('Send email to'), + name: 'mailto', + hidden: true, + emptyText: Proxmox.Utils.noneText, + }); + + let notificationModeSelector = Ext.create({ + xtype: 'proxmoxKVComboBox', + comboItems: [ + ['notification-system', gettext('Use global settings')], + // TRANSLATORS: sendmail is a piece of software + ['legacy-sendmail', gettext('Use sendmail (legacy)')], + ], + fieldLabel: gettext('Notification'), + name: 'notification-mode', + value: 'notification-system', + listeners: { + change: function (field, value) { + mailtoField.setHidden(value === 'notification-system'); + }, + }, + }); + + let pbsChangeDetectionModeSelector = Ext.create({ + xtype: 'proxmoxKVComboBox', + flex: 1, + disabled: true, + name: 'pbs-change-detection-mode', + deleteEmpty: true, + value: '__default__', + comboItems: [ + ['__default__', 'Default'], + ['data', 'Data'], + ['metadata', 'Metadata'], + ], + }); + + let pbsChangeDetection = Ext.create('Ext.form.FieldContainer', { + fieldLabel: gettext('PBS change detection mode'), + hidden: true, + layout: { + type: 'hbox', + align: 'center', + }, + items: [ + pbsChangeDetectionModeSelector, + { + xtype: 'box', + html: ``, + padding: 5, + }, + ], + }); + + const keepNames = [ + ['keep-last', gettext('Keep Last')], + ['keep-hourly', gettext('Keep Hourly')], + ['keep-daily', gettext('Keep Daily')], + ['keep-weekly', gettext('Keep Weekly')], + ['keep-monthly', gettext('Keep Monthly')], + ['keep-yearly', gettext('Keep Yearly')], + ]; + + let pruneSettings = keepNames.map((name) => + Ext.create('Ext.form.field.Display', { + name: name[0], + fieldLabel: name[1], + hidden: true, + }), + ); + + let removeCheckbox = Ext.create('Proxmox.form.Checkbox', { + name: 'remove', + checked: false, + hidden: true, + uncheckedValue: 0, + fieldLabel: gettext('Prune'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Prune older backups afterwards'), + }, + handler: function (checkbox, value) { + pruneSettings.forEach((field) => field.setHidden(!value)); + me.down('label[name="pruneLabel"]').setHidden(!value); + }, + }); + + let initialDefaults = false; + + var storagesel = Ext.create('PVE.form.StorageSelector', { + nodename: me.nodename, + name: 'storage', + fieldLabel: gettext('Storage'), + storageContent: 'backup', + allowBlank: false, + listeners: { + change: function (f, v) { + if (!initialDefaults) { + me.setLoading(false); + } + + if (v === null || v === undefined || v === '') { + return; + } + + let store = f.getStore(); + let rec = store.findRecord('storage', v, 0, false, true, true); + + if (rec && rec.data && rec.data.type === 'pbs') { + compressionSelector.setValue('zstd'); + compressionSelector.setDisabled(true); + if (me.vmtype === 'lxc') { + pbsChangeDetectionModeSelector.setValue('__default__'); + pbsChangeDetectionModeSelector.setDisabled(false); + pbsChangeDetection.setHidden(false); + } else { + pbsChangeDetectionModeSelector.setDisabled(true); + pbsChangeDetection.setHidden(true); + } + } else { + if (!compressionSelector.getEditable()) { + compressionSelector.setDisabled(false); + } + pbsChangeDetectionModeSelector.setDisabled(true); + pbsChangeDetection.setHidden(true); + } + + Proxmox.Utils.API2Request({ + url: `/nodes/${me.nodename}/vzdump/defaults`, + method: 'GET', + params: { + storage: v, + }, + waitMsgTarget: me, + success: function (response, opts) { + const data = response.result.data; + + if (!initialDefaults) { + let notificationMode = data['notification-mode'] ?? 'auto'; + let mailto = data.mailto; + + if (notificationMode === 'auto' && mailto !== undefined) { + notificationMode = 'legacy-sendmail'; + } + if (notificationMode === 'auto' && mailto === undefined) { + notificationMode = 'notification-system'; + } + + notificationModeSelector.setValue(notificationMode); + if (mailto !== undefined) { + mailtoField.setValue(mailto); + } + } + if (!initialDefaults && data.mode !== undefined) { + modeSelector.setValue(data.mode); + } + if (!initialDefaults && (data['notes-template'] ?? false)) { + me.down('field[name=notes-template]').setValue( + PVE.Utils.unEscapeNotesTemplate(data['notes-template']), + ); + } + + initialDefaults = true; + + // always update storage dependent properties + if (data['prune-backups'] !== undefined) { + const keepParams = PVE.Parser.parsePropertyString( + data['prune-backups'], + ); + if (!keepParams['keep-all']) { + removeCheckbox.setHidden(false); + pruneSettings.forEach(function (field) { + const keep = keepParams[field.name]; + if (keep) { + field.setValue(keep); + } else { + field.reset(); + } + }); + return; + } + } + + // no defaults or keep-all=1 + removeCheckbox.setHidden(true); + removeCheckbox.setValue(false); + pruneSettings.forEach((field) => field.reset()); + }, + failure: function (response, opts) { + initialDefaults = true; + + removeCheckbox.setHidden(true); + removeCheckbox.setValue(false); + pruneSettings.forEach((field) => field.reset()); + + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }, + }); + + let protectedCheckbox = Ext.create('Proxmox.form.Checkbox', { + name: 'protected', + checked: false, + uncheckedValue: 0, + fieldLabel: gettext('Protected'), + // Tiny amount of padding to stop the UI from shifting + // when the 'mailto' field is shown. + padding: '0 0 1 0', + }); + + me.formPanel = Ext.create('Proxmox.panel.InputPanel', { + bodyPadding: 10, + border: false, + column1: [storagesel, modeSelector, protectedCheckbox, pbsChangeDetection], + column2: [compressionSelector, notificationModeSelector, mailtoField, removeCheckbox], + columnB: [ + { + xtype: 'textareafield', + name: 'notes-template', + fieldLabel: gettext('Notes'), + anchor: '100%', + value: '{{guestname}}', + }, + { + xtype: 'box', + style: { + margin: '8px 0px', + 'line-height': '1.5em', + }, + html: Ext.String.format( + gettext('Possible template variables are: {0}'), + PVE.Utils.notesTemplateVars.map((v) => `{{${v}}}`).join(', '), + ), + }, + { + xtype: 'label', + name: 'pruneLabel', + text: gettext('Storage Retention Configuration') + ':', + hidden: true, + }, + { + layout: 'hbox', + border: false, + defaults: { + border: false, + layout: 'anchor', + flex: 1, + }, + items: [ + { + padding: '0 10 0 0', + defaults: { + labelWidth: 110, + }, + items: [pruneSettings[0], pruneSettings[2], pruneSettings[4]], + }, + { + padding: '0 0 0 10', + defaults: { + labelWidth: 110, + }, + items: [pruneSettings[1], pruneSettings[3], pruneSettings[5]], + }, + ], + }, + ], + }); + + var submitBtn = Ext.create('Ext.Button', { + text: gettext('Backup'), + handler: function () { + var storage = storagesel.getValue(); + let values = me.formPanel.getValues(); + var params = { + storage: storage, + vmid: me.vmid, + mode: values.mode, + remove: values.remove, + }; + + if (values.mailto) { + params.mailto = values.mailto; + } + + if (values['notification-mode']) { + params['notification-mode'] = values['notification-mode']; + } + + if (values.compress) { + params.compress = values.compress; + } + + if (values.protected) { + params.protected = values.protected; + } + + if (values['pbs-change-detection-mode']) { + params['pbs-change-detection-mode'] = values['pbs-change-detection-mode']; + } + + if (values['notes-template']) { + params['notes-template'] = PVE.Utils.escapeNotesTemplate( + values['notes-template'], + ); + } + + Proxmox.Utils.API2Request({ + url: '/nodes/' + me.nodename + '/vzdump', + params: params, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + success: function (response, options) { + // close later so we reload the grid + // after the task has completed + me.hide(); + + var upid = response.result.data; + + var win = Ext.create('Proxmox.window.TaskViewer', { + upid: upid, + listeners: { + close: function () { + me.close(); + }, + }, + }); + win.show(); + }, + }); + }, + }); + + var helpBtn = Ext.create('Proxmox.button.Help', { + onlineHelp: 'chapter_vzdump', + listenToGlobalEvent: false, + hidden: false, + }); + + let guestTypeStr = me.vmtype === 'lxc' ? 'CT' : 'VM'; + let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier(me.vmid, me.vmname); + let title = `${gettext('Backup')} ${guestTypeStr} ${formattedGuestIdentifier}`; + + Ext.apply(me, { + title: title, + modal: true, + layout: 'auto', + border: false, + width: 600, + items: [me.formPanel], + buttons: [helpBtn, '->', submitBtn], + listeners: { + afterrender: function () { + /// cleared within the storage selector's change listener + me.setLoading(gettext('Please wait...')); + storagesel.setValue(me.storage); + }, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.window.BackupConfig', { + extend: 'Ext.window.Window', + title: gettext('Configuration'), + width: 600, + height: 400, + layout: 'fit', + modal: true, + items: { + xtype: 'component', + itemId: 'configtext', + autoScroll: true, + style: { + 'white-space': 'pre', + 'font-family': 'monospace', + padding: '5px', + }, + }, + + initComponent: function () { + var me = this; + + if (!me.volume) { + throw 'no volume specified'; + } + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.callParent(); + + Proxmox.Utils.API2Request({ + url: '/nodes/' + nodename + '/vzdump/extractconfig', + method: 'GET', + params: { + volume: me.volume, + }, + failure: function (response, opts) { + me.close(); + Ext.Msg.alert('Error', response.htmlStatus); + }, + success: function (response, options) { + me.show(); + me.down('#configtext').update(Ext.htmlEncode(response.result.data)); + }, + }); + }, +}); +Ext.define('PVE.window.BulkAction', { + extend: 'Ext.window.Window', + + resizable: true, + width: 800, + height: 600, + modal: true, + layout: { + type: 'fit', + }, + border: false, + + // the action to set, currently there are: `startall`, `migrateall`, `stopall`, `suspendall` + action: undefined, + + // if set to true, the 'vms' parameter will be sent as an array' + // necessary for the cluster-wide api call + vmsAsArray: false, + + submit: function (params) { + let me = this; + + let url; + if (me.nodename) { + url = `/nodes/${me.nodename}/${me.action}`; + } else { + url = `/cluster/bulk-action/guest/${me.action}`; + } + + if (me.vmsAsArray) { + params.vms = params.vms.split(/[,; ]/); + } + + Proxmox.Utils.API2Request({ + params: params, + url, + waitMsgTarget: me, + method: 'POST', + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + success: function ({ result }, options) { + Ext.create('Proxmox.window.TaskViewer', { + autoShow: true, + upid: result.data, + listeners: { + destroy: () => me.close(), + }, + }); + me.hide(); + }, + }); + }, + + initComponent: function () { + let me = this; + + if (!me.action) { + throw 'no action specified'; + } + if (!me.btnText) { + throw 'no button text specified'; + } + if (!me.title) { + throw 'no title specified'; + } + + let items = []; + if (me.action === 'migrateall' || me.action === 'migrate') { + let disallowedNodes = []; + if (me.nodename) { + disallowedNodes.push(me.nodename); + } + items.push( + { + xtype: 'fieldcontainer', + layout: 'hbox', + items: [ + { + flex: 1, + xtype: 'pveNodeSelector', + name: 'target', + disallowedNodes, + fieldLabel: gettext('Target node'), + labelWidth: 200, + allowBlank: false, + onlineValidator: true, + padding: '0 10 0 0', + }, + { + xtype: 'proxmoxintegerfield', + // TODO: change to newer max-worker spelling for PVE 10 + name: 'maxworkers', + minValue: 1, + maxValue: 64, + value: 1, + fieldLabel: gettext('Parallel jobs'), + allowBlank: false, + flex: 1, + }, + ], + }, + { + xtype: 'fieldcontainer', + layout: 'hbox', + items: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Allow local disk migration'), + name: 'with-local-disks', + labelWidth: 200, + checked: true, + uncheckedValue: 0, + flex: 1, + padding: '0 10 0 0', + }, + { + itemId: 'lxcwarning', + xtype: 'displayfield', + userCls: 'pmx-hint', + value: 'Warning: Running CTs will be migrated in Restart Mode.', + hidden: true, // only visible if running container chosen + flex: 1, + }, + ], + }, + ); + if (me.action === 'migrate') { + items.push({ + xtype: 'hiddenfield', + name: 'online', + value: 1, + }); + } + } else if (me.action === 'startall') { + items.push({ + xtype: 'hiddenfield', + name: 'force', + value: 1, + }); + } else if (me.action === 'stopall' || me.action === 'shutdown') { + items.push({ + xtype: 'fieldcontainer', + layout: 'hbox', + items: [ + { + xtype: 'proxmoxcheckbox', + name: 'force-stop', + labelWidth: 120, + fieldLabel: gettext('Force Stop'), + boxLabel: gettext('Force stop guest if shutdown times out.'), + checked: true, + uncheckedValue: 0, + flex: 7, + }, + { + xtype: 'proxmoxintegerfield', + name: 'timeout', + fieldLabel: gettext('Timeout (s)'), + labelWidth: 120, + emptyText: '180', + minValue: 0, + maxValue: 7200, + allowBlank: true, + flex: 3, + }, + ], + }); + } + if (me.action !== 'migrateall' && me.action !== 'migrate') { + items.push({ + xtype: 'fieldcontainer', + layout: 'hbox', + items: [ + { + xtype: 'box', + flex: 7, + }, + { + xtype: 'proxmoxintegerfield', + name: 'max-workers', + minValue: 1, + maxValue: 64, + emptyText: 'auto', + fieldLabel: gettext('Parallel jobs'), + labelWidth: 120, + allowBlank: true, + flex: 3, + }, + ], + }); + } + + let refreshLxcWarning = function (vmids, records) { + let showWarning = records.some( + (item) => + vmids.includes(item.data.vmid) && + item.data.type === 'lxc' && + item.data.status === 'running', + ); + me.down('#lxcwarning').setVisible(showWarning); + }; + + let defaulStatusMap = { + migrateall: '', + migrate: '', + startall: 'stopped', + start: 'stopped', + stopall: 'running', + shutdown: 'running', + suspendall: 'running', + suspend: 'running', + }; + let defaultStatus = defaulStatusMap[me.action] ?? ''; + let defaultType = me.action === 'suspendall' ? 'qemu' : ''; + + let statusMap = []; + let poolMap = []; + let haMap = []; + let tagMap = []; + PVE.data.ResourceStore.each((rec) => { + if (['qemu', 'lxc'].indexOf(rec.data.type) !== -1) { + statusMap[rec.data.status] = true; + } + if (rec.data.type === 'pool') { + poolMap[rec.data.pool] = true; + } + if (rec.data.hastate !== '') { + haMap[rec.data.hastate] = true; + } + if (rec.data.tags !== '') { + rec.data.tags.split(/[,; ]/).forEach((tag) => { + if (tag !== '') { + tagMap[tag] = true; + } + }); + } + }); + + let statusList = Object.keys(statusMap).map((key) => [key, key]); + statusList.unshift(['', gettext('All')]); + let poolList = Object.keys(poolMap).map((key) => [key, key]); + let tagList = Object.keys(tagMap).map((key) => ({ value: key })); + let haList = Object.keys(haMap).map((key) => [key, key]); + + let clearFilters = function () { + me.down('#namefilter').setValue(''); + ['name', 'status', 'pool', 'type', 'hastate', 'includetag', 'excludetag'].forEach( + (filter) => { + me.down(`#${filter}filter`).setValue(''); + }, + ); + }; + + let filterChange = function () { + let nameValue = me.down('#namefilter').getValue(); + let filterCount = 0; + + if (nameValue !== '') { + filterCount++; + } + + let arrayFiltersData = []; + ['pool', 'hastate'].forEach((filter) => { + let selected = me.down(`#${filter}filter`).getValue() ?? []; + if (selected.length) { + filterCount++; + arrayFiltersData.push([filter, [...selected]]); + } + }); + + let singleFiltersData = []; + ['status', 'type'].forEach((filter) => { + let selected = me.down(`#${filter}filter`).getValue() ?? ''; + if (selected.length) { + filterCount++; + singleFiltersData.push([filter, selected]); + } + }); + + let includeTags = me.down('#includetagfilter').getValue() ?? []; + if (includeTags.length) { + filterCount++; + } + let excludeTags = me.down('#excludetagfilter').getValue() ?? []; + if (excludeTags.length) { + filterCount++; + } + + let fieldSet = me.down('#filters'); + let clearBtn = me.down('#clearBtn'); + if (filterCount) { + fieldSet.setTitle(Ext.String.format(gettext('Filters ({0})'), filterCount)); + clearBtn.setDisabled(false); + } else { + fieldSet.setTitle(gettext('Filters')); + clearBtn.setDisabled(true); + } + + let filterFn = function (value) { + let name = value.data.name.toLowerCase().indexOf(nameValue.toLowerCase()) !== -1; + let arrayFilters = arrayFiltersData.every( + ([filter, selected]) => + !selected.length || selected.indexOf(value.data[filter]) !== -1, + ); + let singleFilters = singleFiltersData.every( + ([filter, selected]) => + !selected.length || value.data[filter].indexOf(selected) !== -1, + ); + let tags = value.data.tags.split(/[;, ]/).filter((t) => !!t); + let includeFilter = + !includeTags.length || tags.some((tag) => includeTags.indexOf(tag) !== -1); + let excludeFilter = + !excludeTags.length || tags.every((tag) => excludeTags.indexOf(tag) === -1); + + return name && arrayFilters && singleFilters && includeFilter && excludeFilter; + }; + let vmselector = me.down('#vms'); + vmselector.getStore().setFilters({ + id: 'customFilter', + filterFn, + }); + vmselector.checkChange(); + if (me.action === 'migrateall') { + let records = vmselector.getSelection(); + refreshLxcWarning(vmselector.getValue(), records); + } + }; + + items.push({ + xtype: 'fieldset', + itemId: 'filters', + collapsible: true, + title: gettext('Filters'), + layout: 'hbox', + items: [ + { + xtype: 'container', + flex: 1, + padding: 5, + layout: { + type: 'vbox', + align: 'stretch', + }, + defaults: { + listeners: { + change: filterChange, + }, + isFormField: false, + }, + items: [ + { + fieldLabel: gettext('Name'), + itemId: 'namefilter', + xtype: 'textfield', + }, + { + xtype: 'combobox', + itemId: 'statusfilter', + fieldLabel: gettext('Status'), + emptyText: gettext('All'), + editable: false, + value: defaultStatus, + store: statusList, + }, + { + xtype: 'combobox', + itemId: 'poolfilter', + fieldLabel: gettext('Pool'), + emptyText: gettext('All'), + editable: false, + multiSelect: true, + store: poolList, + }, + ], + }, + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + flex: 1, + padding: 5, + defaults: { + listeners: { + change: filterChange, + }, + isFormField: false, + }, + items: [ + { + xtype: 'combobox', + itemId: 'typefilter', + fieldLabel: gettext('Type'), + emptyText: gettext('All'), + editable: false, + value: defaultType, + store: [ + ['', gettext('All')], + ['lxc', gettext('CT')], + ['qemu', gettext('VM')], + ], + }, + { + xtype: 'proxmoxComboGrid', + itemId: 'includetagfilter', + fieldLabel: gettext('Include Tags'), + emptyText: gettext('All'), + editable: false, + multiSelect: true, + valueField: 'value', + displayField: 'value', + listConfig: { + userCls: 'proxmox-tags-full', + columns: [ + { + dataIndex: 'value', + flex: 1, + renderer: (value) => + PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), + }, + ], + }, + store: { + data: tagList, + }, + listeners: { + change: filterChange, + }, + }, + { + xtype: 'proxmoxComboGrid', + itemId: 'excludetagfilter', + fieldLabel: gettext('Exclude Tags'), + emptyText: gettext('None'), + multiSelect: true, + editable: false, + valueField: 'value', + displayField: 'value', + listConfig: { + userCls: 'proxmox-tags-full', + columns: [ + { + dataIndex: 'value', + flex: 1, + renderer: (value) => + PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), + }, + ], + }, + store: { + data: tagList, + }, + listeners: { + change: filterChange, + }, + }, + ], + }, + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + flex: 1, + padding: 5, + defaults: { + listeners: { + change: filterChange, + }, + isFormField: false, + }, + items: [ + { + xtype: 'combobox', + itemId: 'hastatefilter', + fieldLabel: gettext('HA status'), + emptyText: gettext('All'), + multiSelect: true, + editable: false, + store: haList, + listeners: { + change: filterChange, + }, + }, + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'end', + }, + items: [ + { + xtype: 'button', + itemId: 'clearBtn', + text: gettext('Clear Filters'), + disabled: true, + handler: clearFilters, + }, + ], + }, + ], + }, + ], + }); + + items.push({ + xtype: 'vmselector', + itemId: 'vms', + name: 'vms', + flex: 1, + height: 300, + selectAll: true, + allowBlank: false, + plugins: '', + nodename: me.nodename, + listeners: { + selectionchange: function (vmselector, records) { + if (me.action === 'migrateall') { + let vmids = me.down('#vms').getValue(); + refreshLxcWarning(vmids, records); + } + }, + }, + }); + + me.formPanel = Ext.create('Ext.form.Panel', { + bodyPadding: 10, + border: false, + layout: { + type: 'vbox', + align: 'stretch', + }, + fieldDefaults: { + anchor: '100%', + }, + items: items, + }); + + let form = me.formPanel.getForm(); + + let submitBtn = Ext.create('Ext.Button', { + text: me.btnText, + handler: function () { + form.isValid(); + me.submit(form.getValues()); + }, + }); + + Ext.apply(me, { + items: [me.formPanel], + buttons: [submitBtn], + }); + + me.callParent(); + + if (me.prefilterIncludeTag) { + me.down('#includetagfilter').setValue(me.prefilterIncludeTag); + } + + form.on('validitychange', function () { + let valid = form.isValid(); + submitBtn.setDisabled(!valid); + }); + form.isValid(); + + filterChange(); + }, +}); +Ext.define('PVE.ceph.Install', { + extend: 'Ext.window.Window', + xtype: 'pveCephInstallWindow', + mixins: ['Proxmox.Mixin.CBind'], + + width: 220, + header: false, + resizable: false, + draggable: false, + modal: true, + nodename: undefined, + shadow: false, + border: false, + bodyBorder: false, + closable: false, + cls: 'install-mask', + bodyCls: 'install-mask', + layout: { + align: 'stretch', + pack: 'center', + type: 'vbox', + }, + viewModel: { + data: { + isInstalled: false, + }, + formulas: { + buttonText: function (get) { + if (get('isInstalled')) { + return gettext('Configure Ceph'); + } else { + return gettext('Install Ceph'); + } + }, + windowText: function (get) { + if (get('isInstalled')) { + return `

+ ${gettext('Ceph is not initialized.')} + ${gettext('You need to create an initial config once.')}

`; + } else { + return ( + '

' + + gettext('Ceph is not installed on this node.') + + '
' + + gettext('Would you like to install it now?') + + '

' + ); + } + }, + }, + }, + items: [ + { + bind: { + html: '{windowText}', + }, + border: false, + padding: 5, + bodyCls: 'install-mask', + }, + { + xtype: 'button', + bind: { + text: '{buttonText}', + }, + viewModel: {}, + cbind: { + nodename: '{nodename}', + }, + handler: function () { + let view = this.up('pveCephInstallWindow'); + let wizard = Ext.create('PVE.ceph.CephInstallWizard', { + nodename: view.nodename, + }); + wizard.getViewModel().set('isInstalled', this.getViewModel().get('isInstalled')); + wizard.show(); + view.mon(wizard, 'beforeClose', function () { + view.fireEvent('cephInstallWindowClosed'); + view.close(); + }); + }, + }, + ], +}); +Ext.define('PVE.window.Clone', { + extend: 'Ext.window.Window', + + resizable: false, + + isTemplate: false, + + onlineHelp: 'qm_copy_and_clone', + + controller: { + xclass: 'Ext.app.ViewController', + control: { + 'panel[reference=cloneform]': { + validitychange: 'disableSubmit', + }, + }, + disableSubmit: function (form) { + this.lookupReference('submitBtn').setDisabled(!form.isValid()); + }, + }, + + statics: { + // display a snapshot selector only if needed + wrap: function (nodename, vmid, vmname, isTemplate, guestType) { + Proxmox.Utils.API2Request({ + url: '/nodes/' + nodename + '/' + guestType + '/' + vmid + '/snapshot', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + success: function (response, opts) { + var snapshotList = response.result.data; + var hasSnapshots = !( + snapshotList.length === 1 && snapshotList[0].name === 'current' + ); + + Ext.create('PVE.window.Clone', { + nodename: nodename, + guestType: guestType, + vmid: vmid, + vmname: vmname, + isTemplate: isTemplate, + hasSnapshots: hasSnapshots, + }).show(); + }, + }); + }, + }, + + create_clone: function (values) { + var me = this; + + var params = { newid: values.newvmid }; + + if (values.snapname && values.snapname !== 'current') { + params.snapname = values.snapname; + } + + if (values.pool) { + params.pool = values.pool; + } + + if (values.name) { + if (me.guestType === 'lxc') { + params.hostname = values.name; + } else { + params.name = values.name; + } + } + + if (values.target) { + params.target = values.target; + } + + if (values.clonemode === 'copy') { + params.full = 1; + if (values.hdstorage) { + params.storage = values.hdstorage; + if (values.diskformat && me.guestType !== 'lxc') { + params.format = values.diskformat; + } + } + } + + Proxmox.Utils.API2Request({ + params: params, + url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/clone', + waitMsgTarget: me, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + success: function (response, options) { + me.close(); + }, + }); + }, + + // disable the Storage selector when clone mode is linked clone + updateVisibility: function () { + var me = this; + var clonemode = me.lookupReference('clonemodesel').getValue(); + var disksel = me.lookup('diskselector'); + disksel.setDisabled(clonemode === 'clone'); + }, + + // add to the list of valid nodes each node where + // all the VM disks are available + verifyFeature: function () { + var me = this; + + var snapname = me.lookupReference('snapshotsel').getValue(); + var clonemode = me.lookupReference('clonemodesel').getValue(); + + var params = { feature: clonemode }; + if (snapname !== 'current') { + params.snapname = snapname; + } + + Proxmox.Utils.API2Request({ + waitMsgTarget: me, + url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/feature', + params: params, + method: 'GET', + failure: function (response, opts) { + me.lookupReference('submitBtn').setDisabled(true); + Ext.Msg.alert('Error', response.htmlStatus); + }, + success: function (response, options) { + var res = response.result.data; + + me.lookupReference('targetsel').allowedNodes = res.nodes; + me.lookupReference('targetsel').validate(); + }, + }); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + if (!me.snapname) { + me.snapname = 'current'; + } + + if (!me.guestType) { + throw 'no Guest Type specified'; + } + + var titletext = me.guestType === 'lxc' ? 'CT' : 'VM'; + if (me.isTemplate) { + titletext += ' Template'; + } + + let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier(me.vmid, me.vmname); + me.title = `Clone ${titletext} ${formattedGuestIdentifier}`; + + var col1 = []; + var col2 = []; + + col1.push({ + xtype: 'pveNodeSelector', + name: 'target', + reference: 'targetsel', + fieldLabel: gettext('Target node'), + selectCurNode: true, + allowBlank: false, + onlineValidator: true, + listeners: { + change: function (f, value) { + me.lookup('diskselector').getComponent('hdstorage').setTargetNode(value); + }, + }, + }); + + var modelist = [['copy', gettext('Full Clone')]]; + if (me.isTemplate) { + modelist.push(['clone', gettext('Linked Clone')]); + } + + col1.push( + { + xtype: 'pveGuestIDSelector', + name: 'newvmid', + guestType: me.guestType, + value: '', + loadNextFreeID: true, + validateExists: false, + }, + { + xtype: 'textfield', + name: 'name', + vtype: 'DnsName', + allowBlank: true, + fieldLabel: me.guestType === 'lxc' ? gettext('Hostname') : gettext('Name'), + }, + { + xtype: 'pvePoolSelector', + fieldLabel: gettext('Resource Pool'), + name: 'pool', + value: '', + allowBlank: true, + }, + ); + + col2.push( + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Mode'), + name: 'clonemode', + reference: 'clonemodesel', + allowBlank: false, + hidden: !me.isTemplate, + value: me.isTemplate ? 'clone' : 'copy', + comboItems: modelist, + listeners: { + change: function (t, value) { + me.updateVisibility(); + me.verifyFeature(); + }, + }, + }, + { + xtype: 'PVE.form.SnapshotSelector', + name: 'snapname', + reference: 'snapshotsel', + fieldLabel: gettext('Snapshot'), + nodename: me.nodename, + guestType: me.guestType, + vmid: me.vmid, + hidden: !!(me.isTemplate || !me.hasSnapshots), + disabled: false, + allowBlank: false, + value: me.snapname, + listeners: { + change: function (f, value) { + me.verifyFeature(); + }, + }, + }, + { + xtype: 'pveDiskStorageSelector', + reference: 'diskselector', + nodename: me.nodename, + autoSelect: false, + hideSize: true, + hideSelection: true, + storageLabel: gettext('Target Storage'), + allowBlank: true, + storageContent: me.guestType === 'qemu' ? 'images' : 'rootdir', + emptyText: gettext('Same as source'), + disabled: !!me.isTemplate, // because default mode is clone for templates + }, + ); + + var formPanel = Ext.create('Ext.form.Panel', { + bodyPadding: 10, + reference: 'cloneform', + border: false, + layout: 'hbox', + defaultType: 'container', + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + items: [ + { + flex: 1, + padding: '0 10 0 0', + layout: 'anchor', + items: col1, + }, + { + flex: 1, + padding: '0 0 0 10', + layout: 'anchor', + items: col2, + }, + ], + }); + + Ext.apply(me, { + modal: true, + width: 600, + height: 250, + border: false, + layout: 'fit', + buttons: [ + { + xtype: 'proxmoxHelpButton', + listenToGlobalEvent: false, + hidden: false, + onlineHelp: me.onlineHelp, + }, + '->', + { + reference: 'submitBtn', + text: gettext('Clone'), + disabled: true, + handler: function () { + var cloneForm = me.lookupReference('cloneform'); + if (cloneForm.isValid()) { + me.create_clone(cloneForm.getValues()); + } + }, + }, + ], + items: [formPanel], + }); + + me.callParent(); + + me.verifyFeature(); + }, +}); +/* + * ConfirmRemoveDialog window with additional checkboxes for removing resources + */ +Ext.define('PVE.window.ConfirmRemoveResource', { + extend: 'Proxmox.window.ConfirmRemoveDialog', + alias: 'widget.pveConfirmRemoveResource', + + additionalItems: [ + { + xtype: 'proxmoxcheckbox', + name: 'purge', + reference: 'purgeCheckbox', + boxLabel: gettext('Purge resource from referenced HA rules'), + padding: '5 0 0 0', + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Also removes resource from HA rules and removes rule if there are no other resources in it', + ), + }, + }, + ], + + getText: function () { + let me = this; + + me.text = `Are you sure you want to remove resource '${me.getItem().id}'?`; + + return me.callParent(); + }, + + getParams: function () { + let me = this; + + const purgeCheckbox = me.lookupReference('purgeCheckbox'); + me.params.purge = purgeCheckbox.checked ? 1 : 0; + + return me.callParent(); + }, +}); +Ext.define('PVE.FirewallEnableEdit', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveFirewallEnableEdit'], + mixins: ['Proxmox.Mixin.CBind'], + + subject: gettext('Firewall'), + cbindData: { + defaultValue: 0, + }, + width: 350, + + items: [ + { + xtype: 'proxmoxcheckbox', + name: 'enable', + uncheckedValue: 0, + cbind: { + defaultValue: '{defaultValue}', + checked: '{defaultValue}', + }, + deleteDefaultValue: false, + fieldLabel: gettext('Firewall'), + }, + { + xtype: 'displayfield', + name: 'warning', + userCls: 'pmx-hint', + value: gettext('Warning: Firewall still disabled at datacenter level!'), + hidden: true, + }, + ], + + beforeShow: function () { + var me = this; + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/cluster/firewall/options', + method: 'GET', + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, opts) { + if (!response.result.data.enable) { + me.down('displayfield[name=warning]').setVisible(true); + } + }, + }); + }, +}); +Ext.define('PVE.FirewallLograteInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveFirewallLograteInputPanel', + + viewModel: {}, + + items: [ + { + xtype: 'proxmoxcheckbox', + name: 'enable', + reference: 'enable', + fieldLabel: gettext('Enable'), + value: true, + }, + { + layout: 'hbox', + border: false, + items: [ + { + xtype: 'numberfield', + name: 'rate', + fieldLabel: gettext('Log rate limit'), + minValue: 1, + maxValue: 99, + allowBlank: false, + flex: 2, + value: 1, + }, + { + xtype: 'box', + html: '
/
', + }, + { + xtype: 'proxmoxKVComboBox', + name: 'unit', + comboItems: [ + ['second', 'second'], + ['minute', 'minute'], + ['hour', 'hour'], + ['day', 'day'], + ], + allowBlank: false, + flex: 1, + value: 'second', + }, + ], + }, + { + xtype: 'numberfield', + name: 'burst', + fieldLabel: gettext('Log burst limit'), + minValue: 1, + maxValue: 99, + value: 5, + }, + ], + + onGetValues: function (values) { + let _me = this; + + let cfg = { + enable: values.enable !== undefined ? 1 : 0, + rate: values.rate + '/' + values.unit, + burst: values.burst, + }; + let properties = PVE.Parser.printPropertyString(cfg, undefined); + if (properties === '') { + return { delete: 'log_ratelimit' }; + } + return { log_ratelimit: properties }; + }, + + setValues: function (values) { + let me = this; + + let properties = {}; + if (values.log_ratelimit !== undefined) { + properties = PVE.Parser.parsePropertyString(values.log_ratelimit, 'enable'); + if (properties.rate) { + let matches = properties.rate.match(/^(\d+)\/(second|minute|hour|day)$/); + if (matches) { + properties.rate = matches[1]; + properties.unit = matches[2]; + } + } + } + me.callParent([properties]); + }, +}); + +Ext.define('PVE.FirewallLograteEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveFirewallLograteEdit', + + subject: gettext('Log rate limit'), + + items: [ + { + xtype: 'pveFirewallLograteInputPanel', + }, + ], + autoLoad: true, +}); +/*global u2f*/ +Ext.define('PVE.window.LoginWindow', { + extend: 'Ext.window.Window', + + viewModel: { + data: { + openid: false, + }, + formulas: { + button_text: function (get) { + if (get('openid') === true) { + return gettext('Login (OpenID redirect)'); + } else { + return gettext('Login'); + } + }, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + init: async function () { + if (Proxmox.ConsentText) { + let oidc_auth_redirect = Proxmox.Utils.getOpenIDRedirectionAuthorization(); + if (oidc_auth_redirect === undefined) { + Ext.create('Proxmox.window.ConsentModal', { + autoShow: true, + consent: Proxmox.Markdown.parse( + Proxmox.Utils.base64ToUtf8(Proxmox.ConsentText), + ), + }); + } + } + }, + + onLogon: async function () { + var me = this; + + var form = this.lookupReference('loginForm'); + var unField = this.lookupReference('usernameField'); + var saveunField = this.lookupReference('saveunField'); + var view = this.getView(); + + if (!form.isValid()) { + return; + } + + let creds = form.getValues(); + + if (this.getViewModel().data.openid === true) { + const redirectURL = location.origin; + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/openid/auth-url', + params: { + realm: creds.realm, + 'redirect-url': redirectURL, + }, + method: 'POST', + success: function (resp, opts) { + window.location = resp.result.data; + }, + failure: function (resp, opts) { + Proxmox.Utils.authClear(); + form.unmask(); + Ext.MessageBox.alert( + gettext('Error'), + gettext('OpenID redirect failed.') + `
${resp.htmlStatus}`, + ); + }, + }); + return; + } + + view.el.mask(gettext('Please wait...'), 'x-mask-loading'); + + // set or clear username + var sp = Ext.state.Manager.getProvider(); + if (saveunField.getValue() === true) { + sp.set(unField.getStateId(), unField.getValue()); + } else { + sp.clear(unField.getStateId()); + } + sp.set(saveunField.getStateId(), saveunField.getValue()); + + try { + // Request updated authentication mechanism: + creds['new-format'] = 1; + + let resp = await Proxmox.Async.api2({ + url: '/api2/extjs/access/ticket', + params: creds, + method: 'POST', + }); + + let data = resp.result.data; + if (data.ticket.startsWith('PVE:!tfa!')) { + // Store first factor login information first: + data.LoggedOut = true; + Proxmox.Utils.setAuthData(data); + + data = await me.performTFAChallenge(data); + + // Fill in what we copy over from the 1st factor: + data.CSRFPreventionToken = Proxmox.CSRFPreventionToken; + data.username = Proxmox.UserName; + me.success(data); + } else if (Ext.isDefined(data.NeedTFA)) { + // Store first factor login information first: + data.LoggedOut = true; + Proxmox.Utils.setAuthData(data); + + if (Ext.isDefined(data.U2FChallenge)) { + me.perform_u2f(data); + } else { + me.perform_otp(); + } + } else { + me.success(data); + } + } catch (error) { + me.failure(error); + } + }, + + /* START NEW TFA CODE (pbs copy) */ + performTFAChallenge: async function (data) { + let _me = this; + + let userid = data.username; + let ticket = data.ticket; + let challenge = JSON.parse( + decodeURIComponent(ticket.split(':')[1].slice('!tfa!'.length)), + ); + + let resp = await new Promise((resolve, reject) => { + Ext.create('Proxmox.window.TfaLoginWindow', { + userid, + ticket, + challenge, + onResolve: (value) => resolve(value), + onReject: reject, + }).show(); + }); + + return resp.result.data; + }, + /* END NEW TFA CODE (pbs copy) */ + + failure: function (resp) { + var me = this; + var view = me.getView(); + view.el.unmask(); + var handler = function () { + var uf = me.lookupReference('usernameField'); + uf.focus(true, true); + }; + + let emsg = gettext('Login failed. Please try again'); + if (resp.status) { + emsg = Ext.String.format( + '{0}
{1}
{2}', + gettext('Login failed:'), + resp.htmlStatus, + gettext('Please try again'), + ); + } + + if (resp.failureType === 'connect') { + emsg = gettext( + 'Connection failure. Network error or Proxmox VE services not running?', + ); + } + + Ext.MessageBox.alert(gettext('Error'), emsg, handler); + }, + success: function (data) { + var me = this; + var view = me.getView(); + var handler = view.handler || Ext.emptyFn; + handler.call(me, data); + view.close(); + }, + + perform_otp: function () { + var me = this; + var win = Ext.create('PVE.window.TFALoginWindow', { + onLogin: function (value) { + me.finish_tfa(value); + }, + onCancel: function () { + Proxmox.LoggedOut = false; + Proxmox.Utils.authClear(); + me.getView().show(); + }, + }); + win.show(); + }, + + perform_u2f: function (data) { + var me = this; + // Show the message: + var msg = Ext.Msg.show({ + title: 'U2F: ' + gettext('Verification'), + message: gettext('Please press the button on your U2F Device'), + buttons: [], + }); + var chlg = data.U2FChallenge; + var key = { + version: chlg.version, + keyHandle: chlg.keyHandle, + }; + u2f.sign(chlg.appId, chlg.challenge, [key], function (res) { + msg.close(); + if (res.errorCode) { + Proxmox.Utils.authClear(); + Ext.Msg.alert(gettext('Error'), PVE.Utils.render_u2f_error(res.errorCode)); + return; + } + delete res.errorCode; + me.finish_tfa(JSON.stringify(res)); + }); + }, + finish_tfa: function (res) { + var me = this; + var view = me.getView(); + view.el.mask(gettext('Please wait...'), 'x-mask-loading'); + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/tfa', + params: { + response: res, + }, + method: 'POST', + timeout: 5000, // it'll delay both success & failure + success: function (resp, opts) { + view.el.unmask(); + // Fill in what we copy over from the 1st factor: + var data = resp.result.data; + data.CSRFPreventionToken = Proxmox.CSRFPreventionToken; + data.username = Proxmox.UserName; + // Finish logging in: + me.success(data); + }, + failure: function (resp, opts) { + Proxmox.Utils.authClear(); + me.failure(resp); + }, + }); + }, + + control: { + 'field[name=username]': { + specialkey: function (f, e) { + if (e.getKey() === e.ENTER) { + let pf = this.lookupReference('passwordField'); + if (!pf.getValue()) { + pf.focus(false); + } + } + }, + }, + 'field[name=lang]': { + change: function (f, value) { + var dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10); + Ext.util.Cookies.set('PVELangCookie', value, dt); + this.getView().mask(gettext('Please wait...'), 'x-mask-loading'); + window.location.reload(); + }, + }, + 'field[name=realm]': { + change: function (f, value) { + let record = f.store.getById(value); + if (record === undefined) { + return; + } + let data = record.data; + this.getViewModel().set('openid', data.type === 'openid'); + }, + }, + 'button[reference=loginButton]': { + click: 'onLogon', + }, + '#': { + show: function () { + var me = this; + + var sp = Ext.state.Manager.getProvider(); + var checkboxField = this.lookupReference('saveunField'); + var unField = this.lookupReference('usernameField'); + + var checked = sp.get(checkboxField.getStateId()); + checkboxField.setValue(checked); + + if (checked === true) { + let username = sp.get(unField.getStateId()); + unField.setValue(username); + let pwField = this.lookupReference('passwordField'); + pwField.focus(); + } + + let auth = Proxmox.Utils.getOpenIDRedirectionAuthorization(); + if (auth !== undefined) { + Proxmox.Utils.authClear(); + + let loginForm = this.lookupReference('loginForm'); + loginForm.mask(gettext('OpenID login - please wait...'), 'x-mask-loading'); + + const redirectURL = location.origin; + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/openid/login', + params: { + state: auth.state, + code: auth.code, + 'redirect-url': redirectURL, + }, + method: 'POST', + failure: function (response) { + loginForm.unmask(); + let error = response.htmlStatus; + Ext.MessageBox.alert( + gettext('Error'), + gettext('OpenID login failed, please try again') + + `
${error}`, + () => { + window.location = redirectURL; + }, + ); + }, + success: function (response, options) { + loginForm.unmask(); + let data = response.result.data; + history.replaceState(null, '', redirectURL); + me.success(data); + }, + }); + } + }, + }, + }, + }, + + width: 400, + modal: true, + border: false, + draggable: true, + closable: false, + resizable: false, + layout: 'auto', + + title: gettext('Proxmox VE Login'), + + defaultFocus: 'usernameField', + defaultButton: 'loginButton', + + items: [ + { + xtype: 'form', + layout: 'form', + url: '/api2/extjs/access/ticket', + reference: 'loginForm', + + fieldDefaults: { + labelAlign: 'right', + allowBlank: false, + }, + + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('User name'), + name: 'username', + itemId: 'usernameField', + reference: 'usernameField', + stateId: 'login-username', + inputAttrTpl: 'autocomplete=username', + bind: { + visible: '{!openid}', + disabled: '{openid}', + }, + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + name: 'password', + reference: 'passwordField', + inputAttrTpl: 'autocomplete=current-password', + bind: { + visible: '{!openid}', + disabled: '{openid}', + }, + }, + { + xtype: 'pmxRealmComboBox', + name: 'realm', + }, + { + xtype: 'proxmoxLanguageSelector', + fieldLabel: gettext('Language'), + value: PVE.Utils.getUiLanguage(), + name: 'lang', + reference: 'langField', + submitValue: false, + }, + ], + buttons: [ + { + xtype: 'checkbox', + fieldLabel: gettext('Save User name'), + name: 'saveusername', + reference: 'saveunField', + stateId: 'login-saveusername', + labelWidth: 250, + labelAlign: 'right', + submitValue: false, + bind: { + visible: '{!openid}', + }, + }, + { + bind: { + text: '{button_text}', + }, + reference: 'loginButton', + }, + ], + }, + ], +}); +Ext.define('PVE.window.Migrate', { + extend: 'Ext.window.Window', + + vmtype: undefined, + nodename: undefined, + vmid: undefined, + vmname: undefined, + maxHeight: 450, + + viewModel: { + data: { + vmid: undefined, + nodename: undefined, + vmtype: undefined, + running: false, + qemu: { + onlineHelp: 'qm_migration', + commonName: 'VM', + }, + lxc: { + onlineHelp: 'pct_migration', + commonName: 'CT', + }, + migration: { + possible: true, + preconditions: [], + 'with-local-disks': 0, + mode: undefined, + allowedNodes: undefined, + overwriteLocalResourceCheck: false, + hasLocalResources: false, + withConntrackState: true, + bothHaveDbusVmstate: false, + }, + }, + formulas: { + setMigrationMode: function (get) { + if (get('running')) { + if (get('vmtype') === 'qemu') { + return gettext('Online'); + } else { + return gettext('Restart Mode'); + } + } else { + return gettext('Offline'); + } + }, + setStorageselectorHidden: function (get) { + if (get('migration.with-local-disks') && get('running')) { + return false; + } else { + return true; + } + }, + setLocalResourceCheckboxHidden: function (get) { + if ( + get('running') || + !get('migration.hasLocalResources') || + Proxmox.UserName !== 'root@pam' + ) { + return true; + } else { + return false; + } + }, + conntrackStateCheckboxHidden: (get) => + !get('running') || + get('vmtype') !== 'qemu' || + !get('migration.bothHaveDbusVmstate'), + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + 'panel[reference=formPanel]': { + validityChange: function (panel, isValid) { + this.getViewModel().set('migration.possible', isValid); + this.checkMigratePreconditions(); + }, + }, + }, + + init: function (view) { + var me = this, + vm = view.getViewModel(); + + if (!view.nodename) { + throw 'missing custom view config: nodename'; + } + vm.set('nodename', view.nodename); + + if (!view.vmid) { + throw 'missing custom view config: vmid'; + } + vm.set('vmid', view.vmid); + + if (!view.vmtype) { + throw 'missing custom view config: vmtype'; + } + vm.set('vmtype', view.vmtype); + + let title = Ext.String.format( + '{0} {1} {2}', + gettext('Migrate'), + vm.get(view.vmtype).commonName, + PVE.Utils.getFormattedGuestIdentifier(view.vmid, view.vmname), + ); + view.setTitle(title); + + me.lookup('proxmoxHelpButton').setHelpConfig({ + onlineHelp: vm.get(view.vmtype).onlineHelp, + }); + me.lookup('formPanel').isValid(); + }, + + onTargetChange: function (nodeSelector) { + // Always display the storages of the currently selected migration target + this.lookup('pveDiskStorageSelector').setNodename(nodeSelector.value); + this.checkMigratePreconditions(true); + }, + + startMigration: function () { + var me = this, + view = me.getView(), + vm = me.getViewModel(); + + var values = me.lookup('formPanel').getValues(); + var params = { + target: values.target, + }; + + if (vm.get('migration.mode')) { + params[vm.get('migration.mode')] = 1; + } + if (vm.get('migration.with-local-disks')) { + params['with-local-disks'] = 1; + } + //offline migration to a different storage currently might fail at a late stage + //(i.e. after some disks have been moved), so don't expose it yet in the GUI + if (vm.get('migration.with-local-disks') && vm.get('running') && values.targetstorage) { + params.targetstorage = values.targetstorage; + } + + if (vm.get('migration.overwriteLocalResourceCheck')) { + params.force = 1; + } + + if (vm.get('migration.bothHaveDbusVmstate') && vm.get('migration.withConntrackState')) { + params['with-conntrack-state'] = 1; + } + + Proxmox.Utils.API2Request({ + params: params, + url: + '/nodes/' + + vm.get('nodename') + + '/' + + vm.get('vmtype') + + '/' + + vm.get('vmid') + + '/migrate', + waitMsgTarget: view, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus); + }, + success: function (response, options) { + var upid = response.result.data; + var extraTitle = Ext.String.format( + ' ({0} ---> {1})', + vm.get('nodename'), + params.target, + ); + + Ext.create('Proxmox.window.TaskViewer', { + upid: upid, + extraTitle: extraTitle, + }).show(); + + view.close(); + }, + }); + }, + + checkMigratePreconditions: async function (resetMigrationPossible) { + var me = this, + vm = me.getViewModel(); + + var vmrec = PVE.data.ResourceStore.findRecord( + 'vmid', + vm.get('vmid'), + 0, + false, + false, + true, + ); + if (vmrec && vmrec.data && vmrec.data.running) { + vm.set('running', true); + } + + me.lookup('pveNodeSelector').disallowedNodes = [vm.get('nodename')]; + + if (vm.get('vmtype') === 'qemu') { + await me.checkQemuPreconditions(resetMigrationPossible); + } else { + await me.checkLxcPreconditions(resetMigrationPossible); + } + + // Only allow nodes where the local storage is available in case of offline migration + // where storage migration is not possible + me.lookup('pveNodeSelector').allowedNodes = vm.get('migration.allowedNodes'); + + me.lookup('formPanel').isValid(); + }, + + checkQemuPreconditions: async function (resetMigrationPossible) { + let me = this, + vm = me.getViewModel(), + migrateStats; + + if (vm.get('running')) { + vm.set('migration.mode', 'online'); + } + + try { + if ( + me.fetchingNodeMigrateInfo && + me.fetchingNodeMigrateInfo === vm.get('nodename') + ) { + return; + } + me.fetchingNodeMigrateInfo = vm.get('nodename'); + let { result } = await Proxmox.Async.api2({ + url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`, + method: 'GET', + }); + migrateStats = result.data; + } catch (error) { + if (error?.result?.status !== 501) { + Ext.Msg.alert(Proxmox.Utils.errorText, error.htmlStatus); + } + me.fetchingNodeMigrateInfo = false; + return; + } + + const target = me.lookup('pveNodeSelector').value; + let targetCapabilities = {}; + + try { + const { result } = await Proxmox.Async.api2({ + url: `/nodes/${target}/capabilities/qemu/migration`, + method: 'GET', + }); + targetCapabilities = result.data; + } catch (err) { + // Only emit a warning in the case the target node does not (yet) support the + // `capabilites/qemu/migration` endpoint and simply treat all features as unsupported. + console.warn(`failed to query /capabilites/qemu/migration on '${target}':`, err); + } + + me.fetchingNodeMigrateInfo = false; + + if (migrateStats.running) { + vm.set('running', true); + } + // Get migration object from viewmodel to prevent to many bind callbacks + let migration = vm.get('migration'); + if (resetMigrationPossible) { + migration.possible = true; + } + migration.preconditions = []; + let disallowed = migrateStats.not_allowed_nodes?.[target] ?? {}; + + if (migrateStats.allowed_nodes && !vm.get('running')) { + migration.allowedNodes = migrateStats.allowed_nodes; + if (target.length && !migrateStats.allowed_nodes.includes(target)) { + if (disallowed.unavailable_storages !== undefined) { + let missingStorages = disallowed.unavailable_storages.join(', '); + const text = Ext.String.format( + gettext( + 'Storage(s) ({0}) not available on selected target. Start VM to use live storage migration or select other target node.', + ), + missingStorages, + ); + + migration.possible = false; + migration.preconditions.push({ text, severity: 'error' }); + } + } + } + + if (disallowed['unavailable-resources'] !== undefined) { + let unavailableResources = disallowed['unavailable-resources'].join(', '); + const text = Ext.String.format( + gettext('Mapped Resources ({0}) not available on selected target.'), + unavailableResources, + ); + + migration.possible = false; + migration.preconditions.push({ text, severity: 'error' }); + } + + let blockingResources = []; + let mappedResources = migrateStats['mapped-resource-info'] ?? {}; + + for (const res of migrateStats.local_resources) { + if (!mappedResources[res]) { + blockingResources.push(res); + } + } + + if (blockingResources.length) { + migration.hasLocalResources = true; + if (!migration.overwriteLocalResourceCheck || vm.get('running')) { + const text = Ext.String.format( + gettext('Cannot migrate VM with local resources: {0}'), + blockingResources.join(', '), + ); + + migration.possible = false; + migration.preconditions.push({ text, severity: 'error' }); + } else { + const text = Ext.String.format( + gettext( + 'Migrating VM with local resources: {0}. This might fail if the resources are not available on the target node.', + ), + blockingResources.join(', '), + ); + + migration.preconditions.push({ text, severity: 'warning' }); + } + } + + if (vm.get('running')) { + let allowed = []; + let notAllowed = []; + for (const [key, resource] of Object.entries(mappedResources)) { + if (resource['live-migration']) { + allowed.push(key); + } else { + notAllowed.push(key); + } + } + if (notAllowed.length > 0) { + const text = Ext.String.format( + gettext('Cannot migrate running VM with mapped resources: {0}'), + notAllowed.join(', '), + ); + + migration.possible = false; + migration.preconditions.push({ text, severity: 'error' }); + } else if (allowed.length > 0) { + const text = Ext.String.format( + gettext( + 'Live-migrating running VM with mapped resources (Experimental): {0}', + ), + allowed.join(', '), + ); + + migration.preconditions.push({ text, severity: 'warning' }); + } + } + + if (migrateStats.local_disks.length) { + migrateStats.local_disks.forEach(function (disk) { + if (disk.cdrom && disk.cdrom === 1) { + if (!disk.volid.includes('vm-' + vm.get('vmid') + '-cloudinit')) { + migration.possible = false; + migration.preconditions.push({ + text: gettext('Cannot migrate VM with local CD/DVD'), + severity: 'error', + }); + } + } else { + let size = disk.size + ? '(' + Proxmox.Utils.render_size(disk.size) + ')' + : ''; + const text = Ext.String.format( + gettext('Migration with local disk might take long: {0} {1}'), + disk.volid, + size, + ); + + migration['with-local-disks'] = 1; + migration.preconditions.push({ text, severity: 'warning' }); + } + }); + } + + migration.bothHaveDbusVmstate = + migrateStats['has-dbus-vmstate'] && targetCapabilities['has-dbus-vmstate']; + if (vm.get('running')) { + if (migration.withConntrackState && !migrateStats['has-dbus-vmstate']) { + migration.preconditions.push({ + text: gettext( + 'Cannot migrate conntrack state, source node is lacking support.', + ), + // user cannot really do anything about this, do not bother with scaring them! + severity: 'info', + }); + } + if (migration.withConntrackState && !targetCapabilities['has-dbus-vmstate']) { + migration.preconditions.push({ + text: gettext( + 'Cannot migrate conntrack state, target node is lacking support. Active network connections might get dropped.', + ), + severity: 'warning', + }); + } + + if (migration.bothHaveDbusVmstate && !migration.withConntrackState) { + migration.preconditions.push({ + text: gettext( + 'Conntrack state migration disabled. Active network connections might get dropped.', + ), + severity: 'warning', + }); + } + } + + let blockingHAResources = disallowed['blocking-ha-resources'] ?? []; + if (blockingHAResources.length) { + migration.possible = false; + + for (const { sid, cause } of blockingHAResources) { + let reasonText; + if (cause === 'resource-affinity') { + reasonText = Ext.String.format( + gettext( + 'HA resource {0} with negative affinity to VM on selected target node', + ), + sid, + ); + } else { + reasonText = Ext.String.format( + gettext('blocking HA resource {0} on selected target node'), + sid, + ); + } + + migration.preconditions.push({ + severity: 'error', + text: Ext.String.format( + gettext('Cannot migrate VM, because {0}.'), + reasonText, + ), + }); + } + } + + let dependentHAResources = migrateStats['dependent-ha-resources']; + if (dependentHAResources !== undefined) { + for (const sid of dependentHAResources) { + const text = Ext.String.format( + gettext( + 'HA resource {0} with positive affinity to VM is also migrated to selected target node.', + ), + sid, + ); + + migration.preconditions.push({ text, severity: 'warning' }); + } + } + + vm.set('migration', migration); + }, + checkLxcPreconditions: async function (resetMigrationPossible) { + let me = this; + let vm = me.getViewModel(); + let migrateStats; + + if (vm.get('running')) { + vm.set('migration.mode', 'restart'); + } + + try { + if ( + me.fetchingNodeMigrateInfo && + me.fetchingNodeMigrateInfo === vm.get('nodename') + ) { + return; + } + me.fetchingNodeMigrateInfo = vm.get('nodename'); + let { result } = await Proxmox.Async.api2({ + url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`, + method: 'GET', + }); + migrateStats = result.data; + me.fetchingNodeMigrateInfo = false; + } catch (error) { + if (error?.result?.status !== 501) { + Ext.Msg.alert(Proxmox.Utils.errorText, error.htmlStatus); + } + me.fetchingNodeMigrateInfo = false; + return; + } + + if (migrateStats.running) { + vm.set('running', true); + } + + // Get migration object from viewmodel to prevent to many bind callbacks + let migration = vm.get('migration'); + if (resetMigrationPossible) { + migration.possible = true; + } + migration.preconditions = []; + let targetNode = me.lookup('pveNodeSelector').value; + let disallowed = migrateStats['not-allowed-nodes']?.[targetNode] ?? {}; + + let blockingHAResources = disallowed['blocking-ha-resources'] ?? []; + if (blockingHAResources.length) { + migration.possible = false; + + for (const { sid, cause } of blockingHAResources) { + let reasonText; + if (cause === 'resource-affinity') { + reasonText = Ext.String.format( + gettext( + 'HA resource {0} with negative affinity to container on selected target node', + ), + sid, + ); + } else { + reasonText = Ext.String.format( + gettext('blocking HA resource {0} on selected target node'), + sid, + ); + } + + migration.preconditions.push({ + severity: 'error', + text: Ext.String.format( + gettext('Cannot migrate container, because {0}.'), + reasonText, + ), + }); + } + } + + let dependentHAResources = migrateStats['dependent-ha-resources']; + if (dependentHAResources !== undefined) { + for (const sid of dependentHAResources) { + const text = Ext.String.format( + gettext( + 'HA resource {0} with positive affinity to container is also migrated to selected target node.', + ), + sid, + ); + + migration.preconditions.push({ text, severity: 'warning' }); + } + } + + vm.set('migration', migration); + }, + }, + + width: 600, + modal: true, + layout: { + type: 'vbox', + align: 'stretch', + }, + border: false, + items: [ + { + xtype: 'form', + reference: 'formPanel', + bodyPadding: 10, + border: false, + layout: 'hbox', + items: [ + { + xtype: 'container', + flex: 1, + items: [ + { + xtype: 'displayfield', + name: 'source', + fieldLabel: gettext('Source node'), + bind: { + value: '{nodename}', + }, + }, + { + xtype: 'displayfield', + reference: 'migrationMode', + fieldLabel: gettext('Mode'), + bind: { + value: '{setMigrationMode}', + }, + }, + ], + }, + { + xtype: 'container', + flex: 1, + items: [ + { + xtype: 'pveNodeSelector', + reference: 'pveNodeSelector', + name: 'target', + fieldLabel: gettext('Target node'), + allowBlank: false, + disallowedNodes: undefined, + onlineValidator: true, + listeners: { + change: 'onTargetChange', + }, + }, + { + xtype: 'pveStorageSelector', + reference: 'pveDiskStorageSelector', + name: 'targetstorage', + fieldLabel: gettext('Target storage'), + storageContent: 'images', + allowBlank: true, + autoSelect: false, + emptyText: gettext('Current layout'), + bind: { + hidden: '{setStorageselectorHidden}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'overwriteLocalResourceCheck', + fieldLabel: gettext('Force'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Overwrite local resources unavailable check'), + }, + bind: { + hidden: '{setLocalResourceCheckboxHidden}', + value: '{migration.overwriteLocalResourceCheck}', + }, + listeners: { + change: { + fn: 'checkMigratePreconditions', + extraArg: true, + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'withConntrackState', + // TRANSLATORS: See https://www.kernel.org/doc/html/next/networking/netlink_spec/conntrack.html + fieldLabel: gettext('Conntrack state'), + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Enables live migration of conntrack entries for this VM.', + ), + }, + bind: { + hidden: '{conntrackStateCheckboxHidden}', + value: '{migration.withConntrackState}', + }, + listeners: { + change: { + fn: 'checkMigratePreconditions', + extraArg: true, + }, + }, + }, + ], + }, + ], + }, + { + xtype: 'gridpanel', + reference: 'preconditionGrid', + selectable: false, + flex: 1, + columns: [ + { + text: '', + dataIndex: 'severity', + renderer: function (v) { + switch (v) { + case 'warning': + return ' '; + case 'error': + return ''; + case 'info': + return ''; + default: + return v; + } + }, + width: 35, + }, + { + text: 'Info', + dataIndex: 'text', + cellWrap: true, + flex: 1, + }, + ], + bind: { + hidden: '{!migration.preconditions.length}', + store: { + fields: ['severity', 'text'], + data: '{migration.preconditions}', + sorters: 'text', + }, + }, + }, + ], + buttons: [ + { + xtype: 'proxmoxHelpButton', + reference: 'proxmoxHelpButton', + onlineHelp: 'pct_migration', + listenToGlobalEvent: false, + hidden: false, + }, + '->', + { + xtype: 'button', + reference: 'submitButton', + text: gettext('Migrate'), + handler: 'startMigration', + bind: { + disabled: '{!migration.possible}', + }, + }, + ], +}); +Ext.define('pve-prune-list', { + extend: 'Ext.data.Model', + fields: [ + 'type', + 'vmid', + { + name: 'ctime', + type: 'date', + dateFormat: 'timestamp', + }, + ], +}); + +Ext.define('PVE.PruneInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pvePruneInputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + onGetValues: function (values) { + let me = this; + + // the API expects a single prune-backups property string + let pruneBackups = PVE.Parser.printPropertyString(values); + values = { + 'prune-backups': pruneBackups, + type: me.backup_type, + vmid: me.backup_id, + }; + + return values; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + if (!view.url) { + throw 'no url specified'; + } + if (!view.backup_type) { + throw 'no backup_type specified'; + } + if (!view.backup_id) { + throw 'no backup_id specified'; + } + + this.reload(); // initial load + }, + + reload: function () { + let view = this.getView(); + + // helper to allow showing why a backup is kept + let addKeepReasons = function (backups, params) { + const rules = [ + 'keep-last', + 'keep-hourly', + 'keep-daily', + 'keep-weekly', + 'keep-monthly', + 'keep-yearly', + 'keep-all', // when all keep options are not set + ]; + let counter = {}; + + backups.sort((a, b) => b.ctime - a.ctime); + + let ruleIndex = -1; + let nextRule = function () { + let rule; + do { + ruleIndex++; + rule = rules[ruleIndex]; + } while (!params[rule] && rule !== 'keep-all'); + counter[rule] = 0; + return rule; + }; + + let rule = nextRule(); + for (let backup of backups) { + if (backup.mark === 'keep') { + counter[rule]++; + if (rule !== 'keep-all') { + backup.keepReason = rule + ': ' + counter[rule]; + if (counter[rule] >= params[rule]) { + rule = nextRule(); + } + } else { + backup.keepReason = rule; + } + } + } + }; + + let params = view.getValues(); + let keepParams = PVE.Parser.parsePropertyString(params['prune-backups']); + + Proxmox.Utils.API2Request({ + url: view.url, + method: 'GET', + params: params, + callback: function () { + // for easy breakpoint setting + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, options) { + var data = response.result.data; + addKeepReasons(data, keepParams); + view.pruneStore.setData(data); + }, + }); + }, + + control: { + field: { change: 'reload' }, + }, + }, + + column1: [ + { + xtype: 'pmxPruneKeepField', + name: 'keep-last', + fieldLabel: gettext('keep-last'), + }, + { + xtype: 'pmxPruneKeepField', + name: 'keep-hourly', + fieldLabel: gettext('keep-hourly'), + }, + { + xtype: 'pmxPruneKeepField', + name: 'keep-daily', + fieldLabel: gettext('keep-daily'), + }, + { + xtype: 'pmxPruneKeepField', + name: 'keep-weekly', + fieldLabel: gettext('keep-weekly'), + }, + { + xtype: 'pmxPruneKeepField', + name: 'keep-monthly', + fieldLabel: gettext('keep-monthly'), + }, + { + xtype: 'pmxPruneKeepField', + name: 'keep-yearly', + fieldLabel: gettext('keep-yearly'), + }, + ], + + initComponent: function () { + var me = this; + + me.pruneStore = Ext.create('Ext.data.Store', { + model: 'pve-prune-list', + sorters: { property: 'ctime', direction: 'DESC' }, + }); + + me.column2 = [ + { + xtype: 'grid', + height: 200, + store: me.pruneStore, + columns: [ + { + header: gettext('Backup Time'), + sortable: true, + dataIndex: 'ctime', + renderer: function (value, metaData, record) { + let text = Ext.Date.format(value, 'Y-m-d H:i:s'); + if (record.data.mark === 'remove') { + return ( + '
' + text + '
' + ); + } else { + return text; + } + }, + flex: 1, + }, + { + text: 'Keep (reason)', + dataIndex: 'mark', + renderer: function (value, metaData, record) { + if (record.data.mark === 'keep') { + return 'true (' + record.data.keepReason + ')'; + } else if (record.data.mark === 'protected') { + return 'true (protected)'; + } else if (record.data.mark === 'renamed') { + return 'true (renamed)'; + } else { + return 'false'; + } + }, + flex: 1, + }, + ], + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.window.Prune', { + extend: 'Proxmox.window.Edit', + + method: 'DELETE', + submitText: gettext('Prune'), + + fieldDefaults: { labelWidth: 130 }, + + isCreate: true, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no nodename specified'; + } + if (!me.storage) { + throw 'no storage specified'; + } + if (!me.backup_type) { + throw 'no backup_type specified'; + } + if (me.backup_type !== 'qemu' && me.backup_type !== 'lxc') { + throw 'unknown backup type: ' + me.backup_type; + } + if (!me.backup_id) { + throw 'no backup_id specified'; + } + + let title = Ext.String.format( + gettext("Prune Backups for '{0}' on Storage '{1}'"), + me.backup_type + '/' + me.backup_id, + me.storage, + ); + + Ext.apply(me, { + url: '/api2/extjs/nodes/' + me.nodename + '/storage/' + me.storage + '/prunebackups', + title: title, + items: [ + { + xtype: 'pvePruneInputPanel', + url: + '/api2/extjs/nodes/' + + me.nodename + + '/storage/' + + me.storage + + '/prunebackups', + backup_type: me.backup_type, + backup_id: me.backup_id, + storage: me.storage, + }, + ], + }); + + me.callParent(); + }, +}); +Ext.define('PVE.window.Restore', { + extend: 'Ext.window.Window', // fixme: Proxmox.window.Edit? + + resizable: false, + width: 500, + modal: true, + layout: 'auto', + border: false, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + '#liveRestore': { + change: function (el, newVal) { + let liveWarning = this.lookupReference('liveWarning'); + liveWarning.setHidden(!newVal); + let start = this.lookupReference('start'); + start.setDisabled(newVal); + }, + }, + form: { + validitychange: function (f, valid) { + this.lookupReference('doRestoreBtn').setDisabled(!valid); + }, + }, + }, + + doRestore: function () { + let me = this; + let view = me.getView(); + + let values = view.down('form').getForm().getValues(); + + let params = { + vmid: view.vmid || values.vmid, + force: view.vmid ? 1 : 0, + }; + if (values.unique) { + params.unique = 1; + } + if (values.start && !values['live-restore']) { + params.start = 1; + } + if (values['ha-managed']) { + params['ha-managed'] = 1; + } + if (values['live-restore']) { + params['live-restore'] = 1; + } + if (values.storage) { + params.storage = values.storage; + } + + ['bwlimit', 'cores', 'name', 'memory', 'sockets'].forEach((opt) => { + if ((values[opt] ?? '') !== '') { + params[opt] = values[opt]; + } + }); + + if (params.name && view.vmtype === 'lxc') { + params.hostname = params.name; + delete params.name; + } + + let taskDescription; + if (view.vmtype === 'lxc') { + params.ostemplate = view.volid; + params.restore = 1; + if (values.unprivileged !== 'keep') { + params.unprivileged = values.unprivileged; + } + taskDescription = Proxmox.Utils.format_task_description('vzrestore', params.vmid); + } else if (view.vmtype === 'qemu') { + params.archive = view.volid; + taskDescription = Proxmox.Utils.format_task_description('qmrestore', params.vmid); + } else { + throw 'unknown VM type'; + } + let confirmMsg = Ext.htmlEncode(taskDescription); + + let executeRestore = () => { + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/${view.vmtype}`, + params: params, + method: 'POST', + waitMsgTarget: view, + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function (response, options) { + Ext.create('Proxmox.window.TaskViewer', { + autoShow: true, + upid: response.result.data, + }); + view.close(); + }, + }); + }; + + if (view.vmid) { + if (view.vmtype === 'lxc') { + confirmMsg += `. ${gettext('This will permanently erase current CT data.')}`; + confirmMsg += `
${gettext('Mount point volumes are also erased.')}`; + } else { + confirmMsg += `. ${gettext('This will permanently erase current VM data.')}`; + } + Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function (btn) { + if (btn === 'yes') { + executeRestore(); + } + }); + } else { + executeRestore(); + } + }, + + afterRender: function () { + let view = this.getView(); + + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/vzdump/extractconfig`, + method: 'GET', + waitMsgTarget: view, + params: { + volume: view.volid, + }, + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + success: function (response, options) { + let allStoragesAvailable = true; + + response.result.data.split('\n').forEach((line) => { + let [_, key, value] = line.match(/^([^:]+):\s*(\S+)\s*$/) ?? []; + + if (!key) { + return; + } + + if (key === '#qmdump#map') { + let match = value.match(/^(\S+):(\S+):(\S*):(\S*):$/) ?? []; + // if a /dev/XYZ disk was backed up, there is no storage hint + allStoragesAvailable &&= + !!match[3] && + !!PVE.data.ResourceStore.getById( + `storage/${view.nodename}/${match[3]}`, + ); + } else if (key === 'name' || key === 'hostname') { + view.lookupReference('nameField').setEmptyText(value); + } else if (key === 'memory' || key === 'cores' || key === 'sockets') { + view.lookupReference(`${key}Field`).setEmptyText(value); + } + }); + + if (!allStoragesAvailable) { + let storagesel = view.down('pveStorageSelector[name=storage]'); + storagesel.allowBlank = false; + storagesel.setEmptyText(''); + } + }, + }); + }, + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + if (!me.volid) { + throw 'no volume ID specified'; + } + if (!me.vmtype) { + throw 'no vmtype specified'; + } + + let storagesel = Ext.create('PVE.form.StorageSelector', { + nodename: me.nodename, + name: 'storage', + value: '', + fieldLabel: gettext('Storage'), + storageContent: me.vmtype === 'lxc' ? 'rootdir' : 'images', + // when restoring a container without specifying a storage, the backend defaults + // to 'local', which is unintuitive and 'rootdir' might not even be allowed on it + allowBlank: me.vmtype !== 'lxc', + emptyText: me.vmtype === 'lxc' ? '' : gettext('From backup configuration'), + autoSelect: me.vmtype === 'lxc', + }); + + let items = [ + { + xtype: 'displayfield', + value: me.volidText || me.volid, + fieldLabel: gettext('Source'), + }, + storagesel, + { + xtype: 'pmxDisplayEditField', + name: 'vmid', + fieldLabel: me.vmtype === 'lxc' ? 'CT' : 'VM', + value: me.vmid, + editable: !me.vmid, + editConfig: { + xtype: 'pveGuestIDSelector', + guestType: me.vmtype, + loadNextFreeID: true, + validateExists: false, + }, + }, + { + xtype: 'pveBandwidthField', + name: 'bwlimit', + backendUnit: 'KiB', + allowZero: true, + fieldLabel: gettext('Bandwidth Limit'), + emptyText: gettext('Defaults to target storage restore limit'), + autoEl: { + tag: 'div', + 'data-qtip': gettext("Use '0' to disable all bandwidth limits."), + }, + }, + { + xtype: 'fieldcontainer', + layout: 'hbox', + items: [ + { + xtype: 'proxmoxcheckbox', + name: 'unique', + fieldLabel: gettext('Unique'), + flex: 1, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Autogenerate unique properties, e.g., MAC addresses', + ), + }, + checked: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'start', + reference: 'start', + flex: 1, + fieldLabel: gettext('Start after restore'), + labelWidth: 105, + checked: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'ha-managed', + reference: 'ha-managed', + flex: 1, + fieldLabel: gettext('Add to HA'), + labelWidth: 120, + checked: false, + }, + ], + }, + ]; + + if (me.vmtype === 'lxc') { + items.push({ + xtype: 'radiogroup', + fieldLabel: gettext('Privilege Level'), + reference: 'noVNCScalingGroup', + height: '15px', // renders faster with value assigned + layout: { + type: 'hbox', + algin: 'stretch', + }, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Choose if you want to keep or override the privilege level of the restored Container.', + ), + }, + items: [ + { + xtype: 'radiofield', + name: 'unprivileged', + inputValue: 'keep', + boxLabel: gettext('From Backup'), + flex: 1, + checked: true, + }, + { + xtype: 'radiofield', + name: 'unprivileged', + inputValue: '1', + boxLabel: gettext('Unprivileged'), + flex: 1, + }, + { + xtype: 'radiofield', + name: 'unprivileged', + inputValue: '0', + boxLabel: gettext('Privileged'), + flex: 1, + //margin: '0 0 0 10', + }, + ], + }); + } else if (me.vmtype === 'qemu') { + items.push( + { + xtype: 'proxmoxcheckbox', + name: 'live-restore', + itemId: 'liveRestore', + flex: 1, + fieldLabel: gettext('Live restore'), + checked: false, + hidden: !me.isPBS, + }, + { + xtype: 'displayfield', + reference: 'liveWarning', + // TODO: Remove once more tested/stable? + value: gettext( + 'Note: If anything goes wrong during the live-restore, new data written by the VM may be lost.', + ), + userCls: 'pmx-hint', + hidden: true, + }, + ); + } + + items.push({ + xtype: 'fieldset', + title: `${gettext('Override Settings')}:`, + layout: 'hbox', + defaults: { + border: false, + layout: 'anchor', + flex: 1, + }, + items: [ + { + padding: '0 10 0 0', + items: [ + { + xtype: 'textfield', + fieldLabel: me.vmtype === 'lxc' ? gettext('Hostname') : gettext('Name'), + name: 'name', + vtype: 'DnsName', + reference: 'nameField', + allowBlank: true, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('Cores'), + name: 'cores', + reference: 'coresField', + minValue: 1, + maxValue: 128, + allowBlank: true, + }, + ], + }, + { + padding: '0 0 0 10', + items: [ + { + xtype: 'pveMemoryField', + fieldLabel: gettext('Memory'), + name: 'memory', + reference: 'memoryField', + value: '', + allowBlank: true, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('Sockets'), + name: 'sockets', + reference: 'socketsField', + minValue: 1, + maxValue: 4, + allowBlank: true, + hidden: me.vmtype !== 'qemu', + disabled: me.vmtype !== 'qemu', + }, + ], + }, + ], + }); + + let title = gettext('Restore') + ': ' + (me.vmtype === 'lxc' ? 'CT' : 'VM'); + if (me.vmid) { + let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier( + me.vmid, + me.vmname, + ); + title = `${gettext('Overwrite')} ${title} ${formattedGuestIdentifier}`; + } + + Ext.apply(me, { + title: title, + items: [ + { + xtype: 'form', + bodyPadding: 10, + border: false, + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + items: items, + }, + ], + buttons: [ + { + text: gettext('Restore'), + reference: 'doRestoreBtn', + handler: 'doRestore', + }, + ], + }); + + me.callParent(); + }, +}); +/* + * SafeDestroy window with additional checkboxes for removing guests + */ +Ext.define('PVE.window.SafeDestroyGuest', { + extend: 'Proxmox.window.SafeDestroy', + alias: 'widget.pveSafeDestroyGuest', + + additionalItems: [ + { + xtype: 'proxmoxcheckbox', + name: 'purge', + reference: 'purgeCheckbox', + boxLabel: gettext('Purge from job configurations'), + checked: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Remove from replication, HA and backup jobs'), + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'destroyUnreferenced', + reference: 'destroyUnreferencedCheckbox', + boxLabel: gettext('Destroy unreferenced disks owned by guest'), + checked: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Scan all enabled storages for unreferenced disks and delete them.', + ), + }, + }, + ], + + note: gettext('Referenced disks will always be destroyed.'), + + getParams: function () { + let me = this; + + const purgeCheckbox = me.lookupReference('purgeCheckbox'); + me.params.purge = purgeCheckbox.checked ? 1 : 0; + + const destroyUnreferencedCheckbox = me.lookupReference('destroyUnreferencedCheckbox'); + me.params['destroy-unreferenced-disks'] = destroyUnreferencedCheckbox.checked ? 1 : 0; + + return me.callParent(); + }, +}); +/* + * SafeDestroy window with additional checkboxes for removing a storage on the disk level. + */ +Ext.define('PVE.window.SafeDestroyStorage', { + extend: 'Proxmox.window.SafeDestroy', + alias: 'widget.pveSafeDestroyStorage', + + showProgress: true, + + additionalItems: [ + { + xtype: 'proxmoxcheckbox', + name: 'wipeDisks', + reference: 'wipeDisksCheckbox', + boxLabel: gettext('Cleanup Disks'), + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Wipe labels and other left-overs'), + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'cleanupConfig', + reference: 'cleanupConfigCheckbox', + boxLabel: gettext('Cleanup Storage Configuration'), + checked: true, + }, + ], + + getParams: function () { + let me = this; + + me.params['cleanup-disks'] = me.lookupReference('wipeDisksCheckbox').checked ? 1 : 0; + me.params['cleanup-config'] = me.lookupReference('cleanupConfigCheckbox').checked ? 1 : 0; + + return me.callParent(); + }, +}); +Ext.define('PVE.window.Settings', { + extend: 'Ext.window.Window', + + width: '800px', + title: gettext('My Settings'), + iconCls: 'fa fa-gear', + modal: true, + bodyPadding: 10, + resizable: false, + + buttons: [ + { + xtype: 'proxmoxHelpButton', + onlineHelp: 'gui_my_settings', + hidden: false, + }, + '->', + { + text: gettext('Close'), + handler: function () { + this.up('window').close(); + }, + }, + ], + + layout: 'hbox', + + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + var me = this; + var sp = Ext.state.Manager.getProvider(); + + var username = sp.get('login-username') || Proxmox.Utils.noneText; + me.lookupReference('savedUserName').setValue(Ext.String.htmlEncode(username)); + var vncMode = sp.get('novnc-scaling') || 'auto'; + me.lookupReference('noVNCScalingGroup').setValue({ noVNCScalingField: vncMode }); + + let summarycolumns = sp.get('summarycolumns', 'auto'); + me.lookup('summarycolumns').setValue(summarycolumns); + + me.lookup('guestNotesCollapse').setValue(sp.get('guest-notes-collapse', 'never')); + me.lookup('editNotesOnDoubleClick').setValue( + sp.get('edit-notes-on-double-click', false), + ); + + var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight']; + settings.forEach(function (setting) { + var val = localStorage.getItem('pve-xterm-' + setting); + if (val !== undefined && val !== null) { + let field = me.lookup(setting); + field.setValue(val); + field.resetOriginalValue(); + } + }); + }, + + set_button_status: function () { + let me = this; + let form = me.lookup('xtermform'); + + let valid = form.isValid(), + dirty = form.isDirty(); + let hasValues = Object.values(form.getValues()).some((v) => !!v); + + me.lookup('xtermsave').setDisabled(!dirty || !valid); + me.lookup('xtermreset').setDisabled(!hasValues); + }, + + control: { + '#xtermjs form': { + dirtychange: 'set_button_status', + validitychange: 'set_button_status', + }, + '#xtermjs button': { + click: function (button) { + var me = this; + var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight']; + settings.forEach(function (setting) { + var field = me.lookup(setting); + if (button.reference === 'xtermsave') { + let value = field.getValue(); + if (value) { + localStorage.setItem('pve-xterm-' + setting, value); + } else { + localStorage.removeItem('pve-xterm-' + setting); + } + } else if (button.reference === 'xtermreset') { + field.setValue(undefined); + localStorage.removeItem('pve-xterm-' + setting); + } + field.resetOriginalValue(); + }); + me.set_button_status(); + }, + }, + 'button[name=reset]': { + click: function () { + let blacklist = ['GuiCap', 'login-username', 'dash-storages']; + let sp = Ext.state.Manager.getProvider(); + for (const state of Object.keys(sp.state)) { + if (!blacklist.includes(state)) { + sp.clear(state); + } + } + window.location.reload(); + }, + }, + 'button[name=clear-username]': { + click: function () { + let me = this; + me.lookupReference('savedUserName').setValue(Proxmox.Utils.noneText); + Ext.state.Manager.getProvider().clear('login-username'); + }, + }, + 'grid[reference=dashboard-storages]': { + selectionchange: function (grid, selected) { + var _me = this; + var sp = Ext.state.Manager.getProvider(); + + // saves the selected storageids as "id1,id2,id3,..." or clears the variable + if (selected.length > 0) { + sp.set('dash-storages', Ext.Array.pluck(selected, 'id').join(',')); + } else { + sp.clear('dash-storages'); + } + }, + afterrender: function (grid) { + let store = grid.getStore(); + let storages = Ext.state.Manager.getProvider().get('dash-storages') || ''; + + let items = []; + storages.split(',').forEach((storage) => { + if (storage !== '') { + // we have to get the records to be able to select them + let item = store.getById(storage); + if (item) { + items.push(item); + } + } + }); + grid.suspendEvent('selectionchange'); + grid.getSelectionModel().select(items); + grid.resumeEvent('selectionchange'); + }, + }, + 'field[reference=summarycolumns]': { + change: (el, newValue) => + Ext.state.Manager.getProvider().set('summarycolumns', newValue), + }, + 'field[reference=guestNotesCollapse]': { + change: (e, v) => Ext.state.Manager.getProvider().set('guest-notes-collapse', v), + }, + 'field[reference=editNotesOnDoubleClick]': { + change: (e, v) => + Ext.state.Manager.getProvider().set('edit-notes-on-double-click', v), + }, + }, + }, + + items: [ + { + xtype: 'fieldset', + flex: 1, + title: gettext('Webinterface Settings'), + margin: '5', + layout: { + type: 'vbox', + align: 'left', + }, + defaults: { + width: '100%', + margin: '0 0 10 0', + }, + items: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Dashboard Storages'), + labelAlign: 'left', + labelWidth: '50%', + }, + { + xtype: 'grid', + maxHeight: 150, + reference: 'dashboard-storages', + selModel: { + selType: 'checkboxmodel', + }, + columns: [ + { + header: gettext('Name'), + dataIndex: 'storage', + flex: 1, + }, + { + header: gettext('Node'), + dataIndex: 'node', + flex: 1, + }, + ], + store: { + type: 'diff', + field: ['type', 'storage', 'id', 'node'], + rstore: PVE.data.ResourceStore, + filters: [ + { + property: 'type', + value: 'storage', + }, + ], + sorters: ['node', 'storage'], + }, + }, + { + xtype: 'box', + autoEl: { tag: 'hr' }, + }, + { + xtype: 'container', + layout: 'hbox', + items: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Saved User Name') + ':', + labelWidth: 150, + stateId: 'login-username', + reference: 'savedUserName', + flex: 1, + value: '', + }, + { + xtype: 'button', + cls: 'x-btn-default-toolbar-small proxmox-inline-button', + text: gettext('Reset'), + name: 'clear-username', + }, + ], + }, + { + xtype: 'box', + autoEl: { tag: 'hr' }, + }, + { + xtype: 'container', + layout: 'hbox', + items: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Layout') + ':', + flex: 1, + }, + { + xtype: 'button', + cls: 'x-btn-default-toolbar-small proxmox-inline-button', + text: gettext('Reset'), + tooltip: gettext( + 'Reset all layout changes (for example, column widths)', + ), + name: 'reset', + }, + ], + }, + { + xtype: 'box', + autoEl: { tag: 'hr' }, + }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Summary columns') + ':', + labelWidth: 125, + stateId: 'summarycolumns', + reference: 'summarycolumns', + comboItems: [ + ['auto', 'auto'], + ['1', '1'], + ['2', '2'], + ['3', '3'], + ], + }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Guest Notes') + ':', + labelWidth: 125, + stateId: 'guest-notes-collapse', + reference: 'guestNotesCollapse', + comboItems: [ + ['never', 'Show by default'], + ['always', 'Collapse by default'], + ['auto', 'auto (Collapse if empty)'], + ], + }, + { + xtype: 'checkbox', + fieldLabel: gettext('Notes'), + labelWidth: 125, + boxLabel: gettext('Open editor on double-click'), + reference: 'editNotesOnDoubleClick', + inputValue: true, + uncheckedValue: false, + }, + ], + }, + { + xtype: 'container', + layout: 'vbox', + flex: 1, + margin: '5', + defaults: { + width: '100%', + // right margin ensures that the right border of the fieldsets + // is shown + margin: '0 2 10 0', + }, + items: [ + { + xtype: 'fieldset', + itemId: 'xtermjs', + title: gettext('xterm.js Settings'), + items: [ + { + xtype: 'form', + reference: 'xtermform', + border: false, + layout: { + type: 'vbox', + algin: 'left', + }, + defaults: { + width: '100%', + margin: '0 0 10 0', + }, + items: [ + { + xtype: 'textfield', + name: 'fontFamily', + reference: 'fontFamily', + emptyText: Proxmox.Utils.defaultText, + fieldLabel: gettext('Font-Family'), + }, + { + xtype: 'proxmoxintegerfield', + emptyText: Proxmox.Utils.defaultText, + name: 'fontSize', + reference: 'fontSize', + minValue: 1, + fieldLabel: gettext('Font-Size'), + }, + { + xtype: 'numberfield', + name: 'letterSpacing', + reference: 'letterSpacing', + emptyText: Proxmox.Utils.defaultText, + fieldLabel: gettext('Letter Spacing'), + }, + { + xtype: 'numberfield', + name: 'lineHeight', + minValue: 0.1, + reference: 'lineHeight', + emptyText: Proxmox.Utils.defaultText, + fieldLabel: gettext('Line Height'), + }, + { + xtype: 'container', + layout: { + type: 'hbox', + pack: 'end', + }, + defaults: { + margin: '0 0 0 5', + }, + items: [ + { + xtype: 'button', + reference: 'xtermreset', + disabled: true, + text: gettext('Reset'), + }, + { + xtype: 'button', + reference: 'xtermsave', + disabled: true, + text: gettext('Save'), + }, + ], + }, + ], + }, + ], + }, + { + xtype: 'fieldset', + title: gettext('noVNC Settings'), + items: [ + { + xtype: 'radiogroup', + fieldLabel: gettext('Scaling mode'), + reference: 'noVNCScalingGroup', + height: '15px', // renders faster with value assigned + layout: { + type: 'hbox', + }, + items: [ + { + xtype: 'radiofield', + name: 'noVNCScalingField', + inputValue: 'auto', + boxLabel: 'Auto', + }, + { + xtype: 'radiofield', + name: 'noVNCScalingField', + inputValue: 'scale', + boxLabel: 'Local Scaling', + margin: '0 0 0 10', + }, + { + xtype: 'radiofield', + name: 'noVNCScalingField', + inputValue: 'off', + boxLabel: 'Off', + margin: '0 0 0 10', + }, + ], + listeners: { + change: function (el, { noVNCScalingField }) { + let provider = Ext.state.Manager.getProvider(); + if (noVNCScalingField === 'auto') { + provider.clear('novnc-scaling'); + } else { + provider.set('novnc-scaling', noVNCScalingField); + } + }, + }, + }, + ], + }, + ], + }, + ], +}); +Ext.define('PVE.window.Snapshot', { + extend: 'Proxmox.window.Edit', + + viewModel: { + data: { + type: undefined, + isCreate: undefined, + running: false, + guestAgentEnabled: false, + }, + formulas: { + runningWithoutGuestAgent: (get) => + get('type') === 'qemu' && get('running') && !get('guestAgentEnabled'), + shouldWarnAboutFS: (get) => + get('isCreate') && get('runningWithoutGuestAgent') && get('!vmstate.checked'), + }, + }, + + onGetValues: function (values) { + let me = this; + + if (me.type === 'lxc') { + delete values.vmstate; + } + + return values; + }, + + initComponent: function () { + var me = this; + var vm = me.getViewModel(); + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + if (!me.type) { + throw 'no type specified'; + } + + vm.set('type', me.type); + vm.set('running', me.running); + vm.set('isCreate', me.isCreate); + + if (me.type === 'qemu' && me.isCreate) { + Proxmox.Utils.API2Request({ + url: `/nodes/${me.nodename}/${me.type}/${me.vmid}/config`, + params: { current: '1' }, + method: 'GET', + success: function (response, options) { + let res = response.result.data; + let enabled = PVE.Parser.parsePropertyString(res.agent, 'enabled'); + vm.set('guestAgentEnabled', !!PVE.Parser.parseBoolean(enabled.enabled)); + }, + }); + } + + me.items = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'snapname', + value: me.snapname, + fieldLabel: gettext('Name'), + vtype: 'ConfigId', + allowBlank: false, + }, + { + xtype: 'displayfield', + hidden: me.isCreate, + disabled: me.isCreate, + name: 'snaptime', + renderer: PVE.Utils.render_timestamp_human_readable, + fieldLabel: gettext('Timestamp'), + }, + { + xtype: 'proxmoxcheckbox', + hidden: me.type !== 'qemu' || !me.isCreate || !me.running, + disabled: me.type !== 'qemu' || !me.isCreate || !me.running, + name: 'vmstate', + reference: 'vmstate', + uncheckedValue: 0, + defaultValue: 0, + checked: 1, + fieldLabel: gettext('Include RAM'), + }, + { + xtype: 'textareafield', + grow: true, + editable: !me.viewonly, + name: 'description', + fieldLabel: gettext('Description'), + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + name: 'fswarning', + hidden: true, + value: gettext( + 'It is recommended to either include the RAM or use the QEMU Guest Agent when taking a snapshot of a running VM to avoid inconsistencies.', + ), + bind: { + hidden: '{!shouldWarnAboutFS}', + }, + }, + { + title: gettext('Settings'), + hidden: me.isCreate, + xtype: 'grid', + itemId: 'summary', + border: true, + height: 200, + store: { + model: 'KeyValue', + sorters: [ + { + property: 'key', + direction: 'ASC', + }, + ], + }, + columns: [ + { + header: gettext('Key'), + width: 150, + dataIndex: 'key', + }, + { + header: gettext('Value'), + flex: 1, + dataIndex: 'value', + }, + ], + }, + ]; + + me.url = `/nodes/${me.nodename}/${me.type}/${me.vmid}/snapshot`; + + let subject; + if (me.isCreate) { + let guestTypeStr = me.type === 'qemu' ? 'VM' : 'CT'; + let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier( + me.vmid, + me.vmname, + ); + subject = `${guestTypeStr} ${formattedGuestIdentifier} ${gettext('Snapshot')}`; + me.method = 'POST'; + me.showTaskViewer = true; + } else { + subject = `${gettext('Snapshot')} ${me.snapname}`; + me.url += `/${me.snapname}/config`; + } + + Ext.apply(me, { + subject: subject, + width: me.isCreate ? 450 : 620, + height: me.isCreate ? undefined : 420, + }); + + me.callParent(); + + if (!me.snapname) { + return; + } + + me.load({ + success: function (response) { + let kvarray = []; + Ext.Object.each(response.result.data, function (key, value) { + if (key === 'description' || key === 'snaptime') { + return; + } + kvarray.push({ key: key, value: value }); + }); + + let summarystore = me.down('#summary').getStore(); + summarystore.suspendEvents(); + summarystore.add(kvarray); + summarystore.sort(); + summarystore.resumeEvents(); + summarystore.fireEvent('refresh', summarystore); + + me.setValues(response.result.data); + }, + }); + }, +}); +Ext.define('PVE.panel.StartupInputPanel', { + extend: 'Proxmox.panel.InputPanel', + onlineHelp: 'qm_startup_and_shutdown', + + onGetValues: function (values) { + var _me = this; + + var res = PVE.Parser.printStartup(values); + + if (res === undefined || res === '') { + return { delete: 'startup' }; + } + + return { startup: res }; + }, + + setStartup: function (value) { + var me = this; + + var startup = PVE.Parser.parseStartup(value); + if (startup) { + me.setValues(startup); + } + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'textfield', + name: 'order', + defaultValue: '', + emptyText: 'any', + fieldLabel: gettext('Start/Shutdown order'), + }, + { + xtype: 'textfield', + name: 'up', + defaultValue: '', + emptyText: 'default', + fieldLabel: gettext('Startup delay'), + }, + { + xtype: 'textfield', + name: 'down', + defaultValue: '', + emptyText: 'default', + fieldLabel: gettext('Shutdown timeout'), + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.window.StartupEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveWindowStartupEdit', + onlineHelp: undefined, + + initComponent: function () { + let me = this; + + let ipanelConfig = me.onlineHelp ? { onlineHelp: me.onlineHelp } : {}; + let ipanel = Ext.create('PVE.panel.StartupInputPanel', ipanelConfig); + + Ext.applyIf(me, { + subject: gettext('Start/Shutdown order'), + fieldDefaults: { + labelWidth: 120, + }, + items: [ipanel], + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + me.vmconfig = response.result.data; + ipanel.setStartup(me.vmconfig.startup); + }, + }); + }, +}); +Ext.define('PVE.window.DownloadUrlToStorage', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveStorageDownloadUrl', + mixins: ['Proxmox.Mixin.CBind'], + + isCreate: true, + + method: 'POST', + + showTaskViewer: true, + + title: gettext('Download from URL'), + submitText: gettext('Download'), + + cbindData: function (initialConfig) { + var me = this; + return { + nodename: me.nodename, + storage: me.storage, + content: me.content, + }; + }, + + cbind: { + url: '/nodes/{nodename}/storage/{storage}/download-url', + }, + + viewModel: { + data: { + size: '-', + mimetype: '-', + enableQuery: true, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + urlChange: function (field) { + this.resetMetaInfo(); + this.setQueryEnabled(); + }, + setQueryEnabled: function () { + this.getViewModel().set('enableQuery', true); + }, + resetMetaInfo: function () { + let vm = this.getViewModel(); + vm.set('size', '-'); + vm.set('mimetype', '-'); + }, + + urlCheck: function (field) { + let me = this; + let view = me.getView(); + + const queryParam = view.getValues(); + + me.getViewModel().set('enableQuery', false); + me.resetMetaInfo(); + let urlField = view.down('[name=url]'); + + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/query-url-metadata`, + method: 'GET', + params: { + url: queryParam.url, + 'verify-certificates': queryParam['verify-certificates'], + }, + waitMsgTarget: view, + failure: (res) => { + urlField.setValidation(res.result.message); + urlField.validate(); + Ext.MessageBox.alert(gettext('Error'), res.htmlStatus); + // re-enable so one can directly requery, e.g., if it was just a network hiccup + me.setQueryEnabled(); + }, + success: function (res, opt) { + urlField.setValidation(); + urlField.validate(); + + let data = res.result.data; + + let filename = data.filename || ''; + let compression = '__default__'; + if (view.content === 'iso') { + const matches = filename.match(/^(.+)\.(gz|lzo|zst|bz2)$/i); + if (matches) { + filename = matches[1]; + compression = matches[2].toLowerCase(); + } + } else if (view.content === 'import') { + if (filename.endsWith('.img')) { + filename += '.raw'; + } + } + + view.setValues({ + filename, + compression, + size: + (data.size && Proxmox.Utils.format_size(data.size)) || + gettext('Unknown'), + mimetype: data.mimetype || gettext('Unknown'), + }); + }, + }); + }, + + hashChange: function (field) { + let checksum = Ext.getCmp('downloadUrlChecksum'); + if (field.getValue() === '__default__') { + checksum.setDisabled(true); + checksum.setValue(''); + checksum.allowBlank = true; + } else { + checksum.setDisabled(false); + checksum.allowBlank = false; + } + }, + }, + + items: [ + { + xtype: 'inputpanel', + border: false, + onGetValues: function (values) { + if (typeof values.checksum === 'string') { + values.checksum = values.checksum.trim(); + } + return values; + }, + columnT: [ + { + xtype: 'fieldcontainer', + layout: 'hbox', + fieldLabel: gettext('URL'), + items: [ + { + xtype: 'textfield', + name: 'url', + emptyText: gettext('Enter URL to download'), + allowBlank: false, + flex: 1, + listeners: { + change: 'urlChange', + }, + }, + { + xtype: 'button', + name: 'check', + text: gettext('Query URL'), + margin: '0 0 0 5', + bind: { + disabled: '{!enableQuery}', + }, + listeners: { + click: 'urlCheck', + }, + }, + ], + }, + { + xtype: 'textfield', + name: 'filename', + allowBlank: false, + fieldLabel: gettext('File name'), + emptyText: gettext('Please (re-)query URL to get meta information'), + }, + ], + column1: [ + { + xtype: 'displayfield', + name: 'size', + fieldLabel: gettext('File size'), + bind: { + value: '{size}', + }, + }, + ], + column2: [ + { + xtype: 'displayfield', + name: 'mimetype', + fieldLabel: gettext('MIME type'), + bind: { + value: '{mimetype}', + }, + }, + ], + advancedColumn1: [ + { + xtype: 'pveHashAlgorithmSelector', + name: 'checksum-algorithm', + fieldLabel: gettext('Hash algorithm'), + allowBlank: true, + hasNoneOption: true, + value: '__default__', + listeners: { + change: 'hashChange', + }, + }, + { + xtype: 'textfield', + name: 'checksum', + fieldLabel: gettext('Checksum'), + allowBlank: true, + disabled: true, + emptyText: gettext('none'), + id: 'downloadUrlChecksum', + }, + ], + advancedColumn2: [ + { + xtype: 'proxmoxcheckbox', + name: 'verify-certificates', + fieldLabel: gettext('Verify certificates'), + uncheckedValue: 0, + checked: true, + listeners: { + change: 'setQueryEnabled', + }, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'compression', + fieldLabel: gettext('Decompression algorithm'), + allowBlank: true, + hasNoneOption: true, + deleteEmpty: false, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.NoneText], + ['lzo', 'LZO'], + ['gz', 'GZIP'], + ['zst', 'ZSTD'], + ['bz2', 'BZIP2'], + ], + cbind: { + hidden: (get) => get('content') !== 'iso', + }, + }, + ], + }, + { + xtype: 'hiddenfield', + name: 'content', + cbind: { + value: '{content}', + }, + }, + ], + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + if (!me.storage) { + throw 'no storage ID specified'; + } + me.callParent(); + }, +}); +Ext.define('PVE.window.UploadToStorage', { + extend: 'Ext.window.Window', + alias: 'widget.pveStorageUpload', + mixins: ['Proxmox.Mixin.CBind'], + + resizable: false, + modal: true, + + title: gettext('Upload'), + + acceptedExtensions: { + import: ['.ova', '.qcow2', '.raw', '.vmdk'], + iso: ['.img', '.iso'], + vztmpl: ['.tar.gz', '.tar.xz', '.tar.zst'], + }, + + // accepted for file selection, will be renamed to real extension + extensionAliases: { + import: { + '.img': '.raw', + }, + }, + + cbindData: function (initialConfig) { + const me = this; + const ext = me.acceptedExtensions[me.content] || []; + + me.url = `/nodes/${me.nodename}/storage/${me.storage}/upload`; + + let fileSelectorExt = ext.concat(Object.keys(me.extensionAliases[me.content] ?? {})); + + return { + extensions: fileSelectorExt.join(', '), + filenameRegex: new RegExp('^.*(?:' + ext.join('|').replaceAll('.', '\\.') + ')$', 'i'), + }; + }, + + viewModel: { + data: { + size: '-', + mimetype: '-', + filename: '', + }, + }, + + controller: { + submit: function (button) { + const view = this.getView(); + const form = this.lookup('formPanel').getForm(); + const abortBtn = this.lookup('abortBtn'); + const pbar = this.lookup('progressBar'); + + const updateProgress = function (per, bytes) { + let text = (per * 100).toFixed(2) + '%'; + if (bytes) { + text += ' (' + Proxmox.Utils.format_size(bytes) + ')'; + } + pbar.updateProgress(per, text); + }; + + const fd = new FormData(); + + button.setDisabled(true); + abortBtn.setDisabled(false); + + fd.append('content', view.content); + + const fileField = form.findField('file'); + const file = fileField.fileInputEl.dom.files[0]; + fileField.setDisabled(true); + + const filenameField = form.findField('filename'); + const filename = filenameField.getValue(); + filenameField.setDisabled(true); + + const algorithmField = form.findField('checksum-algorithm'); + algorithmField.setDisabled(true); + if (algorithmField.getValue() !== '__default__') { + fd.append('checksum-algorithm', algorithmField.getValue()); + + const checksumField = form.findField('checksum'); + fd.append('checksum', checksumField.getValue()?.trim()); + checksumField.setDisabled(true); + } + + fd.append('filename', file, filename); + + pbar.setVisible(true); + updateProgress(0); + + const xhr = new XMLHttpRequest(); + view.xhr = xhr; + + xhr.addEventListener( + 'load', + function (e) { + if (xhr.status === 200) { + view.hide(); + + const result = JSON.parse(xhr.response); + const upid = result.data; + Ext.create('Proxmox.window.TaskViewer', { + autoShow: true, + upid: upid, + taskDone: view.taskDone, + listeners: { + destroy: function () { + view.close(); + }, + }, + }); + + return; + } + const err = Ext.htmlEncode(xhr.statusText); + let msg = `${gettext('Error')} ${xhr.status.toString()}: ${err}`; + if (xhr.responseText !== '') { + const result = Ext.decode(xhr.responseText); + result.message = msg; + msg = Proxmox.Utils.extractRequestError(result, true); + } + Ext.Msg.alert(gettext('Error'), msg, (btn) => view.close()); + }, + false, + ); + + xhr.addEventListener('error', function (e) { + const err = e.target.status.toString(); + const msg = `Error '${err}' occurred while receiving the document.`; + Ext.Msg.alert(gettext('Error'), msg, (btn) => view.close()); + }); + + xhr.upload.addEventListener( + 'progress', + function (evt) { + if (evt.lengthComputable) { + const percentComplete = evt.loaded / evt.total; + updateProgress(percentComplete, evt.loaded); + } + }, + false, + ); + + xhr.open('POST', `/api2/json${view.url}`, true); + xhr.send(fd); + }, + + validitychange: function (f, valid) { + const submitBtn = this.lookup('submitBtn'); + submitBtn.setDisabled(!valid); + }, + + fileChange: function (input) { + const me = this; + const vm = me.getViewModel(); + const view = me.getView(); + let name = input.value.replace(/^.*(\/|\\)/, ''); + for (const [alias, real] of Object.entries(view.extensionAliases[view.content] ?? {})) { + if (name.endsWith(alias)) { + name += real; + } + } + const fileInput = input.fileInputEl.dom; + vm.set('filename', name); + vm.set( + 'size', + (fileInput.files[0] && Proxmox.Utils.format_size(fileInput.files[0].size)) || '-', + ); + vm.set('mimetype', (fileInput.files[0] && fileInput.files[0].type) || '-'); + }, + + hashChange: function (field, value) { + const checksum = this.lookup('downloadUrlChecksum'); + if (value === '__default__') { + checksum.setDisabled(true); + checksum.setValue(''); + } else { + checksum.setDisabled(false); + } + }, + }, + + items: [ + { + xtype: 'form', + reference: 'formPanel', + method: 'POST', + waitMsgTarget: true, + bodyPadding: 10, + border: false, + width: 400, + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + items: [ + { + xtype: 'filefield', + name: 'file', + buttonText: gettext('Select File'), + allowBlank: false, + fieldLabel: gettext('File'), + cbind: { + accept: '{extensions}', + }, + listeners: { + change: 'fileChange', + }, + }, + { + xtype: 'textfield', + name: 'filename', + allowBlank: false, + fieldLabel: gettext('File name'), + bind: { + value: '{filename}', + }, + cbind: { + regex: '{filenameRegex}', + }, + regexText: gettext('Wrong file extension'), + }, + { + xtype: 'displayfield', + name: 'size', + fieldLabel: gettext('File size'), + bind: { + value: '{size}', + }, + }, + { + xtype: 'displayfield', + name: 'mimetype', + fieldLabel: gettext('MIME type'), + bind: { + value: '{mimetype}', + }, + }, + { + xtype: 'pveHashAlgorithmSelector', + name: 'checksum-algorithm', + fieldLabel: gettext('Hash algorithm'), + allowBlank: true, + hasNoneOption: true, + value: '__default__', + listeners: { + change: 'hashChange', + }, + }, + { + xtype: 'textfield', + name: 'checksum', + fieldLabel: gettext('Checksum'), + allowBlank: false, + disabled: true, + emptyText: gettext('none'), + reference: 'downloadUrlChecksum', + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + "Uploads are stored temporarily in '/var/tmp/', make sure there is enough free space.", + ), + }, + { + xtype: 'progressbar', + text: 'Ready', + hidden: true, + reference: 'progressBar', + }, + { + xtype: 'hiddenfield', + name: 'content', + cbind: { + value: '{content}', + }, + }, + ], + listeners: { + validitychange: 'validitychange', + }, + }, + ], + + buttons: [ + { + xtype: 'button', + text: gettext('Abort'), + reference: 'abortBtn', + disabled: true, + handler: function () { + const me = this; + me.up('pveStorageUpload').close(); + }, + }, + { + text: gettext('Upload'), + reference: 'submitBtn', + disabled: true, + handler: 'submit', + }, + ], + + listeners: { + close: function () { + const me = this; + if (me.xhr) { + me.xhr.abort(); + delete me.xhr; + } + }, + }, + + initComponent: function () { + const me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + if (!me.storage) { + throw 'no storage ID specified'; + } + if (!me.acceptedExtensions[me.content]) { + throw 'content type not supported'; + } + + me.callParent(); + }, +}); +Ext.define('PVE.window.ScheduleSimulator', { + extend: 'Ext.window.Window', + + title: gettext('Job Schedule Simulator'), + + viewModel: { + data: { + simulatedOnce: false, + }, + formulas: { + gridEmptyText: (get) => + get('simulatedOnce') ? Proxmox.Utils.NoneText : gettext('No simulation done'), + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + close: function () { + this.getView().close(); + }, + simulate: function () { + let me = this; + let schedule = me.lookup('schedule').getValue(); + if (!schedule) { + return; + } + let iterations = me.lookup('iterations').getValue() || 10; + Proxmox.Utils.API2Request({ + url: '/cluster/jobs/schedule-analyze', + method: 'GET', + params: { + schedule, + iterations, + }, + failure: (response) => { + me.getViewModel().set('simulatedOnce', true); + me.lookup('grid').getStore().setData([]); + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response) { + let schedules = response.result.data; + me.lookup('grid').getStore().setData(schedules); + me.getViewModel().set('simulatedOnce', true); + }, + }); + }, + + scheduleChanged: function (field, value) { + this.lookup('simulateBtn').setDisabled(!value); + }, + + renderDate: function (value) { + let date = new Date(value * 1000); + return date.toLocaleDateString(); + }, + + renderTime: function (value) { + let date = new Date(value * 1000); + return date.toLocaleTimeString(); + }, + + init: function (view) { + let me = this; + if (view.schedule) { + me.lookup('schedule').setValue(view.schedule); + } + }, + }, + + bodyPadding: 10, + modal: true, + resizable: false, + width: 600, + + layout: 'fit', + + items: [ + { + xtype: 'inputpanel', + column1: [ + { + xtype: 'pveCalendarEvent', + reference: 'schedule', + fieldLabel: gettext('Schedule'), + listeners: { + change: 'scheduleChanged', + }, + }, + { + xtype: 'proxmoxintegerfield', + reference: 'iterations', + fieldLabel: gettext('Iterations'), + minValue: 1, + maxValue: 100, + value: 10, + }, + { + xtype: 'container', + layout: 'hbox', + items: [ + { + xtype: 'box', + flex: 1, + }, + { + xtype: 'button', + reference: 'simulateBtn', + text: gettext('Simulate'), + handler: 'simulate', + disabled: true, + }, + ], + }, + ], + + column2: [ + { + xtype: 'grid', + reference: 'grid', + bind: { + emptyText: '{gridEmptyText}', + }, + scrollable: true, + height: 300, + columns: [ + { + text: gettext('Date'), + renderer: 'renderDate', + dataIndex: 'timestamp', + flex: 1, + }, + { + text: gettext('Time'), + renderer: 'renderTime', + dataIndex: 'timestamp', + align: 'right', + flex: 1, + }, + ], + store: { + fields: ['timestamp'], + data: [], + sorter: 'timestamp', + }, + }, + ], + }, + ], + + buttons: [ + { + text: gettext('Done'), + handler: 'close', + }, + ], +}); +Ext.define('PVE.window.Wizard', { + extend: 'Ext.window.Window', + + activeTitle: '', // used for automated testing + + width: 720, + height: 540, + + modal: true, + border: false, + + draggable: true, + closable: true, + resizable: false, + + layout: 'border', + + getValues: function (dirtyOnly) { + let me = this; + + let values = {}; + + me.down('form') + .getForm() + .getFields() + .each((field) => { + if (!field.up('inputpanel') && (!dirtyOnly || field.isDirty())) { + Proxmox.Utils.assemble_field_data(values, field.getSubmitData()); + } + }); + + me.query('inputpanel').forEach((panel) => { + Proxmox.Utils.assemble_field_data(values, panel.getValues(dirtyOnly)); + }); + + return values; + }, + + initComponent: function () { + var me = this; + + var tabs = me.items || []; + delete me.items; + + /* + * Items may have the following functions: + * validator(): per tab custom validation + * onSubmit(): submit handler + * onGetValues(): overwrite getValues results + */ + + Ext.Array.each(tabs, function (tab) { + tab.disabled = true; + }); + tabs[0].disabled = false; + + let maxidx = 0, + curidx = 0; + + let check_card = function (card) { + let fields = card.query('field, fieldcontainer'); + if (card.isXType('fieldcontainer')) { + fields.unshift(card); + } + let valid = true; + for (const field of fields) { + // Note: not all fielcontainer have isValid() + if (Ext.isFunction(field.isValid) && !field.isValid()) { + valid = false; + } + } + if (Ext.isFunction(card.validator)) { + return card.validator(); + } + return valid; + }; + + let disableTab = function (card) { + let tp = me.down('#wizcontent'); + for (let idx = tp.items.indexOf(card); idx < tp.items.getCount(); idx++) { + let tab = tp.items.getAt(idx); + if (tab) { + tab.disable(); + } + } + }; + + let tabchange = function (tp, newcard, oldcard) { + if (newcard.onSubmit) { + me.down('#next').setVisible(false); + me.down('#submit').setVisible(true); + } else { + me.down('#next').setVisible(true); + me.down('#submit').setVisible(false); + } + let valid = check_card(newcard); + me.down('#next').setDisabled(!valid); + me.down('#submit').setDisabled(!valid); + me.down('#back').setDisabled(tp.items.indexOf(newcard) === 0); + + let idx = tp.items.indexOf(newcard); + if (idx > maxidx) { + maxidx = idx; + } + curidx = idx; + + let ntab = tp.items.getAt(idx + 1); + if (valid && ntab && !newcard.onSubmit) { + ntab.enable(); + } + }; + + if (me.subject && !me.title) { + me.title = Proxmox.Utils.dialog_title(me.subject, true, false); + } + + let sp = Ext.state.Manager.getProvider(); + let advancedOn = sp.get('proxmox-advanced-cb'); + + Ext.apply(me, { + items: [ + { + xtype: 'form', + region: 'center', + layout: 'fit', + border: false, + margins: '5 5 0 5', + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + items: [ + { + itemId: 'wizcontent', + xtype: 'tabpanel', + activeItem: 0, + bodyPadding: 0, + listeners: { + afterrender: function (tp) { + tabchange(tp, this.getActiveTab()); + }, + tabchange: function (tp, newcard, oldcard) { + tabchange(tp, newcard, oldcard); + }, + }, + defaults: { + padding: 10, + }, + items: tabs, + }, + ], + }, + ], + fbar: [ + { + xtype: 'proxmoxHelpButton', + itemId: 'help', + }, + '->', + { + xtype: 'proxmoxcheckbox', + boxLabelAlign: 'before', + boxLabel: gettext('Advanced'), + value: advancedOn, + listeners: { + change: function (_, value) { + let tp = me.down('#wizcontent'); + tp.query('inputpanel').forEach(function (ip) { + ip.setAdvancedVisible(value); + }); + sp.set('proxmox-advanced-cb', value); + }, + }, + }, + { + text: gettext('Back'), + disabled: true, + itemId: 'back', + minWidth: 60, + handler: function () { + let tp = me.down('#wizcontent'); + let prev = tp.items.indexOf(tp.getActiveTab()) - 1; + if (prev < 0) { + return; + } + let ntab = tp.items.getAt(prev); + if (ntab) { + tp.setActiveTab(ntab); + } + }, + }, + { + text: gettext('Next'), + disabled: true, + itemId: 'next', + minWidth: 60, + handler: function () { + let tp = me.down('#wizcontent'); + let activeTab = tp.getActiveTab(); + if (!check_card(activeTab)) { + return; + } + let next = tp.items.indexOf(activeTab) + 1; + let ntab = tp.items.getAt(next); + if (ntab) { + ntab.enable(); + tp.setActiveTab(ntab); + } + }, + }, + { + text: gettext('Finish'), + minWidth: 60, + hidden: true, + itemId: 'submit', + handler: function () { + let tp = me.down('#wizcontent'); + tp.getActiveTab().onSubmit(); + }, + }, + ], + }); + me.callParent(); + + Ext.Array.each(me.query('inputpanel'), function (panel) { + panel.setAdvancedVisible(advancedOn); + }); + + Ext.Array.each(me.query('field'), function (field) { + let validcheck = function () { + let tp = me.down('#wizcontent'); + + // check validity for current to last enabled tab, as local change may affect validity of a later one + for (let i = curidx; i <= maxidx && i < tp.items.getCount(); i++) { + let tab = tp.items.getAt(i); + let valid = check_card(tab); + + // only set the buttons on the current panel + if (i === curidx) { + me.down('#next').setDisabled(!valid); + me.down('#submit').setDisabled(!valid); + } + // if a panel is invalid, then disable all following, else enable the next tab + let nextTab = tp.items.getAt(i + 1); + if (!valid) { + disableTab(nextTab); + return; + } else if (nextTab && !tab.onSubmit) { + nextTab.enable(); + } + } + }; + field.on('change', validcheck); + field.on('validitychange', validcheck); + }); + }, +}); +Ext.define('PVE.window.GuestDiskReassign', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + resizable: false, + modal: true, + width: 350, + border: false, + layout: 'fit', + showReset: false, + showProgress: true, + method: 'POST', + + viewModel: { + data: { + mpType: '', + }, + formulas: { + mpMaxCount: (get) => + get('mpType') === 'mp' + ? PVE.Utils.lxc_mp_counts.mps - 1 + : PVE.Utils.lxc_mp_counts.unused - 1, + }, + }, + + cbindData: function () { + let me = this; + return { + vmid: me.vmid, + disk: me.disk, + isQemu: me.type === 'qemu', + nodename: me.nodename, + url: () => { + let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume'; + return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`; + }, + }; + }, + + cbind: { + title: (get) => (get('isQemu') ? gettext('Reassign Disk') : gettext('Reassign Volume')), + submitText: (get) => get('title'), + qemu: '{isQemu}', + url: '{url}', + }, + + getValues: function () { + let me = this; + let values = me.formPanel.getForm().getValues(); + + let params = { + vmid: me.vmid, + 'target-vmid': values.targetVmid, + }; + + params[me.qemu ? 'disk' : 'volume'] = me.disk; + + if (me.qemu) { + params['target-disk'] = `${values.controller}${values.deviceid}`; + } else { + params['target-volume'] = `${values.mpType}${values.mpId}`; + } + return params; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + initViewModel: function (model) { + let view = this.getView(); + let mpTypeValue = view.disk.match(/^unused\d+/) ? 'unused' : 'mp'; + model.set('mpType', mpTypeValue); + }, + + onMpTypeChange: function (value) { + let view = this.getView(); + view.getViewModel().set('mpType', value.getValue()); + view.lookup('mpIdSelector').validate(); + }, + + onTargetVMChange: function (f, vmid) { + let me = this; + let view = me.getView(); + let diskSelector = view.lookup('diskSelector'); + if (!vmid) { + diskSelector.setVMConfig(null); + me.VMConfig = null; + return; + } + + let url = `/nodes/${view.nodename}/${view.type}/${vmid}/config`; + Proxmox.Utils.API2Request({ + url: url, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function ({ result }, options) { + if (view.qemu) { + diskSelector.setVMConfig(result.data); + diskSelector.setDisabled(false); + } else { + let mpIdSelector = view.lookup('mpIdSelector'); + let mpType = view.lookup('mpType'); + + view.VMConfig = result.data; + + mpIdSelector.setValue( + PVE.Utils.nextFreeLxcMP( + view.getViewModel().get('mpType'), + view.VMConfig, + ).id, + ); + + mpType.setDisabled(false); + mpIdSelector.setDisabled(false); + mpIdSelector.validate(); + } + }, + }); + }, + }, + + defaultFocus: 'sourceDisk', + items: [ + { + xtype: 'displayfield', + name: 'sourceDisk', + fieldLabel: gettext('Source'), + cbind: { + name: (get) => (get('isQemu') ? 'disk' : 'volume'), + value: '{disk}', + }, + allowBlank: false, + }, + { + xtype: 'vmComboSelector', + name: 'targetVmid', + allowBlank: false, + fieldLabel: gettext('Target Guest'), + store: { + model: 'PVEResources', + autoLoad: true, + sorters: 'vmid', + cbind: {}, // for nested cbinds + filters: [ + { + property: 'type', + cbind: { value: '{type}' }, + }, + { + property: 'node', + cbind: { value: '{nodename}' }, + }, + // FIXME: remove, artificial restriction that doesn't gains us anything.. + { + property: 'vmid', + operator: '!=', + cbind: { value: '{vmid}' }, + }, + { + property: 'template', + value: 0, + }, + ], + }, + listeners: { change: 'onTargetVMChange' }, + }, + { + xtype: 'pveControllerSelector', + reference: 'diskSelector', + withUnused: true, + disabled: true, + cbind: { + hidden: '{!isQemu}', + }, + }, + { + xtype: 'container', + layout: 'hbox', + cbind: { + hidden: '{isQemu}', + disabled: '{isQemu}', + }, + items: [ + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: (get) => !get('disk').match(/^unused\d+/), + value: (get) => (get('disk').match(/^unused\d+/) ? 'unused' : 'mp'), + }, + disabled: true, + name: 'mpType', + reference: 'mpType', + fieldLabel: gettext('Add as'), + submitValue: true, + flex: 4, + editConfig: { + xtype: 'proxmoxKVComboBox', + name: 'mpTypeCombo', + deleteEmpty: false, + cbind: { + hidden: '{isQemu}', + }, + comboItems: [ + ['mp', gettext('Mount Point')], + ['unused', gettext('Unused')], + ], + listeners: { change: 'onMpTypeChange' }, + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'mpId', + reference: 'mpIdSelector', + minValue: 0, + flex: 1, + allowBlank: false, + validateOnChange: true, + disabled: true, + bind: { + maxValue: '{mpMaxCount}', + }, + validator: function (value) { + let view = this.up('window'); + let type = view.getViewModel().get('mpType'); + if (Ext.isDefined(view.VMConfig[`${type}${value}`])) { + return 'Mount point is already in use.'; + } + return true; + }, + }, + ], + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + if (!me.type) { + throw 'no type specified'; + } + + me.callParent(); + }, +}); +Ext.define('PVE.GuestStop', { + extend: 'Ext.window.MessageBox', + + closeAction: 'destroy', + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + if (!me.vm) { + throw 'no vm specified'; + } + + let isQemuVM = me.vm.type === 'qemu'; + let overruleTaskType = isQemuVM ? 'qmshutdown' : 'vzshutdown'; + + me.taskType = isQemuVM ? 'qmstop' : 'vzstop'; + me.url = `/nodes/${me.nodename}/${me.vm.type}/${me.vm.vmid}/status/stop`; + + let caps = Ext.state.Manager.get('GuiCap'); + let hasSysModify = !!caps.nodes['Sys.Modify']; + + // offer to overrule if there is at least one matching shutdown task and the guest is not + // HA-enabled. Also allow users to abort tasks started by one of their API tokens. + let activeShutdownTask = + Ext.getStore('pve-cluster-tasks')?.findBy( + (task) => + (hasSysModify || task.data.user === Proxmox.UserName) && + task.data.id === me.vm.vmid.toString() && + task.data.status === undefined && + task.data.type === overruleTaskType, + ) !== -1; + let haEnabled = me.vm.hastate && me.vm.hastate !== 'unmanaged'; + + me.callParent(); + + // message box has its actual content in a sub-container, the top one is just for layouting + me.promptContainer.add({ + xtype: 'proxmoxcheckbox', + name: 'overrule-shutdown', + checked: !haEnabled && activeShutdownTask, + boxLabel: gettext('Overrule active shutdown tasks'), + hidden: !(hasSysModify || activeShutdownTask), + disabled: !(hasSysModify || activeShutdownTask) || haEnabled, + padding: '3 0 0 0', + }); + }, + + handler: function (btn) { + let me = this; + if (btn === 'yes') { + let overruleField = me.promptContainer.down('proxmoxcheckbox[name=overrule-shutdown]'); + let params = + !overruleField.isDisabled() && overruleField.getSubmitValue() + ? { 'overrule-shutdown': 1 } + : undefined; + Proxmox.Utils.API2Request({ + url: me.url, + waitMsgTarget: me, + method: 'POST', + params: params, + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + }); + } + }, + + show: function () { + let me = this; + let cfg = { + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + msg: PVE.Utils.formatGuestTaskConfirmation(me.taskType, me.vm.vmid, me.vm.name), + buttons: Ext.Msg.YESNO, + callback: (btn) => me.handler(btn), + }; + me.callParent([cfg]); + }, +}); +Ext.define('PVE.window.TreeSettingsEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveTreeSettingsEdit', + + title: gettext('Tree Settings'), + isCreate: false, + + url: '#', // ignored as submit() gets overridden here, but the parent class requires it + + width: 450, + fieldDefaults: { + labelWidth: 150, + }, + + items: [ + { + xtype: 'inputpanel', + items: [ + { + xtype: 'proxmoxKVComboBox', + name: 'sort-field', + fieldLabel: gettext('Sort Key'), + comboItems: [ + ['__default__', `${Proxmox.Utils.defaultText} (VMID)`], + ['vmid', 'VMID'], + ['name', gettext('Name')], + ], + defaultValue: '__default__', + value: '__default__', + deleteEmpty: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'group-templates', + fieldLabel: gettext('Group Templates'), + comboItems: [ + ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Yes')})`], + [1, gettext('Yes')], + [0, gettext('No')], + ], + defaultValue: '__default__', + value: '__default__', + deleteEmpty: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'group-guest-types', + fieldLabel: gettext('Group Guest Types'), + comboItems: [ + ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Yes')})`], + [1, gettext('Yes')], + [0, gettext('No')], + ], + defaultValue: '__default__', + value: '__default__', + deleteEmpty: false, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('Settings are saved in the local storage of the browser'), + }, + ], + }, + ], + + submit: function () { + let me = this; + + let localStorage = Ext.state.Manager.getProvider(); + localStorage.set('pve-tree-sorting', me.down('inputpanel').getValues() || null); + + me.apiCallDone(); + me.close(); + }, + + initComponent: function () { + let me = this; + + me.callParent(); + + let localStorage = Ext.state.Manager.getProvider(); + me.down('inputpanel').setValues(localStorage.get('pve-tree-sorting')); + }, +}); +Ext.define('PVE.window.PCIMapEditWindow', { + extend: 'Proxmox.window.Edit', + + mixins: ['Proxmox.Mixin.CBind'], + + width: 800, + + subject: gettext('PCI mapping'), + + onlineHelp: 'resource_mapping', + + method: 'POST', + + cbindData: function (initialConfig) { + let me = this; + me.isCreate = (!me.name || !me.nodename) && !me.entryOnly; + me.method = me.name ? 'PUT' : 'POST'; + me.hideMapping = !!me.entryOnly; + me.globalEdit = !me.name || me.entryOnly; + me.hideNodeSelector = me.nodename || me.entryOnly; + me.hideNode = !me.nodename || !me.hideNodeSelector; + return { + name: me.name, + nodename: me.nodename, + }; + }, + + submitUrl: function (_url, data) { + let me = this; + let name = me.method === 'PUT' ? me.name : ''; + return `/cluster/mapping/pci/${name}`; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + onGetValues: function (values) { + let me = this; + let view = me.getView(); + if (view.method === 'POST') { + delete me.digest; + } + + if (values.iommugroup === -1) { + delete values.iommugroup; + } + + let nodename = values.node ?? view.nodename; + delete values.node; + if (me.originalMap) { + let otherMaps = PVE.Parser.filterPropertyStringList( + me.originalMap, + (e) => e.node !== nodename, + ); + if (otherMaps.length) { + values.map = values.map.concat(otherMaps); + } + } + + return values; + }, + + onSetValues: function (values) { + let me = this; + let view = me.getView(); + me.originalMap = [...values.map]; + let configuredNodes = []; + values.map = PVE.Parser.filterPropertyStringList(values.map, (e) => { + configuredNodes.push(e.node); + return e.node === view.nodename; + }); + + me.lookup('nodeselector').disallowedNodes = configuredNodes; + return values; + }, + + checkIommu: function (store, records, success) { + let me = this; + if (!success || !records.length) { + return; + } + me.lookup('iommu_warning').setVisible( + records.every((val) => val.data.iommugroup === -1), + ); + + let value = me.lookup('pciselector').getValue(); + me.checkIsolated(value); + }, + + checkIsolated: function (value) { + let me = this; + + let store = me.lookup('pciselector').getStore(); + + let isIsolated = function (entry) { + let isolated = true; + let parsed = PVE.Parser.parsePropertyString(entry); + parsed.iommugroup = parseInt(parsed.iommugroup, 10); + if (!parsed.iommugroup) { + return isolated; + } + store.each(({ data }) => { + let isSubDevice = data.id.startsWith(parsed.path); + if ( + data.iommugroup === parsed.iommugroup && + data.id !== parsed.path && + !isSubDevice + ) { + isolated = false; + return false; + } + return true; + }); + return isolated; + }; + + let showWarning = false; + if (Ext.isArray(value)) { + for (const entry of value) { + if (!isIsolated(entry)) { + showWarning = true; + break; + } + } + } else { + showWarning = isIsolated(value); + } + me.lookup('group_warning').setVisible(showWarning); + }, + + mdevChange: function (mdevField, value) { + this.lookup('pciselector').setMdev(value); + }, + + nodeChange: function (field, value) { + if (!field.isDisabled()) { + this.lookup('pciselector').setNodename(value); + } + }, + + pciChange: function (_field, value) { + let me = this; + me.lookup('multiple_warning').setVisible(Ext.isArray(value) && value.length > 1); + me.checkIsolated(value); + }, + + control: { + 'field[name=mdev]': { + change: 'mdevChange', + }, + pveNodeSelector: { + change: 'nodeChange', + }, + pveMultiPCISelector: { + change: 'pciChange', + }, + }, + }, + + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + return this.up('window').getController().onGetValues(values); + }, + + onSetValues: function (values) { + return this.up('window').getController().onSetValues(values); + }, + + columnT: [ + { + xtype: 'displayfield', + reference: 'iommu_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + value: gettext( + 'No IOMMU detected, please activate it. See Documentation for further information.', + ), + userCls: 'pmx-hint', + }, + { + xtype: 'displayfield', + reference: 'multiple_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + value: gettext( + 'When multiple devices are selected, the first free one will be chosen on guest start.', + ), + userCls: 'pmx-hint', + }, + { + xtype: 'displayfield', + reference: 'group_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + itemId: 'iommuwarning', + value: gettext( + 'A selected device is not in a separate IOMMU group, make sure this is intended.', + ), + userCls: 'pmx-hint', + }, + ], + + column1: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + labelWidth: 120, + cbind: { + editable: '{!name}', + value: '{name}', + submitValue: '{isCreate}', + }, + name: 'id', + allowBlank: false, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Mapping on Node'), + labelWidth: 120, + name: 'node', + cbind: { + value: '{nodename}', + disabled: '{hideNode}', + hidden: '{hideNode}', + }, + allowBlank: false, + }, + { + xtype: 'pveNodeSelector', + reference: 'nodeselector', + fieldLabel: gettext('Mapping on Node'), + labelWidth: 120, + name: 'node', + cbind: { + disabled: '{hideNodeSelector}', + hidden: '{hideNodeSelector}', + }, + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Use with Mediated Devices'), + labelWidth: 200, + reference: 'mdev', + name: 'mdev', + cbind: { + deleteEmpty: '{!isCreate}', + disabled: '{!globalEdit}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Live Migration Capable'), + labelWidth: 200, + boxLabel: ` ${gettext('Experimental')}`, + reference: 'live-migration-capable', + name: 'live-migration-capable', + cbind: { + deleteEmpty: '{!isCreate}', + disabled: '{!globalEdit}', + }, + }, + ], + + columnB: [ + { + xtype: 'pveMultiPCISelector', + fieldLabel: gettext('Device'), + labelWidth: 120, + height: 300, + reference: 'pciselector', + name: 'map', + cbind: { + nodename: '{nodename}', + disabled: '{hideMapping}', + hidden: '{hideMapping}', + }, + allowBlank: false, + onLoadCallBack: 'checkIommu', + margin: '0 0 10 0', + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Comment'), + labelWidth: 120, + submitValue: true, + name: 'description', + cbind: { + deleteEmpty: '{!isCreate}', + disabled: '{!globalEdit}', + hidden: '{!globalEdit}', + }, + }, + ], + }, + ], +}); +Ext.define('PVE.window.USBMapEditWindow', { + extend: 'Proxmox.window.Edit', + + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: function (initialConfig) { + let me = this; + me.isCreate = !me.name; + me.method = me.isCreate ? 'POST' : 'PUT'; + me.hideMapping = !!me.entryOnly; + me.hideComment = me.name && !me.entryOnly; + me.hideNodeSelector = me.nodename || me.entryOnly; + me.hideNode = !me.nodename || !me.hideNodeSelector; + return { + name: me.name, + nodename: me.nodename, + }; + }, + + submitUrl: function (_url, data) { + let me = this; + let name = me.isCreate ? '' : me.name; + return `/cluster/mapping/usb/${name}`; + }, + + title: gettext('Add USB mapping'), + + onlineHelp: 'resource_mapping', + + method: 'POST', + + controller: { + xclass: 'Ext.app.ViewController', + + onGetValues: function (values) { + let me = this; + let view = me.getView(); + values.node ??= view.nodename; + + let type = me.getView().down('radiofield').getGroupValue(); + let name = values.name; + let description = values.description; + delete values.description; + delete values.name; + + if (type === 'path') { + let usbsel = me.lookup(type); + let usbDev = usbsel + .getStore() + .findRecord('usbid', values[type], 0, false, true, true); + + if (!usbDev) { + return {}; + } + values.id = `${usbDev.data.vendid}:${usbDev.data.prodid}`; + } + + let map = []; + if (me.originalMap) { + map = PVE.Parser.filterPropertyStringList( + me.originalMap, + (e) => e.node !== values.node, + ); + } + if (values.id) { + map.push(PVE.Parser.printPropertyString(values)); + } + + values = { map }; + if (description) { + values.description = description; + } + + if (view.isCreate) { + values.id = name; + } + + return values; + }, + + onSetValues: function (values) { + let me = this; + let view = me.getView(); + me.originalMap = [...values.map]; + let configuredNodes = []; + PVE.Parser.filterPropertyStringList(values.map, (e) => { + configuredNodes.push(e.node); + if (e.node === view.nodename) { + values = e; + } + return false; + }); + + me.lookup('nodeselector').disallowedNodes = configuredNodes; + if (values.path) { + values.usb = 'path'; + } + + return values; + }, + + modeChange: function (field, value) { + let me = this; + let type = field.inputValue; + let usbsel = me.lookup(type); + usbsel.setDisabled(!value); + }, + + nodeChange: function (field, value) { + if (!field.isDisabled()) { + this.lookup('id').setNodename(value); + this.lookup('path').setNodename(value); + } + }, + + init: function (view) { + let _me = this; + + if (!view.nodename) { + //throw "no nodename given"; + } + }, + + control: { + radiofield: { + change: 'modeChange', + }, + pveNodeSelector: { + change: 'nodeChange', + }, + }, + }, + + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + return this.up('window').getController().onGetValues(values); + }, + + onSetValues: function (values) { + return this.up('window').getController().onSetValues(values); + }, + + column1: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + cbind: { + editable: '{!name}', + value: '{name}', + submitValue: '{isCreate}', + }, + name: 'name', + allowBlank: false, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Mapping on Node'), + labelWidth: 120, + name: 'node', + cbind: { + value: '{nodename}', + disabled: '{hideNode}', + hidden: '{hideNode}', + }, + allowBlank: false, + }, + { + xtype: 'pveNodeSelector', + reference: 'nodeselector', + fieldLabel: gettext('Mapping on Node'), + labelWidth: 120, + name: 'node', + cbind: { + disabled: '{hideNodeSelector}', + hidden: '{hideNodeSelector}', + }, + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'fieldcontainer', + defaultType: 'radiofield', + layout: 'fit', + cbind: { + disabled: '{hideMapping}', + hidden: '{hideMapping}', + }, + items: [ + { + name: 'usb', + inputValue: 'id', + checked: true, + boxLabel: gettext('Use USB Vendor/Device ID'), + submitValue: false, + }, + { + xtype: 'pveUSBSelector', + type: 'device', + reference: 'id', + name: 'id', + cbind: { + nodename: '{nodename}', + disabled: '{hideMapping}', + }, + editable: true, + allowBlank: false, + fieldLabel: gettext('Choose Device'), + labelAlign: 'right', + }, + { + name: 'usb', + inputValue: 'path', + boxLabel: gettext('Use USB Port'), + submitValue: false, + }, + { + xtype: 'pveUSBSelector', + disabled: true, + name: 'path', + reference: 'path', + cbind: { + nodename: '{nodename}', + }, + editable: true, + type: 'port', + allowBlank: false, + fieldLabel: gettext('Choose Port'), + labelAlign: 'right', + }, + ], + }, + ], + + columnB: [ + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Comment'), + submitValue: true, + name: 'description', + cbind: { + disabled: '{hideComment}', + hidden: '{hideComment}', + }, + }, + ], + }, + ], +}); +Ext.define('PVE.window.DirMapEditWindow', { + extend: 'Proxmox.window.Edit', + + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: function (initialConfig) { + let me = this; + me.isCreate = !me.name; + me.method = me.isCreate ? 'POST' : 'PUT'; + me.hideMapping = !!me.entryOnly; + me.hideComment = me.name && !me.entryOnly; + me.hideNodeSelector = me.nodename || me.entryOnly; + me.hideNode = !me.nodename || !me.hideNodeSelector; + return { + name: me.name, + nodename: me.nodename, + }; + }, + + submitUrl: function (_url, data) { + let me = this; + let name = me.isCreate ? '' : me.name; + return `/cluster/mapping/dir/${name}`; + }, + + title: gettext('Add Directory Mapping'), + + onlineHelp: 'resource_mapping', + + method: 'POST', + + controller: { + xclass: 'Ext.app.ViewController', + + onGetValues: function (values) { + let me = this; + let view = me.getView(); + values.node ??= view.nodename; + + let name = values.name; + let description = values.description; + let deletes = values.delete; + + delete values.description; + delete values.name; + delete values.delete; + + let map = []; + if (me.originalMap) { + map = PVE.Parser.filterPropertyStringList( + me.originalMap, + (e) => e.node !== values.node, + ); + } + if (values.path) { + // TODO: Remove this when property string supports quotation of properties + if (!/^\/[^;,=()]+/.test(values.path)) { + let errMsg = + 'Value does not look like a valid absolute path.' + + ' These symbols are currently not allowed in path: ;,=()\n'; + Ext.Msg.alert(gettext('Error'), errMsg); + // prevent sending a broken property string to the API + throw errMsg; + } + map.push(PVE.Parser.printPropertyString(values)); + } + values = { map }; + + if (description) { + values.description = description; + } + if (deletes && !view.isCreate) { + values.delete = deletes; + } + if (view.isCreate) { + values.id = name; + } + + return values; + }, + + onSetValues: function (values) { + let me = this; + let view = me.getView(); + me.originalMap = [...values.map]; + let configuredNodes = []; + PVE.Parser.filterPropertyStringList(values.map, (e) => { + configuredNodes.push(e.node); + if (e.node === view.nodename) { + values = e; + } + return false; + }); + + me.lookup('nodeselector').disallowedNodes = configuredNodes; + + return values; + }, + + init: function (view) { + let _me = this; + + if (!view.nodename) { + //throw "no nodename given"; + } + }, + }, + + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + return this.up('window').getController().onGetValues(values); + }, + + onSetValues: function (values) { + return this.up('window').getController().onSetValues(values); + }, + + columnT: [ + { + xtype: 'displayfield', + reference: 'directory-hint', + columnWidth: 1, + value: 'Make sure the directory exists.', + cbind: { + disabled: '{hideMapping}', + hidden: '{hideMapping}', + }, + userCls: 'pmx-hint', + }, + ], + + column1: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + cbind: { + editable: '{!name}', + value: '{name}', + submitValue: '{isCreate}', + }, + name: 'name', + allowBlank: false, + }, + { + xtype: 'pveNodeSelector', + reference: 'nodeselector', + fieldLabel: gettext('Node'), + name: 'node', + cbind: { + disabled: '{hideNodeSelector}', + hidden: '{hideNodeSelector}', + }, + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'fieldcontainer', + defaultType: 'radiofield', + layout: 'fit', + cbind: { + disabled: '{hideMapping}', + hidden: '{hideMapping}', + }, + items: [ + { + xtype: 'textfield', + name: 'path', + reference: 'path', + value: '', + emptyText: gettext('/some/path'), + cbind: { + nodename: '{nodename}', + disabled: '{hideMapping}', + }, + allowBlank: false, + fieldLabel: gettext('Path'), + }, + ], + }, + ], + + columnB: [ + { + xtype: 'fieldcontainer', + defaultType: 'radiofield', + layout: 'fit', + cbind: { + disabled: '{hideComment}', + hidden: '{hideComment}', + }, + items: [ + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Comment'), + submitValue: true, + name: 'description', + deleteEmpty: true, + }, + ], + }, + ], + }, + ], +}); +Ext.define('PVE.window.GuestImport', { + extend: 'Proxmox.window.Edit', // fixme: Proxmox.window.Edit? + alias: 'widget.pveGuestImportWindow', + + title: gettext('Import Guest'), + + onlineHelp: 'qm_import_virtual_machines', + + width: 720, + bodyPadding: 0, + + submitUrl: function () { + let me = this; + return `/nodes/${me.nodename}/qemu`; + }, + + isAdd: true, + isCreate: true, + submitText: gettext('Import'), + showTaskViewer: true, + method: 'POST', + + loadUrl: function (_url, { storage, nodename, volumeName }) { + let args = Ext.Object.toQueryString({ volume: volumeName }); + return `/nodes/${nodename}/storage/${storage}/import-metadata?${args}`; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + setNodename: function (_column, widget) { + let me = this; + let view = me.getView(); + widget.setNodename(view.nodename); + }, + + diskStorageChange: function (storageSelector, value) { + let me = this; + + let grid = me.lookup('diskGrid'); + let rec = storageSelector.getWidgetRecord(); + let validFormats = storageSelector.store.getById(value)?.data.format; + grid.query('pveDiskFormatSelector').some((selector) => { + if (selector.getWidgetRecord().data.id !== rec.data.id) { + return false; + } + + if (validFormats?.[0]?.qcow2) { + selector.setDisabled(false); + selector.setValue('qcow2'); + } else { + selector.setValue('raw'); + selector.setDisabled(true); + } + + return true; + }); + }, + + isoStorageChange: function (storageSelector, value) { + let me = this; + + let grid = me.lookup('cdGrid'); + let rec = storageSelector.getWidgetRecord(); + grid.query('pveFileSelector').some((selector) => { + if (selector.getWidgetRecord().data.id !== rec.data.id) { + return false; + } + + selector.setStorage(value); + if (!value) { + selector.setValue(''); + } + + return true; + }); + }, + + onOSBaseChange: function (_field, value) { + let me = this; + let ostype = me.lookup('ostype'); + let store = ostype.getStore(); + store.setData(PVE.Utils.kvm_ostypes[value]); + let old_val = ostype.getValue(); + if (old_val && store.find('val', old_val) !== -1) { + ostype.setValue(old_val); + } else { + ostype.setValue(store.getAt(0)); + } + }, + + calculateConfig: function () { + let me = this; + let inputPanel = me.lookup('mainInputPanel'); + let summaryGrid = me.lookup('summaryGrid'); + let values = inputPanel.getValues(); + summaryGrid + .getStore() + .setData(Object.entries(values).map(([key, value]) => ({ key, value }))); + }, + + calculateAdditionalCDIdx: function () { + let me = this; + + let maxIde = me.getMaxControllerId('ide'); + let maxSata = me.getMaxControllerId('sata'); + // only ide0 and ide2 can be used reliably for isos (e.g. for q35) + if (maxIde < 0) { + return 'ide0'; + } + if (maxIde < 2) { + return 'ide2'; + } + if (maxSata < PVE.Utils.diskControllerMaxIDs.sata - 1) { + return `sata${maxSata + 1}`; + } + + return ''; + }, + + // assume assigned sata disks indices are continuous, so without holes + getMaxControllerId: function (controller) { + let me = this; + let view = me.getView(); + if (!controller) { + return -1; + } + + let max = view[`max${controller}`]; + if (max !== undefined) { + return max; + } + + max = -1; + for (const key of Object.keys(me.getView().vmConfig)) { + if (!key.toLowerCase().startsWith(controller)) { + continue; + } + let idx = parseInt(key.slice(controller.length), 10); + if (idx > max) { + max = idx; + } + } + me.lookup('diskGrid') + .getStore() + .each((rec) => { + if (!rec.data.id.toLowerCase().startsWith(controller)) { + return; + } + let idx = parseInt(rec.data.id.slice(controller.length), 10); + if (idx > max) { + max = idx; + } + }); + me.lookup('cdGrid') + .getStore() + .each((rec) => { + if (!rec.data.id.toLowerCase().startsWith(controller) || rec.data.hidden) { + return; + } + let idx = parseInt(rec.data.id.slice(controller.length), 10); + if (idx > max) { + max = idx; + } + }); + + view[`max${controller}`] = max; + return max; + }, + + renderDisk: function (value, metaData, record, rowIndex, colIndex, store, tableView) { + let diskGrid = tableView.grid ?? this.lookup('diskGrid'); + if (diskGrid.diskMap) { + let mappedID = diskGrid.diskMap[value]; + if (mappedID) { + let prefix = ''; + if (mappedID === value) { + // mapped to the same value means we ran out of IDs + let warning = gettext('Too many disks, could not map to SATA.'); + prefix = ` `; + } + return `${prefix}${mappedID}`; + } + } + return value; + }, + + refreshGrids: function () { + this.lookup('diskGrid').reconfigure(); + this.lookup('cdGrid').reconfigure(); + this.lookup('netGrid').reconfigure(); + }, + + onOSTypeChange: function (_cb, value) { + let me = this; + if (!value) { + return; + } + let store = me.lookup('cdGrid').getStore(); + let collection = store.getData().getSource() ?? store.getData(); + let rec = collection.find('autogenerated', true); + + let isWindows = (value ?? '').startsWith('w'); + if (rec) { + rec.set('hidden', !isWindows); + rec.commit(); + } + let prepareVirtio = me.lookup('prepareForVirtIO').getValue(); + let defaultScsiHw = me.getView().vmConfig.scsihw ?? '__default__'; + me.lookup('scsihw').setValue( + prepareVirtio && isWindows ? 'virtio-scsi-single' : defaultScsiHw, + ); + + me.refreshGrids(); + }, + + onPrepareVirtioChange: function (_cb, value) { + let me = this; + let view = me.getView(); + let diskGrid = me.lookup('diskGrid'); + + diskGrid.diskMap = {}; + if (value) { + const hasAdditionalSataCDROM = + me.getViewModel().get('isWindows') && view.additionalCdIdx?.startsWith('sata'); + + diskGrid.getStore().each((rec) => { + let diskID = rec.data.id; + if (!diskID.toLowerCase().startsWith('scsi')) { + return; // continue + } + let offset = parseInt(diskID.slice(4), 10); + let newIdx = offset + me.getMaxControllerId('sata') + 1; + if (hasAdditionalSataCDROM) { + newIdx++; + } + let mappedID = `sata${newIdx}`; + if (newIdx >= PVE.Utils.diskControllerMaxIDs.sata) { + mappedID = diskID; // map to self so that the renderer can detect that we're out of IDs + } + diskGrid.diskMap[diskID] = mappedID; + }); + } + + let scsihw = me.lookup('scsihw'); + scsihw.suspendEvents(); + scsihw.setValue(value ? 'virtio-scsi-single' : me.getView().vmConfig.scsihw); + scsihw.resumeEvents(); + + me.refreshGrids(); + }, + + onScsiHwChange: function (_field, value) { + let me = this; + me.getView().vmConfig.scsihw = value; + }, + + onUniqueMACChange: function (_cb, value) { + let me = this; + + me.getViewModel().set('uniqueMACAdresses', value); + + me.lookup('netGrid').reconfigure(); + }, + + renderMacAddress: function (value, metaData, record, rowIndex, colIndex, store, view) { + let me = this; + let vm = me.getViewModel(); + + return !vm.get('uniqueMACAdresses') && value ? value : 'auto'; + }, + + control: { + 'grid field': { + // update records from widgetcolumns + change: function (widget, value) { + let rec = widget.getWidgetRecord(); + rec.set(widget.name, value); + rec.commit(); + }, + }, + 'grid[reference=diskGrid] pveStorageSelector': { + change: 'diskStorageChange', + }, + 'grid[reference=cdGrid] pveStorageSelector': { + change: 'isoStorageChange', + }, + 'field[name=osbase]': { + change: 'onOSBaseChange', + }, + 'panel[reference=summaryTab]': { + activate: 'calculateConfig', + }, + 'proxmoxcheckbox[reference=prepareForVirtIO]': { + change: 'onPrepareVirtioChange', + }, + 'combobox[name=ostype]': { + change: 'onOSTypeChange', + }, + pveScsiHwSelector: { + change: 'onScsiHwChange', + }, + 'proxmoxcheckbox[name=uniqueMACs]': { + change: 'onUniqueMACChange', + }, + }, + }, + + viewModel: { + data: { + coreCount: 1, + socketCount: 1, + liveImport: false, + os: 'l26', + maxCdDrives: false, + uniqueMACAdresses: false, + isOva: false, + warnings: [], + }, + + formulas: { + totalCoreCount: (get) => get('socketCount') * get('coreCount'), + hideWarnings: (get) => get('warnings').length === 0, + warningsText: (get) => + '
    ' + + get('warnings') + .map((w) => `
  • ${w}
  • `) + .join('') + + '
', + liveImportNote: (get) => + !get('liveImport') + ? '' + : gettext( + 'Note: If anything goes wrong during the live-import, new data written by the VM may be lost.', + ), + isWindows: (get) => (get('os') ?? '').startsWith('w'), + liveImportText: (get) => + get('isOva') + ? gettext('Starts a VM and imports the disks in the background') + : gettext( + 'Starts a previously stopped VM on Proxmox VE and imports the disks in the background.', + ), + }, + }, + + items: [ + { + xtype: 'tabpanel', + defaults: { + bodyPadding: 10, + }, + items: [ + { + title: gettext('General'), + xtype: 'inputpanel', + reference: 'mainInputPanel', + onGetValues: function (values) { + let me = this; + let view = me.up('pveGuestImportWindow'); + let vm = view.getViewModel(); + let diskGrid = view.lookup('diskGrid'); + + // from pveDiskStorageSelector + let defaultStorage = values.hdstorage; + let defaultFormat = values.diskformat; + delete values.hdstorage; + delete values.diskformat; + + let defaultBridge = values.defaultBridge; + delete values.defaultBridge; + + let config = { ...view.vmConfig }; + Ext.apply(config, values); + + if (config.scsi0) { + config.scsi0 = config.scsi0.replace( + 'local:0,', + 'local:0,format=qcow2,', + ); + } + + let parsedBoot = PVE.Parser.parsePropertyString(config.boot ?? ''); + if (parsedBoot.order) { + parsedBoot.order = parsedBoot.order.split(';'); + } + + let diskMap = diskGrid.diskMap ?? {}; + diskGrid.getStore().each((rec) => { + if (!rec.data.enable) { + return; + } + let id = diskMap[rec.data.id] ?? rec.data.id; + if (id !== rec.data.id && parsedBoot?.order) { + let idx = parsedBoot.order.indexOf(rec.data.id); + if (idx !== -1) { + parsedBoot.order[idx] = id; + } + } + let data = { + ...rec.data, + }; + delete data.enable; + delete data.id; + delete data.size; + if (!data.file) { + data.file = defaultStorage; + data.format = defaultFormat; + } + data.file += ':0'; // for our special api format + if (id === 'efidisk0') { + data.efitype = '4m'; + delete data['import-from']; + } + config[id] = PVE.Parser.printQemuDrive(data); + }); + + if (parsedBoot.order) { + parsedBoot.order = parsedBoot.order.join(';'); + } + config.boot = PVE.Parser.printPropertyString(parsedBoot); + + view.lookup('netGrid') + .getStore() + .each((rec) => { + if (!rec.data.enable) { + return; + } + let id = rec.data.id; + let data = { + ...rec.data, + }; + delete data.enable; + delete data.id; + if (!data.bridge) { + data.bridge = defaultBridge; + } + if (vm.get('uniqueMACAdresses')) { + data.macaddr = undefined; + } + config[id] = PVE.Parser.printQemuNetwork(data); + }); + + view.lookup('cdGrid') + .getStore() + .each((rec) => { + if (!rec.data.enable) { + return; + } + let id = rec.data.id; + let cd = { + media: 'cdrom', + file: rec.data.file ? rec.data.file : 'none', + }; + config[id] = PVE.Parser.printPropertyString(cd); + }); + + config.scsihw = view.lookup('scsihw').getValue(); + + if (view.lookup('liveimport').getValue()) { + config['live-restore'] = 1; + } + + // remove __default__ values + for (const [key, value] of Object.entries(config)) { + if (value === '__default__') { + delete config[key]; + } + } + + if (config['import-working-storage'] === '') { + delete config['import-working-storage']; + } + + return config; + }, + + column1: [ + { + xtype: 'pveGuestIDSelector', + name: 'vmid', + fieldLabel: 'VM', + guestType: 'qemu', + loadNextFreeID: true, + validateExists: false, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('Sockets'), + name: 'sockets', + reference: 'socketsField', + value: 1, + minValue: 1, + maxValue: 128, + allowBlank: true, + bind: { + value: '{socketCount}', + }, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('Cores'), + name: 'cores', + reference: 'coresField', + value: 1, + minValue: 1, + maxValue: 1024, + allowBlank: true, + bind: { + value: '{coreCount}', + }, + }, + { + xtype: 'pveMemoryField', + fieldLabel: gettext('Memory') + ' (MiB)', + name: 'memory', + reference: 'memoryField', + value: 512, + allowBlank: true, + }, + { xtype: 'displayfield' }, // spacer + { xtype: 'displayfield' }, // spacer + { + xtype: 'pveDiskStorageSelector', + reference: 'defaultStorage', + storageLabel: gettext('Default Storage'), + storageContent: 'images', + autoSelect: true, + hideSize: true, + name: 'defaultStorage', + }, + ], + + column2: [ + { + xtype: 'textfield', + fieldLabel: gettext('Name'), + name: 'name', + vtype: 'DnsName', + reference: 'nameField', + allowBlank: true, + }, + { + xtype: 'CPUModelSelector', + name: 'cpu', + reference: 'cputype', + value: 'x86-64-v2-AES', + fieldLabel: gettext('CPU Type'), + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Total cores'), + name: 'totalcores', + isFormField: false, + bind: { + value: '{totalCoreCount}', + }, + }, + { + xtype: 'combobox', + submitValue: false, + name: 'osbase', + fieldLabel: gettext('OS Type'), + editable: false, + queryMode: 'local', + value: 'Linux', + store: Object.keys(PVE.Utils.kvm_ostypes), + }, + { + xtype: 'combobox', + name: 'ostype', + reference: 'ostype', + fieldLabel: gettext('Version'), + value: 'l26', + allowBlank: false, + editable: false, + queryMode: 'local', + valueField: 'val', + displayField: 'desc', + bind: { + value: '{os}', + }, + store: { + fields: ['desc', 'val'], + data: PVE.Utils.kvm_ostypes.Linux, + }, + }, + { xtype: 'displayfield' }, // spacer + { + xtype: 'PVE.form.BridgeSelector', + reference: 'defaultBridge', + name: 'defaultBridge', + allowBlank: false, + fieldLabel: gettext('Default Bridge'), + }, + { + xtype: 'pveStorageSelector', + reference: 'extractionStorage', + fieldLabel: gettext('Import Working Storage'), + storageContent: 'images', + emptyText: gettext('Source Storage'), + autoSelect: false, + name: 'import-working-storage', + disabled: true, + hidden: true, + allowBlank: true, + bind: { + disabled: '{!isOva}', + hidden: '{!isOva}', + }, + }, + ], + + columnB: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Live Import'), + reference: 'liveimport', + isFormField: false, + bind: { + value: '{liveImport}', + boxLabel: '{liveImportText}', + }, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint black', + value: gettext( + 'Note: If anything goes wrong during the live-import, new data written by the VM may be lost.', + ), + bind: { + hidden: '{!liveImport}', + }, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Warnings'), + labelWidth: 200, + hidden: true, + bind: { + hidden: '{hideWarnings}', + }, + }, + { + xtype: 'displayfield', + reference: 'warningText', + userCls: 'pmx-hint', + hidden: true, + bind: { + hidden: '{hideWarnings}', + value: '{warningsText}', + }, + }, + ], + }, + { + title: gettext('Advanced'), + xtype: 'inputpanel', + + // the first inputpanel handles all values, so prevent value leakage here + onGetValues: () => ({}), + + columnT: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Disks'), + labelWidth: 200, + }, + { + xtype: 'grid', + reference: 'diskGrid', + minHeight: 60, + maxHeight: 150, + store: { + data: [], + sorters: ['id'], + }, + columns: [ + { + xtype: 'checkcolumn', + header: gettext('Use'), + width: 50, + dataIndex: 'enable', + listeners: { + checkchange: function ( + _column, + _rowIndex, + _checked, + record, + ) { + record.commit(); + }, + }, + }, + { + text: gettext('Disk'), + dataIndex: 'id', + renderer: 'renderDisk', + }, + { + text: gettext('Source'), + dataIndex: 'import-from', + flex: 1, + renderer: function (value) { + return value.replace(/^.*\//, ''); + }, + }, + { + text: gettext('Size'), + dataIndex: 'size', + renderer: (value) => { + if (Ext.isNumeric(value)) { + return Proxmox.Utils.render_size(value); + } + return value ?? Proxmox.Utils.unknownText; + }, + }, + { + text: gettext('Storage'), + dataIndex: 'file', + xtype: 'widgetcolumn', + width: 150, + widget: { + xtype: 'pveStorageSelector', + isFormField: false, + autoSelect: false, + allowBlank: true, + emptyText: gettext('From Default'), + name: 'file', + storageContent: 'images', + }, + onWidgetAttach: 'setNodename', + }, + { + text: gettext('Format'), + dataIndex: 'format', + xtype: 'widgetcolumn', + width: 150, + widget: { + xtype: 'pveDiskFormatSelector', + name: 'format', + disabled: true, + isFormField: false, + matchFieldWidth: false, + }, + }, + ], + }, + ], + + column1: [ + { + xtype: 'proxmoxcheckbox', + boxLabel: gettext('Prepare for VirtIO-SCSI'), + reference: 'prepareForVirtIO', + name: 'prepareForVirtIO', + submitValue: false, + disabled: true, + bind: { + disabled: '{!isWindows}', + }, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Maps SCSI disks to SATA and changes the SCSI Controller. Useful for a quicker switch to VirtIO-SCSI attached disks', + ), + }, + }, + ], + + column2: [ + { + xtype: 'pveScsiHwSelector', + reference: 'scsihw', + name: 'scsihw', + value: '__default__', + submitValue: false, + fieldLabel: gettext('SCSI Controller'), + }, + ], + + columnB: [ + { + xtype: 'displayfield', + fieldLabel: gettext('CD/DVD Drives'), + labelWidth: 200, + }, + { + xtype: 'grid', + reference: 'cdGrid', + minHeight: 60, + maxHeight: 150, + store: { + data: [], + sorters: ['id'], + filters: [ + function (rec) { + return !rec.data.hidden; + }, + ], + }, + columns: [ + { + xtype: 'checkcolumn', + header: gettext('Use'), + width: 50, + dataIndex: 'enable', + listeners: { + checkchange: function ( + _column, + _rowIndex, + _checked, + record, + ) { + record.commit(); + }, + }, + }, + { + text: gettext('Slot'), + dataIndex: 'id', + sorted: true, + }, + { + text: gettext('Storage'), + xtype: 'widgetcolumn', + width: 150, + widget: { + xtype: 'pveStorageSelector', + isFormField: false, + autoSelect: false, + allowBlank: true, + emptyText: Proxmox.Utils.noneText, + storageContent: 'iso', + }, + onWidgetAttach: 'setNodename', + }, + { + text: gettext('ISO'), + dataIndex: 'file', + xtype: 'widgetcolumn', + flex: 1, + widget: { + xtype: 'pveFileSelector', + name: 'file', + isFormField: false, + allowBlank: true, + emptyText: Proxmox.Utils.noneText, + storageContent: 'iso', + }, + onWidgetAttach: 'setNodename', + }, + ], + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Network Interfaces'), + labelWidth: 200, + style: { + paddingTop: '10px', + }, + }, + { + xtype: 'grid', + minHeight: 58, + maxHeight: 150, + reference: 'netGrid', + store: { + data: [], + sorters: ['id'], + }, + columns: [ + { + xtype: 'checkcolumn', + header: gettext('Use'), + width: 50, + dataIndex: 'enable', + listeners: { + checkchange: function ( + _column, + _rowIndex, + _checked, + record, + ) { + record.commit(); + }, + }, + }, + { + text: gettext('ID'), + dataIndex: 'id', + }, + { + text: gettext('MAC address'), + flex: 7, + dataIndex: 'macaddr', + renderer: 'renderMacAddress', + }, + { + text: gettext('Model'), + flex: 7, + dataIndex: 'model', + xtype: 'widgetcolumn', + widget: { + xtype: 'pveNetworkCardSelector', + name: 'model', + isFormField: false, + allowBlank: false, + }, + }, + { + text: gettext('Bridge'), + dataIndex: 'bridge', + xtype: 'widgetcolumn', + flex: 6, + widget: { + xtype: 'PVE.form.BridgeSelector', + name: 'bridge', + isFormField: false, + autoSelect: false, + allowBlank: true, + emptyText: gettext('From Default'), + }, + onWidgetAttach: 'setNodename', + }, + { + text: gettext('VLAN Tag'), + dataIndex: 'tag', + xtype: 'widgetcolumn', + flex: 5, + widget: { + xtype: 'pveVlanField', + fieldLabel: undefined, + name: 'tag', + isFormField: false, + allowBlank: true, + }, + }, + ], + }, + { + xtype: 'proxmoxcheckbox', + name: 'uniqueMACs', + boxLabel: gettext('Unique MAC addresses'), + uncheckedValue: false, + value: false, + }, + ], + }, + { + title: gettext('Resulting Config'), + reference: 'summaryTab', + items: [ + { + xtype: 'grid', + reference: 'summaryGrid', + maxHeight: 400, + scrollable: true, + store: { + model: 'KeyValue', + sorters: [ + { + property: 'key', + direction: 'ASC', + }, + ], + }, + columns: [ + { header: 'Key', width: 150, dataIndex: 'key' }, + { header: 'Value', flex: 1, dataIndex: 'value' }, + ], + }, + ], + }, + ], + }, + ], + + initComponent: function () { + let me = this; + + if (!me.volumeName) { + throw 'no volumeName given'; + } + + if (!me.storage) { + throw 'no storage given'; + } + + if (!me.nodename) { + throw 'no nodename given'; + } + + me.callParent(); + + me.setTitle( + Ext.String.format(gettext('Import Guest - {0}'), `${me.storage}:${me.volumeName}`), + ); + + me.lookup('defaultStorage').setNodename(me.nodename); + me.lookup('defaultBridge').setNodename(me.nodename); + me.lookup('extractionStorage').setNodename(me.nodename); + + let renderWarning = (w) => { + const warningsCatalogue = { + 'cdrom-image-ignored': gettext( + "CD-ROM images cannot get imported, if required you can reconfigure the '{0}' drive in the 'Advanced' tab.", + ), + 'nvme-unsupported': gettext( + "NVMe disks are currently not supported, '{0}' will get attached as SCSI", + ), + 'ovmf-with-lsi-unsupported': gettext( + "OVMF is built without LSI drivers, scsi hardware was set to '{1}'", + ), + 'serial-port-socket-only': gettext( + "Serial socket '{0}' will be mapped to a socket", + ), + 'guest-is-running': gettext( + 'Virtual guest seems to be running on source host. Import might fail or have inconsistent state!', + ), + 'efi-state-lost': Ext.String.format( + gettext( + 'EFI state cannot be imported, you may need to reconfigure the boot order (see {0})', + ), + 'OVMF/UEFI Boot Entries', + ), + 'ova-needs-extracting': gettext( + 'Importing an OVA temporarily requires extra space on the working storage while extracting the contained disks for further processing.', + ), + }; + let message = warningsCatalogue[w.type]; + if (!w.type || !message) { + return w.message ?? w.type ?? gettext('Unknown warning'); + } + return Ext.String.format(message, w.key ?? 'unknown', w.value ?? 'unknown'); + }; + + me.load({ + success: function (response) { + let data = response.result.data; + me.vmConfig = data['create-args']; + + let disks = []; + for (const [id, value] of Object.entries(data.disks ?? {})) { + let volid = Ext.htmlEncode(''); + let size = 'auto'; + if (Ext.isObject(value)) { + volid = value.volid; + size = value.size; + } + disks.push({ + id, + enable: true, + size, + 'import-from': volid, + format: 'raw', + }); + } + + let nets = []; + for (const [id, parsed] of Object.entries(data.net ?? {})) { + parsed.id = id; + parsed.enable = true; + nets.push(parsed); + } + + let cdroms = []; + for (const [id, value] of Object.entries(me.vmConfig)) { + if (!Ext.isString(value) || !value.match(/media=cdrom/)) { + continue; + } + cdroms.push({ + enable: true, + hidden: false, + id, + }); + delete me.vmConfig[id]; + } + + me.lookup('diskGrid').getStore().setData(disks); + me.lookup('netGrid').getStore().setData(nets); + me.lookup('cdGrid').getStore().setData(cdroms); + + let additionalCdIdx = me.getController().calculateAdditionalCDIdx(); + if (additionalCdIdx === '') { + me.getViewModel().set('maxCdDrives', true); + } else if (cdroms.length === 0) { + me.additionalCdIdx = additionalCdIdx; + me.lookup('cdGrid') + .getStore() + .add({ + enable: true, + hidden: !(me.vmConfig.ostype ?? '').startsWith('w'), + id: additionalCdIdx, + autogenerated: true, + }); + } + + me.getViewModel().set( + 'warnings', + data.warnings.map((w) => renderWarning(w)), + ); + me.getViewModel().set( + 'isOva', + data.warnings.map((w) => w.type).indexOf('ova-needs-extracting') !== -1, + ); + + let osinfo = PVE.Utils.get_kvm_osinfo(me.vmConfig.ostype ?? ''); + let prepareForVirtIO = + (me.vmConfig.ostype ?? '').startsWith('w') && + (me.vmConfig.bios ?? '').indexOf('ovmf') !== -1; + + me.setValues({ + osbase: osinfo.base, + ...me.vmConfig, + }); + + me.lookup('prepareForVirtIO').setValue(prepareForVirtIO); + }, + }); + }, +}); +Ext.define( + 'PVE.ha.FencingView', + { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveFencingView'], + + onlineHelp: 'ha_manager_fencing', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-ha-fencing', + data: [], + }); + + Ext.apply(me, { + store: store, + stateful: false, + viewConfig: { + trackOver: false, + deferEmptyText: false, + emptyText: gettext('Use watchdog based fencing.'), + }, + columns: [ + { + header: gettext('Node'), + width: 100, + sortable: true, + dataIndex: 'node', + }, + { + header: gettext('Command'), + flex: 1, + dataIndex: 'command', + }, + ], + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-ha-fencing', { + extend: 'Ext.data.Model', + fields: ['node', 'command', 'digest'], + }); + }, +); +Ext.define('PVE.ha.VMResourceInputPanel', { + extend: 'Proxmox.panel.InputPanel', + onlineHelp: 'ha_manager_resource_config', + vmid: undefined, + + onGetValues: function (values) { + var me = this; + + if (values.vmid) { + values.sid = values.vmid; + } + delete values.vmid; + + PVE.Utils.delete_if_default(values, 'failback', '1', me.isCreate); + PVE.Utils.delete_if_default(values, 'max_restart', '1', me.isCreate); + PVE.Utils.delete_if_default(values, 'max_relocate', '1', me.isCreate); + + return values; + }, + + initComponent: function () { + var me = this; + var MIN_QUORUM_VOTES = 3; + + var disabledHint = Ext.createWidget({ + xtype: 'displayfield', // won't get submitted by default + userCls: 'pmx-hint', + value: + 'Disabling the resource will stop the guest system. ' + + 'See the online help for details.', + hidden: true, + }); + + var fewVotesHint = Ext.createWidget({ + itemId: 'fewVotesHint', + xtype: 'displayfield', + userCls: 'pmx-hint', + value: 'At least three quorum votes are recommended for reliable HA.', + hidden: true, + }); + + Proxmox.Utils.API2Request({ + url: '/cluster/config/nodes', + method: 'GET', + failure: function (response) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response) { + var nodes = response.result.data; + var votes = 0; + Ext.Array.forEach(nodes, function (node) { + var vote = parseInt(node.quorum_votes, 10); // parse as base 10 + votes += vote || 0; // parseInt might return NaN, which is false + }); + + if (votes < MIN_QUORUM_VOTES) { + fewVotesHint.setVisible(true); + } + }, + }); + + var vmidStore = me.vmid + ? {} + : { + model: 'PVEResources', + autoLoad: true, + sorters: 'vmid', + filters: [ + { + property: 'type', + value: /lxc|qemu/, + }, + { + property: 'hastate', + value: /unmanaged/, + }, + ], + }; + + // value is a string above, but a number below + me.column1 = [ + { + xtype: me.vmid ? 'displayfield' : 'vmComboSelector', + submitValue: me.isCreate, + name: 'vmid', + fieldLabel: me.vmid && me.guestType === 'ct' ? 'CT' : 'VM', + value: me.vmid, + store: vmidStore, + allowBlank: false, + validateExists: true, + }, + { + xtype: 'proxmoxintegerfield', + name: 'max_restart', + fieldLabel: gettext('Max. Restart'), + value: 1, + minValue: 0, + maxValue: 10, + allowBlank: false, + }, + { + xtype: 'proxmoxintegerfield', + name: 'max_relocate', + fieldLabel: gettext('Max. Relocate'), + value: 1, + minValue: 0, + maxValue: 10, + allowBlank: false, + }, + ]; + + me.column2 = [ + { + xtype: 'proxmoxcheckbox', + name: 'failback', + fieldLabel: gettext('Failback'), + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Enable if HA resource should automatically adjust to HA rules.', + ), + }, + uncheckedValue: 0, + value: 1, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'state', + value: 'started', + fieldLabel: gettext('Request State'), + comboItems: [ + ['started', 'started'], + ['stopped', 'stopped'], + ['ignored', 'ignored'], + ['disabled', 'disabled'], + ], + listeners: { + change: function (field, newValue) { + if (newValue === 'disabled') { + disabledHint.setVisible(true); + } else if (disabledHint.isVisible()) { + disabledHint.setVisible(false); + } + }, + }, + }, + disabledHint, + ]; + + me.columnB = [ + { + xtype: 'textfield', + name: 'comment', + fieldLabel: gettext('Comment'), + }, + fewVotesHint, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.ha.VMResourceEdit', { + extend: 'Proxmox.window.Edit', + + vmid: undefined, + guestType: undefined, + isCreate: undefined, + defaultFocus: undefined, + + initComponent: function () { + var me = this; + + if (me.isCreate === undefined) { + me.isCreate = !me.vmid; + } + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/ha/resources'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/cluster/ha/resources/' + me.vmid; + me.method = 'PUT'; + } + + var ipanel = Ext.create('PVE.ha.VMResourceInputPanel', { + isCreate: me.isCreate, + vmid: me.vmid, + guestType: me.guestType, + }); + + Ext.apply(me, { + subject: + gettext('Resource') + + ': ' + + gettext('Container') + + '/' + + gettext('Virtual Machine'), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + + var regex = /^(\S+):(\S+)$/; + var res = regex.exec(values.sid); + + if (res[1] !== 'vm' && res[1] !== 'ct') { + throw 'got unexpected resource type'; + } + + values.vmid = res[2]; + + ipanel.setValues(values); + }, + }); + } + }, +}); +Ext.define('PVE.ha.ResourcesView', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveHAResourcesView'], + + onlineHelp: 'ha_manager_resources', + + stateful: true, + stateId: 'grid-ha-resources', + + initComponent: function () { + let me = this; + + if (!me.rstore) { + throw 'no store given'; + } + + Proxmox.Utils.monStoreErrors(me, me.rstore); + let store = Ext.create('Proxmox.data.DiffStore', { + rstore: me.rstore, + filters: { + property: 'type', + value: 'service', + }, + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + let sid = rec.data.sid; + + let res = sid.match(/^(\S+):(\S+)$/); + if (!res || (res[1] !== 'vm' && res[1] !== 'ct')) { + console.warn(`unknown HA service ID type ${sid}`); + return; + } + let [, guestType, vmid] = res; + Ext.create('PVE.ha.VMResourceEdit', { + guestType: guestType, + vmid: vmid, + listeners: { + destroy: () => me.rstore.load(), + }, + autoShow: true, + }); + }; + + let caps = Ext.state.Manager.get('GuiCap'); + + Ext.apply(me, { + store: store, + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [ + { + text: gettext('Add'), + disabled: !caps.nodes['Sys.Console'], + handler: function () { + Ext.create('PVE.ha.VMResourceEdit', { + listeners: { + destroy: () => me.rstore.load(), + }, + autoShow: true, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }, + { + xtype: 'proxmoxButton', + text: gettext('Remove'), + selModel: sm, + itemId: 'removeBtn', + disabled: true, + handler: function (btn, e, rec) { + Ext.create('PVE.window.ConfirmRemoveResource', { + url: `/cluster/ha/resources/${rec.data.sid}`, + item: { + id: rec.data.sid, + }, + apiCallDone: () => me.rstore.load(), + }).show(); + }, + }, + ], + columns: [ + { + header: 'ID', + width: 100, + sortable: true, + dataIndex: 'sid', + }, + { + header: gettext('State'), + width: 100, + sortable: true, + dataIndex: 'state', + }, + { + header: gettext('Node'), + width: 100, + sortable: true, + dataIndex: 'node', + }, + { + header: gettext('Request State'), + width: 100, + hidden: true, + sortable: true, + renderer: (v) => v || 'started', + dataIndex: 'request_state', + }, + { + header: gettext('CRM State'), + width: 100, + hidden: true, + sortable: true, + dataIndex: 'crm_state', + }, + { + header: gettext('Name'), + width: 100, + sortable: true, + dataIndex: 'vname', + }, + { + header: gettext('Max. Restart'), + width: 100, + sortable: true, + renderer: (v) => (v === undefined ? '1' : v), + dataIndex: 'max_restart', + }, + { + header: gettext('Max. Relocate'), + width: 100, + sortable: true, + renderer: (v) => (v === undefined ? '1' : v), + dataIndex: 'max_relocate', + }, + { + header: gettext('Failback'), + width: 100, + sortable: true, + dataIndex: 'failback', + }, + { + header: gettext('Description'), + flex: 1, + renderer: Ext.String.htmlEncode, + dataIndex: 'comment', + }, + ], + listeners: { + beforeselect: (grid, record, index, eOpts) => caps.nodes['Sys.Console'], + itemdblclick: run_editor, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.ha.RuleInputPanel', { + extend: 'Proxmox.panel.InputPanel', + + onlineHelp: 'ha_manager_rules', + + formatResourceListString: function (resources) { + let me = this; + + return resources.map((vmid) => { + if (me.resourcesStore.getById(`qemu/${vmid}`)) { + return `vm:${vmid}`; + } else if (me.resourcesStore.getById(`lxc/${vmid}`)) { + return `ct:${vmid}`; + } else { + Ext.Msg.alert(gettext('Error'), `Could not find resource type for ${vmid}`); + throw `Unknown resource type: ${vmid}`; + } + }); + }, + + onGetValues: function (values) { + let me = this; + + values.type = me.ruleType; + + if (me.isCreate) { + values.rule = 'ha-rule-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); + } + + if (values.enable) { + if (!me.isCreate) { + Proxmox.Utils.assemble_field_data(values, { delete: 'disable' }); + } + } else { + values.disable = 1; + } + delete values.enable; + + values.resources = me.formatResourceListString(values.resources); + + return values; + }, + + initComponent: function () { + let me = this; + + let resourcesStore = Ext.create('Ext.data.Store', { + model: 'PVEResources', + autoLoad: true, + sorters: 'vmid', + filters: [ + { + property: 'type', + value: /lxc|qemu/, + }, + { + property: 'hastate', + operator: '!=', + value: 'unmanaged', + }, + ], + }); + + Ext.apply(me, { + resourcesStore: resourcesStore, + }); + + me.column1 = me.column1 ?? []; + me.column1.unshift( + { + xtype: 'proxmoxcheckbox', + name: 'enable', + fieldLabel: gettext('Enable'), + uncheckedValue: 0, + defaultValue: 1, + checked: true, + }, + { + xtype: 'vmComboSelector', + name: 'resources', + fieldLabel: gettext('HA Resources'), + store: me.resourcesStore, + allowBlank: false, + autoSelect: false, + multiSelect: true, + validateExists: true, + }, + ); + + me.column2 = me.column2 ?? []; + + me.columnB = me.columnB ?? []; + me.columnB.unshift({ + xtype: 'textfield', + name: 'comment', + fieldLabel: gettext('Comment'), + allowBlank: true, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.ha.RuleEdit', { + extend: 'Proxmox.window.Edit', + + defaultFocus: undefined, // prevent the vmComboSelector to be expanded when focusing the window + + initComponent: function () { + let me = this; + + me.isCreate = !me.ruleId; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/ha/rules'; + me.method = 'POST'; + } else { + me.url = `/api2/extjs/cluster/ha/rules/${me.ruleId}`; + me.method = 'PUT'; + } + + let inputPanel = Ext.create(me.panelType, { + ruleId: me.ruleId, + ruleType: me.ruleType, + isCreate: me.isCreate, + }); + + Ext.apply(me, { + subject: me.panelName, + isAdd: true, + items: [inputPanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: (response, options) => { + let values = response.result.data; + + values.resources = values.resources + .split(',') + .map((resource) => resource.split(':')[1]); + + values.enable = values.disable ? 0 : 1; + + inputPanel.setValues(values); + }, + }); + } + }, +}); +Ext.define('PVE.ha.RuleErrorsModal', { + extend: 'Ext.window.Window', + alias: ['widget.pveHARulesErrorsModal'], + mixins: ['Proxmox.Mixin.CBind'], + + modal: true, + scrollable: true, + resizable: false, + + title: gettext('HA rule errors'), + + initComponent: function () { + let me = this; + + let renderHARuleErrors = (errors) => { + if (!errors) { + return gettext('The HA rule has no errors.'); + } + + let errorListItemsHtml = ''; + + for (let [opt, messages] of Object.entries(errors)) { + errorListItemsHtml += messages + .map((message) => `
  • ${Ext.htmlEncode(`${opt}: ${message}`)}
  • `) + .join(''); + } + + return `
    +

    ${gettext('The HA rule has the following errors:')}

    +
      ${errorListItemsHtml}
    +
    `; + }; + + Ext.apply(me, { + modal: true, + border: false, + layout: 'fit', + items: [ + { + xtype: 'displayfield', + padding: 20, + scrollable: true, + value: renderHARuleErrors(me.errors), + }, + ], + }); + + me.callParent(); + }, +}); +Ext.define('pve-ha-rules', { + extend: 'Ext.data.Model', + fields: [ + 'rule', + 'type', + 'nodes', + 'digest', + 'errors', + 'disable', + 'comment', + 'affinity', + 'resources', + { + name: 'strict', + type: 'boolean', + }, + ], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/ha/rules', + }, + idProperty: 'rule', +}); +Ext.define('pve-ha-rules-memory', { + extend: 'pve-ha-rules', + proxy: { + type: 'memory', + }, +}); + +Ext.define('PVE.ha.RulesBaseView', { + extend: 'Ext.grid.GridPanel', + mixins: ['Proxmox.Mixin.CBind'], + + store: { + model: 'pve-ha-rules-memory', + cbind: {}, // empty cbind to ensure mixin iterates into filter array. + filters: [ + { + property: 'type', + cbind: { + value: '{ruleType}', + }, + }, + ], + }, + + initComponent: function () { + let me = this; + + if (!me.ruleType) { + throw 'no rule type given'; + } + + let reloadStore = () => me.up('pveHARulesView').store.load(); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let createRuleEditWindow = (ruleId) => { + if (!me.inputPanel) { + throw `no editor registered for ha rule type: ${me.ruleType}`; + } + + Ext.create('PVE.ha.RuleEdit', { + panelType: `PVE.ha.rules.${me.inputPanel}`, + panelName: me.ruleTitle, + ruleType: me.ruleType, + ruleId: ruleId, + autoShow: true, + listeners: { + destroy: reloadStore, + }, + }); + }; + + let runEditor = () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let { rule } = rec.data; + createRuleEditWindow(rule); + }; + + let childColumns = me.columns || []; + + Ext.apply(me, { + selModel: sm, + viewConfig: { + trackOver: false, + }, + emptyText: Ext.String.format(gettext('No {0} rules configured.'), me.ruleTitle), + tbar: [ + { + text: gettext('Add'), + handler: () => createRuleEditWindow(), + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: runEditor, + }, + { + xtype: 'proxmoxStdRemoveButton', + selModel: sm, + baseurl: '/cluster/ha/rules/', + callback: reloadStore, + }, + ], + columns: [ + { + header: gettext('ID'), + dataIndex: 'rule', + width: 160, + hidden: true, + sortable: true, + }, + { + header: gettext('Enabled'), + width: 80, + dataIndex: 'disable', + align: 'center', + renderer: (value) => Proxmox.Utils.renderEnabledIcon(!value), + sortable: true, + }, + { + header: gettext('State'), + xtype: 'actioncolumn', + width: 65, + align: 'center', + dataIndex: 'errors', + items: [ + { + handler: (table, rowIndex, colIndex, item, event, { data }) => { + if (Object.keys(data.errors ?? {}.length)) { + Ext.create('PVE.ha.RuleErrorsModal', { + autoShow: true, + errors: data.errors, + }); + } + }, + getTip: (value) => + Object.keys(value ?? {}).length + ? gettext('HA Rule has conflicts and/or errors.') + : gettext('HA Rule is OK.'), + getClass: (value) => + Object.keys(value ?? {}).length + ? 'fa fa-exclamation-triangle' + : 'fa fa-check pmx-unclickable', + }, + ], + }, + ...childColumns, + { + header: gettext('Comment'), + flex: 1, + renderer: Ext.String.htmlEncode, + dataIndex: 'comment', + }, + ], + listeners: { + activate: reloadStore, + itemdblclick: runEditor, + }, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.ha.RulesView', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveHARulesView', + + onlineHelp: 'ha_manager_rules', + + layout: { + type: 'vbox', + align: 'stretch', + }, + + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + view.store = new Ext.data.Store({ + model: 'pve-ha-rules', + storeId: 'pve-ha-rules', + autoLoad: true, + }); + view.store.on('load', this.onStoreLoad, this); + }, + + onStoreLoad: function (store, records, success) { + let me = this; + let view = me.getView(); + + for (const grid of view.query('grid[ruleType]')) { + grid.getStore().setRecords(records); + } + }, + }, + + items: [ + { + title: gettext('HA Node Affinity Rules'), + xtype: 'pveHANodeAffinityRulesView', + flex: 2, + border: 0, + }, + { + xtype: 'splitter', + collapsible: false, + performCollapse: false, + }, + { + title: gettext('HA Resource Affinity Rules'), + xtype: 'pveHAResourceAffinityRulesView', + flex: 3, + border: 0, + }, + ], +}); +Ext.define('PVE.ha.Status', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveHAStatus', + + onlineHelp: 'chapter_ha_manager', + layout: { + type: 'vbox', + align: 'stretch', + }, + + initComponent: function () { + var me = this; + + me.rstore = Ext.create('Proxmox.data.ObjectStore', { + interval: me.interval, + model: 'pve-ha-status', + storeid: 'pve-store-' + ++Ext.idSeed, + groupField: 'type', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/ha/status/current', + }, + }); + + me.items = [ + { + xtype: 'pveHAStatusView', + title: gettext('Status'), + rstore: me.rstore, + border: 0, + collapsible: true, + padding: '0 0 20 0', + }, + { + xtype: 'pveHAResourcesView', + flex: 1, + collapsible: true, + title: gettext('Resources'), + border: 0, + rstore: me.rstore, + }, + ]; + + me.callParent(); + me.on('activate', me.rstore.startUpdate); + }, +}); +Ext.define( + 'PVE.ha.StatusView', + { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveHAStatusView'], + + onlineHelp: 'chapter_ha_manager', + + sortPriority: { + quorum: 1, + master: 2, + lrm: 3, + service: 4, + }, + + initComponent: function () { + var me = this; + + if (!me.rstore) { + throw 'no rstore given'; + } + + Proxmox.Utils.monStoreErrors(me, me.rstore); + + var store = Ext.create('Proxmox.data.DiffStore', { + rstore: me.rstore, + sortAfterUpdate: true, + sorters: [ + { + sorterFn: function (rec1, rec2) { + var p1 = me.sortPriority[rec1.data.type]; + var p2 = me.sortPriority[rec2.data.type]; + return p1 !== p2 ? (p1 > p2 ? 1 : -1) : 0; + }, + }, + ], + filters: { + property: 'type', + value: 'service', + operator: '!=', + }, + }); + + Ext.apply(me, { + store: store, + stateful: false, + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: gettext('Type'), + width: 80, + dataIndex: 'type', + }, + { + header: gettext('Status'), + width: 80, + flex: 1, + dataIndex: 'status', + }, + ], + }); + + me.callParent(); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + }, + }, + function () { + Ext.define('pve-ha-status', { + extend: 'Ext.data.Model', + fields: [ + 'id', + 'type', + 'node', + 'status', + 'sid', + 'state', + 'comment', + { + name: 'failback', + type: 'boolean', + }, + 'max_restart', + 'max_relocate', + 'type', + 'crm_state', + 'request_state', + { + name: 'vname', + convert: function (value, record) { + let sid = record.data.sid; + if (!sid) { + return ''; + } + + let res = sid.match(/^(\S+):(\S+)$/); + if (res[1] !== 'vm' && res[1] !== 'ct') { + return '-'; + } + let vmid = res[2]; + return PVE.data.ResourceStore.guestName(vmid); + }, + }, + ], + idProperty: 'id', + }); + }, +); +Ext.define('PVE.ha.rules.NodeAffinityInputPanel', { + extend: 'PVE.ha.RuleInputPanel', + + initComponent: function () { + let me = this; + + /* TODO Node selector should be factored out in its own component */ + let update_nodefield, update_node_selection; + + let sm = Ext.create('Ext.selection.CheckboxModel', { + mode: 'SIMPLE', + listeners: { + selectionchange: function (model, selected) { + update_nodefield(selected); + }, + }, + }); + + let store = Ext.create('Ext.data.Store', { + fields: ['node', 'mem', 'cpu', 'priority'], + data: PVE.data.ResourceStore.getNodes(), // use already cached data to avoid an API call + proxy: { + type: 'memory', + reader: { type: 'json' }, + }, + sorters: [ + { + property: 'node', + direction: 'ASC', + }, + ], + }); + + var nodegrid = Ext.createWidget('grid', { + store: store, + border: true, + height: 300, + selModel: sm, + columns: [ + { + header: gettext('Node'), + flex: 1, + dataIndex: 'node', + }, + { + header: gettext('Memory usage') + ' %', + renderer: PVE.Utils.render_mem_usage_percent, + sortable: true, + width: 150, + dataIndex: 'mem', + }, + { + header: gettext('CPU usage'), + renderer: Proxmox.Utils.render_cpu, + sortable: true, + width: 150, + dataIndex: 'cpu', + }, + { + header: gettext('Priority'), + xtype: 'widgetcolumn', + dataIndex: 'priority', + sortable: true, + stopSelection: true, + widget: { + xtype: 'proxmoxintegerfield', + minValue: 0, + maxValue: 1000, + isFormField: false, + listeners: { + change: function (numberfield, value, old_value) { + let record = numberfield.getWidgetRecord(); + record.set('priority', value); + update_nodefield(sm.getSelection()); + record.commit(); + }, + }, + }, + }, + ], + }); + + let nodefield = Ext.create('Ext.form.field.Hidden', { + name: 'nodes', + value: '', + listeners: { + change: function (field, value) { + update_node_selection(value); + }, + }, + isValid: function () { + let value = this.getValue(); + return value && value.length !== 0; + }, + }); + + update_node_selection = function (string) { + sm.deselectAll(true); + + string.split(',').forEach(function (e, idx, array) { + let [node, priority] = e.split(':'); + store.each(function (record) { + if (record.get('node') === node) { + sm.select(record, true); + record.set('priority', priority); + record.commit(); + } + }); + }); + nodegrid.reconfigure(store); + }; + + update_nodefield = function (selected) { + let nodes = selected + .map(({ data }) => data.node + (data.priority ? `:${data.priority}` : '')) + .join(','); + + // nodefield change listener calls us again, which results in a + // endless recursion, suspend the event temporary to avoid this + nodefield.suspendEvent('change'); + nodefield.setValue(nodes); + nodefield.resumeEvent('change'); + }; + + me.column2 = [ + { + xtype: 'proxmoxcheckbox', + name: 'strict', + fieldLabel: gettext('Strict'), + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Enable if the HA Resources must be restricted to the nodes.', + ), + }, + uncheckedValue: 0, + defaultValue: 0, + }, + nodefield, + ]; + + me.columnB = [nodegrid]; + + me.callParent(); + }, +}); +Ext.define('PVE.ha.NodeAffinityRulesView', { + extend: 'PVE.ha.RulesBaseView', + alias: 'widget.pveHANodeAffinityRulesView', + + ruleType: 'node-affinity', + ruleTitle: gettext('HA Node Affinity'), + inputPanel: 'NodeAffinityInputPanel', + faIcon: 'map-pin', + + stateful: true, + stateId: 'grid-ha-node-affinity-rules', + + columns: [ + { + header: gettext('Strict'), + width: 75, + dataIndex: 'strict', + }, + { + header: gettext('HA Resources'), + flex: 3, + dataIndex: 'resources', + }, + { + header: gettext('Nodes'), + flex: 2, + dataIndex: 'nodes', + }, + ], +}); +Ext.define('PVE.ha.rules.ResourceAffinityInputPanel', { + extend: 'PVE.ha.RuleInputPanel', + + initComponent: function () { + let me = this; + + me.column1 = []; + + me.column2 = [ + { + xtype: 'proxmoxKVComboBox', + name: 'affinity', + fieldLabel: gettext('Affinity'), + allowBlank: false, + comboItems: [ + ['positive', gettext('Keep Together')], + ['negative', gettext('Keep Separate')], + ], + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.ha.ResourceAffinityRulesView', { + extend: 'PVE.ha.RulesBaseView', + alias: 'widget.pveHAResourceAffinityRulesView', + + ruleType: 'resource-affinity', + ruleTitle: gettext('HA Resource Affinity'), + inputPanel: 'ResourceAffinityInputPanel', + faIcon: 'link', + + stateful: true, + stateId: 'grid-ha-resource-affinity-rules', + + columns: [ + { + header: gettext('Affinity'), + width: 100, + dataIndex: 'affinity', + }, + { + header: gettext('HA Resources'), + flex: 5, + dataIndex: 'resources', + }, + ], +}); +Ext.define('PVE.dc.ACLAdd', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveACLAdd'], + + url: '/access/acl', + method: 'PUT', + isAdd: true, + isCreate: true, + + width: 400, + + initComponent: function () { + let me = this; + + let items = [ + { + xtype: me.path ? 'hiddenfield' : 'pvePermPathSelector', + name: 'path', + value: me.path, + allowBlank: false, + fieldLabel: gettext('Path'), + }, + ]; + + if (me.aclType === 'group') { + me.subject = gettext('Group Permission'); + items.push({ + xtype: 'pveGroupSelector', + name: 'groups', + fieldLabel: gettext('Group'), + }); + } else if (me.aclType === 'user') { + me.subject = gettext('User Permission'); + items.push({ + xtype: 'pmxUserSelector', + name: 'users', + fieldLabel: gettext('User'), + }); + } else if (me.aclType === 'token') { + me.subject = gettext('API Token Permission'); + items.push({ + xtype: 'pveTokenSelector', + name: 'tokens', + fieldLabel: gettext('API Token'), + }); + } else { + throw 'unknown ACL type'; + } + + items.push({ + xtype: 'pmxRoleSelector', + name: 'roles', + value: 'NoAccess', + fieldLabel: gettext('Role'), + }); + + if (!me.path) { + items.push({ + xtype: 'proxmoxcheckbox', + name: 'propagate', + checked: true, + uncheckedValue: 0, + fieldLabel: gettext('Propagate'), + }); + } + + let ipanel = Ext.create('Proxmox.panel.InputPanel', { + items: items, + onlineHelp: 'pveum_permission_management', + }); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + }, +}); + +Ext.define( + 'PVE.dc.ACLView', + { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveACLView'], + + onlineHelp: 'chapter_user_management', + + stateful: true, + stateId: 'grid-acls', + + // use fixed path + path: undefined, + + initComponent: function () { + let me = this; + + let store = Ext.create('Ext.data.Store', { + model: 'pve-acl', + proxy: { + type: 'proxmox', + url: '/api2/json/access/acl', + }, + sorters: { + property: 'path', + direction: 'ASC', + }, + }); + + if (me.path) { + store.addFilter( + Ext.create('Ext.util.Filter', { + filterFn: (item) => item.data.path === me.path, + }), + ); + } + + let render_ugid = function (ugid, metaData, record) { + if (record.data.type === 'group') { + return '@' + ugid; + } + + return Ext.String.htmlEncode(ugid); + }; + + let columns = [ + { + header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'), + flex: 1, + sortable: true, + renderer: render_ugid, + dataIndex: 'ugid', + }, + { + header: gettext('Role'), + flex: 1, + sortable: true, + dataIndex: 'roleid', + }, + ]; + + if (!me.path) { + columns.unshift({ + header: gettext('Path'), + flex: 1, + sortable: true, + dataIndex: 'path', + }); + columns.push({ + header: gettext('Propagate'), + width: 80, + sortable: true, + dataIndex: 'propagate', + }); + } + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let remove_btn = new Proxmox.button.Button({ + text: gettext('Remove'), + disabled: true, + selModel: sm, + confirmMsg: gettext('Are you sure you want to remove this entry'), + handler: function (btn, event, rec) { + var params = { + delete: 1, + path: rec.data.path, + roles: rec.data.roleid, + }; + if (rec.data.type === 'group') { + params.groups = rec.data.ugid; + } else if (rec.data.type === 'user') { + params.users = rec.data.ugid; + } else if (rec.data.type === 'token') { + params.tokens = rec.data.ugid; + } else { + throw 'unknown data type'; + } + + Proxmox.Utils.API2Request({ + url: '/access/acl', + params: params, + method: 'PUT', + waitMsgTarget: me, + callback: () => store.load(), + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, + }); + + Proxmox.Utils.monStoreErrors(me, store); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + { + text: gettext('Add'), + menu: { + xtype: 'menu', + items: [ + { + text: gettext('Group Permission'), + iconCls: 'fa fa-fw fa-group', + handler: function () { + var win = Ext.create('PVE.dc.ACLAdd', { + aclType: 'group', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + { + text: gettext('User Permission'), + iconCls: 'fa fa-fw fa-user', + handler: function () { + var win = Ext.create('PVE.dc.ACLAdd', { + aclType: 'user', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + { + text: gettext('API Token Permission'), + iconCls: 'fa fa-fw fa-user-o', + handler: function () { + let win = Ext.create('PVE.dc.ACLAdd', { + aclType: 'token', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + ], + }, + }, + remove_btn, + ], + viewConfig: { + trackOver: false, + }, + columns: columns, + listeners: { + activate: () => store.load(), + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-acl', { + extend: 'Ext.data.Model', + fields: [ + 'path', + 'type', + 'ugid', + 'roleid', + { + name: 'propagate', + type: 'boolean', + }, + ], + }); + }, +); +Ext.define('pve-acme-accounts', { + extend: 'Ext.data.Model', + fields: ['name'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/acme/account', + }, + idProperty: 'name', +}); + +Ext.define('pve-acme-plugins', { + extend: 'Ext.data.Model', + fields: ['type', 'plugin', 'api'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/acme/plugins', + }, + idProperty: 'plugin', +}); + +Ext.define('PVE.dc.ACMEClusterView', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveACMEClusterView', + + onlineHelp: 'sysadmin_certificate_management', + + items: [ + { + region: 'north', + border: false, + xtype: 'pmxACMEAccounts', + acmeUrl: '/cluster/acme', + }, + { + region: 'center', + border: false, + xtype: 'pmxACMEPluginView', + acmeUrl: '/cluster/acme', + }, + ], +}); +Ext.define('PVE.panel.AuthBase', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveAuthBasePanel', + + type: '', + + onGetValues: function (values) { + let me = this; + + if (!values.port) { + if (!me.isCreate) { + Proxmox.Utils.assemble_field_data(values, { delete: 'port' }); + } + delete values.port; + } + + if (me.isCreate) { + values.type = me.type; + } + + return values; + }, + + initComponent: function () { + let me = this; + + let options = PVE.Utils.authSchema[me.type]; + + if (!me.column1) { + me.column1 = []; + } + if (!me.column2) { + me.column2 = []; + } + if (!me.columnB) { + me.columnB = []; + } + + // first field is name + me.column1.unshift({ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'realm', + fieldLabel: gettext('Realm'), + value: me.realm, + allowBlank: false, + }); + + // last field is default' + me.column1.push({ + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Default'), + name: 'default', + uncheckedValue: 0, + }); + + if (options.tfa) { + // last field of column2is tfa + me.column2.push({ + xtype: 'pveTFASelector', + deleteEmpty: !me.isCreate, + }); + } + + me.columnB.push({ + xtype: 'textfield', + name: 'comment', + fieldLabel: gettext('Comment'), + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.dc.AuthEditBase', { + extend: 'Proxmox.window.Edit', + + onlineHelp: 'pveum_authentication_realms', + + isAdd: true, + + fieldDefaults: { + labelWidth: 120, + }, + + initComponent: function () { + var me = this; + + me.isCreate = !me.realm; + + if (me.isCreate) { + me.url = '/api2/extjs/access/domains'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/access/domains/' + me.realm; + me.method = 'PUT'; + } + + let authConfig = PVE.Utils.authSchema[me.authType]; + if (!authConfig) { + throw 'unknown auth type'; + } else if (!authConfig.add && me.isCreate) { + throw 'trying to add non addable realm'; + } + + me.subject = authConfig.name; + + let items; + let bodyPadding; + if (authConfig.syncipanel) { + bodyPadding = 0; + items = { + xtype: 'tabpanel', + region: 'center', + layout: 'fit', + bodyPadding: 10, + items: [ + { + title: gettext('General'), + realm: me.realm, + xtype: authConfig.ipanel, + isCreate: me.isCreate, + type: me.authType, + }, + { + title: gettext('Sync Options'), + realm: me.realm, + xtype: authConfig.syncipanel, + isCreate: me.isCreate, + type: me.authType, + }, + ], + }; + } else { + items = [ + { + realm: me.realm, + xtype: authConfig.ipanel, + isCreate: me.isCreate, + type: me.authType, + }, + ]; + } + + Ext.apply(me, { + items, + bodyPadding, + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var data = response.result.data || {}; + // just to be sure (should not happen) + if (data.type !== me.authType) { + me.close(); + throw 'got wrong auth type'; + } + me.setValues(data); + }, + }); + } + }, +}); +Ext.define('PVE.panel.ADInputPanel', { + extend: 'PVE.panel.AuthBase', + xtype: 'pveAuthADPanel', + + initComponent: function () { + let me = this; + + if (me.type !== 'ad') { + throw 'invalid type'; + } + + me.column1 = [ + { + xtype: 'textfield', + name: 'domain', + fieldLabel: gettext('Domain'), + emptyText: 'company.net', + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Case-Sensitive'), + name: 'case-sensitive', + uncheckedValue: 0, + checked: true, + }, + ]; + + me.column2 = [ + { + xtype: 'textfield', + fieldLabel: gettext('Server'), + name: 'server1', + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Fallback Server'), + deleteEmpty: !me.isCreate, + name: 'server2', + }, + { + xtype: 'proxmoxintegerfield', + name: 'port', + fieldLabel: gettext('Port'), + minValue: 1, + maxValue: 65535, + emptyText: gettext('Default'), + submitEmptyText: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'mode', + fieldLabel: gettext('Mode'), + editable: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'], + ['ldap', 'LDAP'], + ['ldap+starttls', 'STARTTLS'], + ['ldaps', 'LDAPS'], + ], + value: '__default__', + deleteEmpty: !me.isCreate, + listeners: { + change: function (field, newValue) { + let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]'); + if (newValue === 'ldap' || newValue === '__default__') { + verifyCheckbox.disable(); + verifyCheckbox.setValue(0); + } else { + verifyCheckbox.enable(); + } + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Verify Certificate'), + name: 'verify', + uncheckedValue: 0, + disabled: true, + checked: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Verify TLS certificate of the server'), + }, + }, + ]; + + me.advancedItems = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Check connection'), + name: 'check-connection', + uncheckedValue: 0, + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Verify connection parameters and bind credentials on save', + ), + }, + }, + ]; + + me.callParent(); + }, + onGetValues: function (values) { + let me = this; + + if (!values.verify) { + if (!me.isCreate) { + Proxmox.Utils.assemble_field_data(values, { delete: 'verify' }); + } + delete values.verify; + } + + if (!me.isCreate) { + // Delete old `secure` parameter. It has been deprecated in favor to the + // `mode` parameter. Migration happens automatically in `onSetValues`. + Proxmox.Utils.assemble_field_data(values, { delete: 'secure' }); + } + + return me.callParent([values]); + }, + + onSetValues(values) { + let me = this; + + if (values.secure !== undefined && !values.mode) { + // If `secure` is set, use it to determine the correct setting for `mode` + // `secure` is later deleted by `onSetValues` . + // In case *both* are set, we simply ignore `secure` and use + // whatever `mode` is set to. + values.mode = values.secure ? 'ldaps' : 'ldap'; + } + + return me.callParent([values]); + }, +}); +Ext.define('PVE.panel.LDAPInputPanel', { + extend: 'PVE.panel.AuthBase', + xtype: 'pveAuthLDAPPanel', + + initComponent: function () { + let me = this; + + if (me.type !== 'ldap') { + throw 'invalid type'; + } + + me.column1 = [ + { + xtype: 'textfield', + name: 'base_dn', + fieldLabel: gettext('Base Domain Name'), + emptyText: 'CN=Users,DC=Company,DC=net', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'user_attr', + emptyText: 'uid / sAMAccountName', + fieldLabel: gettext('User Attribute Name'), + allowBlank: false, + }, + ]; + + me.column2 = [ + { + xtype: 'textfield', + fieldLabel: gettext('Server'), + name: 'server1', + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Fallback Server'), + deleteEmpty: !me.isCreate, + name: 'server2', + }, + { + xtype: 'proxmoxintegerfield', + name: 'port', + fieldLabel: gettext('Port'), + minValue: 1, + maxValue: 65535, + emptyText: gettext('Default'), + submitEmptyText: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'mode', + fieldLabel: gettext('Mode'), + editable: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'], + ['ldap', 'LDAP'], + ['ldap+starttls', 'STARTTLS'], + ['ldaps', 'LDAPS'], + ], + value: '__default__', + deleteEmpty: !me.isCreate, + listeners: { + change: function (field, newValue) { + let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]'); + if (newValue === 'ldap' || newValue === '__default__') { + verifyCheckbox.disable(); + verifyCheckbox.setValue(0); + } else { + verifyCheckbox.enable(); + } + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Verify Certificate'), + name: 'verify', + uncheckedValue: 0, + disabled: true, + checked: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Verify TLS certificate of the server'), + }, + }, + ]; + + me.advancedItems = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Check connection'), + name: 'check-connection', + uncheckedValue: 0, + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Verify connection parameters and bind credentials on save', + ), + }, + }, + ]; + + me.callParent(); + }, + onGetValues: function (values) { + let me = this; + + if (!values.verify) { + if (!me.isCreate) { + Proxmox.Utils.assemble_field_data(values, { delete: 'verify' }); + } + delete values.verify; + } + + if (!me.isCreate) { + // Delete old `secure` parameter. It has been deprecated in favor to the + // `mode` parameter. Migration happens automatically in `onSetValues`. + Proxmox.Utils.assemble_field_data(values, { delete: 'secure' }); + } + + return me.callParent([values]); + }, + + onSetValues(values) { + let me = this; + + if (values.secure !== undefined && !values.mode) { + // If `secure` is set, use it to determine the correct setting for `mode` + // `secure` is later deleted by `onSetValues` . + // In case *both* are set, we simply ignore `secure` and use + // whatever `mode` is set to. + values.mode = values.secure ? 'ldaps' : 'ldap'; + } + + return me.callParent([values]); + }, +}); + +Ext.define('PVE.panel.LDAPSyncInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveAuthLDAPSyncPanel', + + editableAttributes: ['email'], + editableDefaults: ['scope', 'enable-new'], + default_opts: {}, + sync_attributes: {}, + + // (de)construct the sync-attributes from the list above, + // not touching all others + onGetValues: function (values) { + let me = this; + me.editableDefaults.forEach((attr) => { + if (values[attr]) { + me.default_opts[attr] = values[attr]; + delete values[attr]; + } else { + delete me.default_opts[attr]; + } + }); + let vanished_opts = []; + ['acl', 'entry', 'properties'].forEach((prop) => { + if (values[`remove-vanished-${prop}`]) { + vanished_opts.push(prop); + } + delete values[`remove-vanished-${prop}`]; + }); + me.default_opts['remove-vanished'] = vanished_opts.join(';'); + + values['sync-defaults-options'] = PVE.Parser.printPropertyString(me.default_opts); + me.editableAttributes.forEach((attr) => { + if (values[attr]) { + me.sync_attributes[attr] = values[attr]; + delete values[attr]; + } else { + delete me.sync_attributes[attr]; + } + }); + values.sync_attributes = PVE.Parser.printPropertyString(me.sync_attributes); + + PVE.Utils.delete_if_default(values, 'sync-defaults-options'); + PVE.Utils.delete_if_default(values, 'sync_attributes'); + + // Force values.delete to be an array + if (typeof values.delete === 'string') { + values.delete = values.delete.split(','); + } + + if (me.isCreate) { + delete values.delete; // on create we cannot delete values + } + + return values; + }, + + setValues: function (values) { + let me = this; + if (values.sync_attributes) { + me.sync_attributes = PVE.Parser.parsePropertyString(values.sync_attributes); + delete values.sync_attributes; + me.editableAttributes.forEach((attr) => { + if (me.sync_attributes[attr]) { + values[attr] = me.sync_attributes[attr]; + } + }); + } + if (values['sync-defaults-options']) { + me.default_opts = PVE.Parser.parsePropertyString(values['sync-defaults-options']); + delete values.default_opts; + me.editableDefaults.forEach((attr) => { + if (me.default_opts[attr]) { + values[attr] = me.default_opts[attr]; + } + }); + + if (me.default_opts['remove-vanished']) { + let opts = me.default_opts['remove-vanished'].split(';'); + for (const opt of opts) { + values[`remove-vanished-${opt}`] = 1; + } + } + } + return me.callParent([values]); + }, + + column1: [ + { + xtype: 'proxmoxtextfield', + name: 'bind_dn', + deleteEmpty: true, + emptyText: Proxmox.Utils.noneText, + fieldLabel: gettext('Bind User'), + }, + { + xtype: 'proxmoxtextfield', + inputType: 'password', + name: 'password', + emptyText: gettext('Unchanged'), + fieldLabel: gettext('Bind Password'), + }, + { + xtype: 'proxmoxtextfield', + name: 'email', + fieldLabel: gettext('E-Mail attribute'), + }, + { + xtype: 'proxmoxtextfield', + name: 'group_name_attr', + deleteEmpty: true, + fieldLabel: gettext('Groupname attr.'), + }, + { + xtype: 'displayfield', + value: gettext('Default Sync Options'), + }, + { + xtype: 'proxmoxKVComboBox', + name: 'scope', + emptyText: Proxmox.Utils.NoneText, + fieldLabel: gettext('Scope'), + value: '__default__', + deleteEmpty: false, + comboItems: [ + ['__default__', Proxmox.Utils.NoneText], + ['users', gettext('Users')], + ['groups', gettext('Groups')], + ['both', gettext('Users and Groups')], + ], + }, + ], + + column2: [ + { + xtype: 'proxmoxtextfield', + name: 'user_classes', + fieldLabel: gettext('User classes'), + deleteEmpty: true, + emptyText: 'inetorgperson, posixaccount, person, user', + }, + { + xtype: 'proxmoxtextfield', + name: 'group_classes', + fieldLabel: gettext('Group classes'), + deleteEmpty: true, + emptyText: 'groupOfNames, group, univentionGroup, ipausergroup', + }, + { + xtype: 'proxmoxtextfield', + name: 'filter', + fieldLabel: gettext('User Filter'), + deleteEmpty: true, + }, + { + xtype: 'proxmoxtextfield', + name: 'group_filter', + fieldLabel: gettext('Group Filter'), + deleteEmpty: true, + }, + { + // fake for spacing + xtype: 'displayfield', + value: ' ', + }, + { + xtype: 'proxmoxKVComboBox', + value: '__default__', + deleteEmpty: false, + comboItems: [ + [ + '__default__', + Ext.String.format( + gettext('{0} ({1})'), + Proxmox.Utils.yesText, + Proxmox.Utils.defaultText, + ), + ], + ['1', Proxmox.Utils.yesText], + ['0', Proxmox.Utils.noText], + ], + name: 'enable-new', + fieldLabel: gettext('Enable new users'), + }, + ], + + columnB: [ + { + xtype: 'fieldset', + title: gettext('Remove Vanished Options'), + items: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('ACL'), + name: 'remove-vanished-acl', + boxLabel: gettext('Remove ACLs of vanished users and groups.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Entry'), + name: 'remove-vanished-entry', + boxLabel: gettext('Remove vanished user and group entries.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Properties'), + name: 'remove-vanished-properties', + boxLabel: gettext('Remove vanished properties from synced users.'), + }, + ], + }, + ], +}); +Ext.define('PVE.panel.OpenIDInputPanel', { + extend: 'PVE.panel.AuthBase', + xtype: 'pveAuthOpenIDPanel', + mixins: ['Proxmox.Mixin.CBind'], + + onGetValues: function (values) { + let me = this; + + if (!values.verify) { + if (!me.isCreate) { + Proxmox.Utils.assemble_field_data(values, { delete: 'verify' }); + } + delete values.verify; + } + + return me.callParent([values]); + }, + + columnT: [ + { + xtype: 'textfield', + name: 'issuer-url', + fieldLabel: gettext('Issuer URL'), + allowBlank: false, + }, + ], + + column1: [ + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Client ID'), + name: 'client-id', + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Client Key'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + name: 'client-key', + }, + { + xtype: 'proxmoxtextfield', + name: 'scopes', + fieldLabel: gettext('Scopes'), + emptyText: `${Proxmox.Utils.defaultText} (email profile)`, + submitEmpty: false, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + + column2: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Autocreate Users'), + name: 'autocreate', + value: 0, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'pmxDisplayEditField', + name: 'username-claim', + fieldLabel: gettext('Username Claim'), + editConfig: { + xtype: 'proxmoxKVComboBox', + editable: true, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['subject', 'subject'], + ['username', 'username'], + ['email', 'email'], + ], + }, + cbind: { + value: (get) => (get('isCreate') ? '__default__' : Proxmox.Utils.defaultText), + deleteEmpty: '{!isCreate}', + editable: '{isCreate}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Autocreate Groups'), + name: 'groups-autocreate', + value: 0, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'groups-claim', + fieldLabel: gettext('Groups Claim'), + emptyText: `${Proxmox.Utils.defaultText} ${gettext('(none)')}`, + submitEmpty: false, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Overwrite Groups'), + name: 'groups-overwrite', + value: 0, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'prompt', + fieldLabel: gettext('Prompt'), + editable: true, + emptyText: gettext('Auth-Provider Default'), + comboItems: [ + ['__default__', gettext('Auth-Provider Default')], + ['none', 'none'], + ['login', 'login'], + ['consent', 'consent'], + ['select_account', 'select_account'], + ], + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + + advancedColumnB: [ + { + xtype: 'proxmoxtextfield', + name: 'acr-values', + fieldLabel: gettext('ACR Values'), + submitEmpty: false, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Query userinfo endpoint'), + name: 'query-userinfo', + checked: true, + uncheckedValue: 0, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + + initComponent: function () { + let me = this; + + if (me.type !== 'openid') { + throw 'invalid type'; + } + + me.callParent(); + }, +}); +Ext.define('PVE.dc.AuthView', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveAuthView'], + + onlineHelp: 'pveum_authentication_realms', + + stateful: true, + stateId: 'grid-authrealms', + + viewConfig: { + trackOver: false, + }, + + columns: [ + { + header: gettext('Realm'), + width: 100, + sortable: true, + dataIndex: 'realm', + }, + { + header: gettext('Type'), + width: 100, + sortable: true, + dataIndex: 'type', + }, + { + header: gettext('TFA'), + width: 100, + sortable: true, + dataIndex: 'tfa', + }, + { + header: gettext('Comment'), + sortable: false, + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + + store: { + model: 'pmx-domains', + sorters: { + property: 'realm', + direction: 'ASC', + }, + }, + + openEditWindow: function (authType, realm) { + let me = this; + Ext.create('PVE.dc.AuthEditBase', { + authType, + realm, + listeners: { + destroy: () => me.reload(), + }, + }).show(); + }, + + reload: function () { + let me = this; + me.getStore().load(); + }, + + run_editor: function () { + let me = this; + let rec = me.getSelection()[0]; + if (!rec) { + return; + } + me.openEditWindow(rec.data.type, rec.data.realm); + }, + + open_sync_window: function () { + let me = this; + let rec = me.getSelection()[0]; + if (!rec) { + return; + } + Ext.create('PVE.dc.SyncWindow', { + realm: rec.data.realm, + listeners: { + destroy: () => me.reload(), + }, + }).show(); + }, + + initComponent: function () { + var me = this; + + let items = []; + for (const [authType, config] of Object.entries(PVE.Utils.authSchema)) { + if (!config.add) { + continue; + } + items.push({ + text: config.name, + iconCls: 'fa fa-fw ' + (config.iconCls || 'fa-address-book-o'), + handler: () => me.openEditWindow(authType), + }); + } + + Ext.apply(me, { + tbar: [ + { + text: gettext('Add'), + menu: { + items: items, + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + handler: () => me.run_editor(), + }, + { + xtype: 'proxmoxStdRemoveButton', + baseurl: '/access/domains/', + enableFn: (rec) => PVE.Utils.authSchema[rec.data.type].add, + callback: () => me.reload(), + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Sync'), + disabled: true, + enableFn: (rec) => Boolean(PVE.Utils.authSchema[rec.data.type].syncipanel), + handler: () => me.open_sync_window(), + }, + ], + listeners: { + itemdblclick: () => me.run_editor(), + }, + }); + + me.callParent(); + me.reload(); + }, +}); +Ext.define('PVE.dc.BackupDiskTree', { + extend: 'Ext.tree.Panel', + alias: 'widget.pveBackupDiskTree', + + folderSort: true, + rootVisible: false, + + store: { + sorters: 'id', + data: {}, + }, + + tools: [ + { + type: 'expand', + tooltip: gettext('Expand All'), + callback: (panel) => panel.expandAll(), + }, + { + type: 'collapse', + tooltip: gettext('Collapse All'), + callback: (panel) => panel.collapseAll(), + }, + ], + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Guest Image'), + renderer: function (value, meta, record) { + if (record.data.type) { + // guest level + let ret = value; + if (record.data.name) { + ret += ' (' + record.data.name + ')'; + } + return ret; + } else { + // extJS needs unique IDs but we only want to show the volumes key from "vmid:key" + return value.split(':')[1] + ' - ' + record.data.name; + } + }, + dataIndex: 'id', + flex: 6, + }, + { + text: gettext('Type'), + dataIndex: 'type', + flex: 1, + }, + { + text: gettext('Backup Job'), + renderer: PVE.Utils.render_backup_status, + dataIndex: 'included', + flex: 3, + }, + ], + + reload: function () { + let me = this; + let sm = me.getSelectionModel(); + + Proxmox.Utils.API2Request({ + url: `/cluster/backup/${me.jobid}/included_volumes`, + waitMsgTarget: me, + method: 'GET', + failure: function (response, opts) { + Proxmox.Utils.setErrorMask(me, response.htmlStatus); + }, + success: function (response, opts) { + sm.deselectAll(); + me.setRootNode(response.result.data); + me.expandAll(); + }, + }); + }, + + initComponent: function () { + var me = this; + + if (!me.jobid) { + throw 'no job id specified'; + } + + var sm = Ext.create('Ext.selection.TreeModel', {}); + + Ext.apply(me, { + selModel: sm, + fields: [ + 'id', + 'type', + { + type: 'string', + name: 'iconCls', + calculate: function (data) { + var txt = 'fa x-fa-tree fa-'; + if (data.leaf && !data.type) { + return txt + 'hdd-o'; + } else if (data.type === 'qemu') { + return txt + 'desktop'; + } else if (data.type === 'lxc') { + return txt + 'cube'; + } else { + return txt + 'question-circle'; + } + }, + }, + ], + header: { + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Search'), + labelWidth: 50, + emptyText: 'Name, VMID, Type', + width: 200, + padding: '0 5 0 0', + enableKeyEvents: true, + listeners: { + buffer: 500, + keyup: function (field) { + let searchValue = field.getValue().toLowerCase(); + me.store.clearFilter(true); + me.store.filterBy(function (record) { + let data = {}; + if (record.data.depth === 0) { + return true; + } else if (record.data.depth === 1) { + data = record.data; + } else if (record.data.depth === 2) { + data = record.parentNode.data; + } + + for (const property of ['name', 'id', 'type']) { + if (!data[property]) { + continue; + } + let v = data[property].toString(); + if (v !== undefined) { + v = v.toLowerCase(); + if (v.includes(searchValue)) { + return true; + } + } + } + return false; + }); + }, + }, + }, + ], + }, + }); + + me.callParent(); + + me.reload(); + }, +}); + +Ext.define('PVE.dc.BackupInfo', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveBackupInfo', + + viewModel: { + data: { + retentionType: 'none', + hideRecipients: true, + }, + formulas: { + hasRetention: (get) => get('retentionType') !== 'none', + retentionKeepAll: (get) => get('retentionType') === 'all', + }, + }, + + padding: '5 0 5 10', + + column1: [ + { + xtype: 'displayfield', + name: 'node', + fieldLabel: gettext('Node'), + renderer: (value) => value || `-- ${gettext('All')} --`, + }, + { + xtype: 'displayfield', + name: 'storage', + fieldLabel: gettext('Storage'), + }, + { + xtype: 'displayfield', + name: 'schedule', + fieldLabel: gettext('Schedule'), + }, + { + xtype: 'displayfield', + name: 'next-run', + fieldLabel: gettext('Next Run'), + renderer: PVE.Utils.render_next_event, + }, + { + xtype: 'displayfield', + name: 'selMode', + fieldLabel: gettext('Selection mode'), + }, + ], + column2: [ + { + xtype: 'displayfield', + name: 'notification-mode', + fieldLabel: gettext('Notification'), + renderer: function (value) { + value = value ?? 'auto'; + let record = this.up('pveBackupInfo')?.record; + let mailto = record?.mailto; + let mailnotification = record?.mailnotification ?? 'always'; + + if ((value === 'auto' && mailto === undefined) || value === 'notification-system') { + return gettext('Use global notification settings'); + } else if (mailnotification === 'always') { + return gettext('Always send email'); + } else { + return gettext('Send email on failure'); + } + }, + }, + { + xtype: 'displayfield', + name: 'mailto', + fieldLabel: gettext('Recipients'), + hidden: true, + bind: { + hidden: '{hideRecipients}', + }, + renderer: function (value) { + if (!value) { + return gettext('No recipients configured'); + } + + return value; + }, + }, + { + xtype: 'displayfield', + name: 'compress', + fieldLabel: gettext('Compression'), + }, + { + xtype: 'displayfield', + name: 'mode', + fieldLabel: gettext('Mode'), + renderer: function (value) { + const modeToDisplay = { + snapshot: gettext('Snapshot'), + stop: gettext('Stop'), + suspend: gettext('Suspend'), + }; + return modeToDisplay[value] ?? gettext('Unknown'); + }, + }, + { + xtype: 'displayfield', + name: 'enabled', + fieldLabel: gettext('Enabled'), + renderer: (v) => + PVE.Parser.parseBoolean(v.toString()) ? gettext('Yes') : gettext('No'), + }, + { + xtype: 'displayfield', + name: 'pool', + fieldLabel: gettext('Pool to backup'), + }, + ], + + columnB: [ + { + xtype: 'displayfield', + name: 'comment', + fieldLabel: gettext('Comment'), + renderer: Ext.String.htmlEncode, + }, + { + xtype: 'fieldset', + title: gettext('Retention Configuration'), + layout: 'hbox', + collapsible: true, + defaults: { + border: false, + layout: 'anchor', + flex: 1, + }, + bind: { + hidden: '{!hasRetention}', + }, + items: [ + { + padding: '0 10 0 0', + defaults: { + labelWidth: 110, + }, + items: [ + { + xtype: 'displayfield', + name: 'keep-all', + fieldLabel: gettext('Keep All'), + renderer: Proxmox.Utils.format_boolean, + bind: { + hidden: '{!retentionKeepAll}', + }, + }, + ].concat( + [ + ['keep-last', gettext('Keep Last')], + ['keep-hourly', gettext('Keep Hourly')], + ].map((name) => ({ + xtype: 'displayfield', + name: name[0], + fieldLabel: name[1], + bind: { + hidden: '{!hasRetention || retentionKeepAll}', + }, + })), + ), + }, + { + padding: '0 0 0 10', + defaults: { + labelWidth: 110, + }, + items: [ + ['keep-daily', gettext('Keep Daily')], + ['keep-weekly', gettext('Keep Weekly')], + ].map((name) => ({ + xtype: 'displayfield', + name: name[0], + fieldLabel: name[1], + bind: { + hidden: '{!hasRetention || retentionKeepAll}', + }, + })), + }, + { + padding: '0 0 0 10', + defaults: { + labelWidth: 110, + }, + items: [ + ['keep-monthly', gettext('Keep Monthly')], + ['keep-yearly', gettext('Keep Yearly')], + ].map((name) => ({ + xtype: 'displayfield', + name: name[0], + fieldLabel: name[1], + bind: { + hidden: '{!hasRetention || retentionKeepAll}', + }, + })), + }, + ], + }, + ], + + setValues: function (values) { + var me = this; + let vm = me.getViewModel(); + + Ext.iterate(values, function (fieldId, val) { + let field = me.query('[isFormField][name=' + fieldId + ']')[0]; + if (field) { + field.setValue(val); + } + }); + + if (values['prune-backups']) { + let keepValues; + if (values['prune-backups']) { + keepValues = values['prune-backups']; + } else { + keepValues = { 'keep-all': 1 }; + } + + vm.set('retentionType', keepValues['keep-all'] ? 'all' : 'other'); + + // set values of all keep-X fields + ['all', 'last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'].forEach((time) => { + let name = `keep-${time}`; + me.query(`[isFormField][name=${name}]`)[0]?.setValue(keepValues[name]); + }); + } else { + vm.set('retentionType', 'none'); + } + + let notificationMode = values['notification-mode'] ?? 'auto'; + let mailto = values.mailto; + + let hideRecipients = + (notificationMode === 'auto' && mailto === undefined) || + notificationMode === 'notification-system'; + vm.set('hideRecipients', hideRecipients); + + // selection Mode depends on the presence/absence of several keys + let selModeField = me.query('[isFormField][name=selMode]')[0]; + let selMode = 'none'; + if (values.vmid) { + selMode = gettext('Include selected VMs'); + } + if (values.all) { + selMode = gettext('All'); + } + if (values.exclude) { + selMode = gettext('Exclude selected VMs'); + } + if (values.pool) { + selMode = gettext('Pool based'); + } + selModeField.setValue(selMode); + + if (!values.pool) { + let poolField = me.query('[isFormField][name=pool]')[0]; + poolField.setVisible(0); + } + }, + + initComponent: function () { + var me = this; + + if (!me.record) { + throw 'no data provided'; + } + me.callParent(); + + me.setValues(me.record); + }, +}); + +Ext.define('PVE.dc.BackedGuests', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveBackedGuests', + + stateful: true, + stateId: 'grid-dc-backed-guests', + + textfilter: '', + + columns: [ + { + header: gettext('Type'), + dataIndex: 'type', + renderer: PVE.Utils.render_resource_type, + flex: 1, + sortable: true, + }, + { + header: 'VMID', + dataIndex: 'vmid', + flex: 1, + sortable: true, + }, + { + header: gettext('Name'), + dataIndex: 'name', + flex: 2, + sortable: true, + }, + ], + viewConfig: { + stripeRows: true, + trackOver: false, + }, + + initComponent: function () { + let me = this; + + me.store.clearFilter(true); + + Ext.apply(me, { + tbar: [ + '->', + gettext('Search') + ':', + ' ', + { + xtype: 'textfield', + width: 200, + emptyText: 'Name, VMID, Type', + enableKeyEvents: true, + listeners: { + buffer: 500, + keyup: function (field) { + let searchValue = field.getValue().toLowerCase(); + me.store.clearFilter(true); + me.store.filterBy(function (record) { + let data = record.data; + for (const property of ['name', 'vmid', 'type']) { + if (data[property] === null) { + continue; + } + let v = data[property].toString(); + if (v !== undefined) { + if (v.toLowerCase().includes(searchValue)) { + return true; + } + } + } + return false; + }); + }, + }, + }, + ], + }); + me.callParent(); + }, +}); +Ext.define('PVE.dc.BackupEdit', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveDcBackupEdit'], + + mixins: ['Proxmox.Mixin.CBind'], + + defaultFocus: undefined, + + subject: gettext('Backup Job'), + width: 720, + bodyPadding: 0, + + url: '/api2/extjs/cluster/backup', + method: 'POST', + isCreate: true, + + cbindData: function () { + let me = this; + if (me.jobid) { + me.isCreate = false; + me.method = 'PUT'; + me.url += `/${me.jobid}`; + } + return {}; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + onGetValues: function (values) { + let me = this; + let isCreate = me.getView().isCreate; + if (!values.node) { + if (!isCreate) { + Proxmox.Utils.assemble_field_data(values, { delete: 'node' }); + } + delete values.node; + } + + let selMode = values.selMode; + delete values.selMode; + + if (selMode === 'all') { + values.all = 1; + values.exclude = ''; + delete values.vmid; + } else if (selMode === 'exclude') { + values.all = 1; + values.exclude = values.vmid; + delete values.vmid; + } else if (selMode === 'pool') { + delete values.vmid; + } + + if (selMode !== 'pool') { + delete values.pool; + } + return values; + }, + + nodeChange: function (f, value) { + let me = this; + me.lookup('storageSelector').setNodename(value); + let vmgrid = me.lookup('vmgrid'); + let store = vmgrid.getStore(); + + store.clearFilter(); + store.filterBy(function (rec) { + return !value || rec.get('node') === value; + }); + + let mode = me.lookup('modeSelector').getValue(); + if (mode === 'all') { + vmgrid.selModel.selectAll(true); + } + if (mode === 'pool') { + me.selectPoolMembers(); + } + }, + + storageChange: function (f, v) { + let me = this; + let rec = f.getStore().findRecord('storage', v, 0, false, true, true); + let compressionSelector = me.lookup('compressionSelector'); + + if (rec?.data?.type === 'pbs') { + compressionSelector.setValue('zstd'); + compressionSelector.setDisabled(true); + } else if (!compressionSelector.getEditable()) { + compressionSelector.setDisabled(false); + } + }, + + selectPoolMembers: function () { + let me = this; + let mode = me.lookup('modeSelector').getValue(); + + if (mode !== 'pool') { + return; + } + + let vmgrid = me.lookup('vmgrid'); + let poolid = me.lookup('poolSelector').getValue(); + + vmgrid.getSelectionModel().deselectAll(true); + if (!poolid) { + return; + } + vmgrid.getStore().filter([ + { + id: 'poolFilter', + property: 'pool', + value: poolid, + }, + ]); + vmgrid.selModel.selectAll(true); + }, + + modeChange: function (f, value, oldValue) { + let me = this; + let vmgrid = me.lookup('vmgrid'); + vmgrid.getStore().removeFilter('poolFilter'); + + if (oldValue === 'all' && value !== 'all') { + vmgrid.getSelectionModel().deselectAll(true); + } + + if (value === 'all') { + vmgrid.getSelectionModel().selectAll(true); + } + + if (value === 'pool') { + me.selectPoolMembers(); + } + }, + + compressionChange: function (f, value, oldValue) { + this.getView().lookup('backupAdvanced').updateCompression(value, f.isDisabled()); + }, + + compressionDisable: function (f) { + this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), true); + }, + + compressionEnable: function (f) { + this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), false); + }, + + prepareValues: function (data) { + let me = this; + let viewModel = me.getViewModel(); + + if (data.exclude) { + data.vmid = data.exclude; + data.selMode = 'exclude'; + } else if (data.all) { + data.vmid = ''; + data.selMode = 'all'; + } else if (data.pool) { + data.selMode = 'pool'; + data.selPool = data.pool; + } else { + data.selMode = 'include'; + } + viewModel.set('selMode', data.selMode); + + if (data['prune-backups']) { + Object.assign(data, data['prune-backups']); + delete data['prune-backups']; + } + + if (data['notes-template']) { + data['notes-template'] = PVE.Utils.unEscapeNotesTemplate(data['notes-template']); + } + + if (data.performance) { + Object.assign(data, data.performance); + delete data.performance; + } + + return data; + }, + + init: function (view) { + let me = this; + + if (view.isCreate) { + me.lookup('modeSelector').setValue('include'); + } else { + view.load({ + success: function (response, _options) { + let values = me.prepareValues(response.result.data); + view.setValues(values); + }, + }); + } + }, + }, + + viewModel: { + data: { + selMode: 'include', + }, + + formulas: { + poolMode: (get) => get('selMode') === 'pool', + disableVMSelection: (get) => + get('selMode') !== 'include' && get('selMode') !== 'exclude', + }, + }, + + items: [ + { + xtype: 'tabpanel', + region: 'center', + layout: 'fit', + bodyPadding: 10, + items: [ + { + title: gettext('General'), + xtype: 'inputpanel', + onlineHelp: 'chapter_vzdump', + column1: [ + { + xtype: 'pveNodeSelector', + name: 'node', + fieldLabel: gettext('Node'), + allowBlank: true, + editable: true, + autoSelect: false, + emptyText: '-- ' + gettext('All') + ' --', + listeners: { + change: 'nodeChange', + }, + }, + { + xtype: 'pveStorageSelector', + reference: 'storageSelector', + fieldLabel: gettext('Storage'), + clusterView: true, + storageContent: 'backup', + allowBlank: false, + name: 'storage', + listeners: { + change: 'storageChange', + }, + }, + { + xtype: 'pveCalendarEvent', + fieldLabel: gettext('Schedule'), + allowBlank: false, + name: 'schedule', + }, + { + xtype: 'proxmoxKVComboBox', + reference: 'modeSelector', + comboItems: [ + ['include', gettext('Include selected VMs')], + ['all', gettext('All')], + ['exclude', gettext('Exclude selected VMs')], + ['pool', gettext('Pool based')], + ], + fieldLabel: gettext('Selection mode'), + name: 'selMode', + value: '', + bind: { + value: '{selMode}', + }, + listeners: { + change: 'modeChange', + }, + }, + { + xtype: 'pvePoolSelector', + reference: 'poolSelector', + fieldLabel: gettext('Pool to backup'), + hidden: true, + allowBlank: false, + name: 'pool', + listeners: { + change: 'selectPoolMembers', + }, + bind: { + hidden: '{!poolMode}', + disabled: '{!poolMode}', + }, + }, + ], + column2: [ + { + xtype: 'pveBackupCompressionSelector', + reference: 'compressionSelector', + fieldLabel: gettext('Compression'), + name: 'compress', + cbind: { + deleteEmpty: '{!isCreate}', + }, + value: 'zstd', + listeners: { + change: 'compressionChange', + disable: 'compressionDisable', + enable: 'compressionEnable', + }, + }, + { + xtype: 'pveBackupModeSelector', + fieldLabel: gettext('Mode'), + value: 'snapshot', + name: 'mode', + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enable'), + name: 'enabled', + uncheckedValue: 0, + defaultValue: 1, + checked: true, + }, + ], + columnB: [ + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Job Comment'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Description of the job'), + }, + }, + { + xtype: 'vmselector', + reference: 'vmgrid', + height: 300, + name: 'vmid', + disabled: true, + allowBlank: false, + columnSelection: ['vmid', 'node', 'status', 'name', 'type'], + bind: { + disabled: '{disableVMSelection}', + }, + }, + ], + onGetValues: function (values) { + return this.up('window').getController().onGetValues(values); + }, + }, + { + xtype: 'pveBackupNotificationOptionsPanel', + title: gettext('Notifications'), + cbind: { + isCreate: '{isCreate}', + }, + }, + { + xtype: 'pveBackupJobPrunePanel', + title: gettext('Retention'), + cbind: { + isCreate: '{isCreate}', + }, + keepAllDefaultForCreate: false, + showPBSHint: false, + fallbackHintHtml: gettext( + "Without any keep option, the storage's configuration or node's vzdump.conf is used as fallback", + ), + }, + { + xtype: 'inputpanel', + onlineHelp: 'chapter_vzdump', + title: gettext('Note Template'), + region: 'center', + layout: { + type: 'vbox', + align: 'stretch', + }, + onGetValues: function (values) { + if (values['notes-template']) { + values['notes-template'] = PVE.Utils.escapeNotesTemplate( + values['notes-template'], + ); + } + return values; + }, + items: [ + { + xtype: 'textarea', + name: 'notes-template', + fieldLabel: gettext('Backup Notes'), + height: 100, + maxLength: 512, + cbind: { + deleteEmpty: '{!isCreate}', + value: (get) => (get('isCreate') ? '{{guestname}}' : undefined), + }, + }, + { + xtype: 'box', + style: { + margin: '8px 0px', + 'line-height': '1.5em', + }, + html: + gettext('The notes are added to each backup created by this job.') + + '
    ' + + Ext.String.format( + gettext('Possible template variables are: {0}'), + PVE.Utils.notesTemplateVars + .map((v) => `{{${v}}}`) + .join(', '), + ), + }, + ], + }, + { + xtype: 'pveBackupAdvancedOptionsPanel', + onlineHelp: 'chapter_vzdump', + reference: 'backupAdvanced', + title: gettext('Advanced'), + cbind: { + isCreate: '{isCreate}', + }, + }, + ], + }, + ], +}); + +Ext.define( + 'PVE.dc.BackupView', + { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveDcBackupView'], + + onlineHelp: 'chapter_vzdump', + + allText: '-- ' + gettext('All') + ' --', + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-cluster-backup', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/backup', + }, + }); + + let not_backed_store = new Ext.data.Store({ + sorters: 'vmid', + proxy: { + type: 'proxmox', + url: 'api2/json/cluster/backup-info/not-backed-up', + }, + }); + + let noBackupJobInfoButton; + let reload = function () { + store.load(); + not_backed_store.load({ + callback: (records) => noBackupJobInfoButton.setVisible(records.length > 0), + }); + }; + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + Ext.create('PVE.dc.BackupEdit', { + autoShow: true, + jobid: rec.data.id, + listeners: { + destroy: () => reload(), + }, + }); + }; + + let run_detail = function () { + let record = sm.getSelection()[0]; + if (!record) { + return; + } + Ext.create('Ext.window.Window', { + modal: true, + width: 800, + height: Ext.getBody().getViewSize().height > 1000 ? 800 : 600, // factor out as common infra? + resizable: true, + layout: 'fit', + title: gettext('Backup Details'), + items: [ + { + xtype: 'panel', + region: 'center', + layout: { + type: 'vbox', + align: 'stretch', + }, + items: [ + { + xtype: 'pveBackupInfo', + flex: 0, + layout: 'fit', + record: record.data, + }, + { + xtype: 'pveBackupDiskTree', + title: gettext('Included disks'), + flex: 1, + jobid: record.data.id, + }, + ], + }, + ], + }).show(); + }; + + let run_backup_now = function (job) { + job = Ext.clone(job); + + let jobNode = job.node; + // Remove properties related to scheduling + delete job.enabled; + delete job.starttime; + delete job.dow; + delete job.id; + delete job.schedule; + delete job.type; + delete job.node; + delete job.comment; + delete job['next-run']; + delete job['repeat-missed']; + job.all = job.all === true ? 1 : 0; + + ['performance', 'prune-backups', 'fleecing'].forEach((key) => { + if (job[key]) { + job[key] = PVE.Parser.printPropertyString(job[key]); + } + }); + + let allNodes = PVE.data.ResourceStore.getNodes(); + let nodes = allNodes + .filter((node) => node.status === 'online') + .map((node) => node.node); + let errors = []; + + if (jobNode !== undefined) { + if (!nodes.includes(jobNode)) { + Ext.Msg.alert( + 'Error', + "Node '" + jobNode + "' from backup job isn't online!", + ); + return; + } + nodes = [jobNode]; + } else { + let unkownNodes = allNodes.filter((node) => node.status !== 'online'); + if (unkownNodes.length > 0) { + errors.push( + unkownNodes.map( + (node) => node.node + ': ' + gettext('Node is offline'), + ), + ); + } + } + let jobTotalCount = nodes.length, + jobsStarted = 0; + + Ext.Msg.show({ + title: gettext('Please wait...'), + closable: false, + progress: true, + progressText: '0/' + jobTotalCount, + }); + + let postRequest = function () { + jobsStarted++; + Ext.Msg.updateProgress( + jobsStarted / jobTotalCount, + jobsStarted + '/' + jobTotalCount, + ); + + if (jobsStarted === jobTotalCount) { + Ext.Msg.hide(); + if (errors.length > 0) { + Ext.Msg.alert( + 'Error', + 'Some errors have been encountered:
    ' + errors.join('
    '), + ); + } + } + }; + + nodes.forEach((node) => + Proxmox.Utils.API2Request({ + url: '/nodes/' + node + '/vzdump', + method: 'POST', + params: job, + failure: function (response, opts) { + errors.push(node + ': ' + response.htmlStatus); + postRequest(); + }, + success: postRequest, + }), + ); + }; + + var edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + var run_btn = new Proxmox.button.Button({ + text: gettext('Run now'), + disabled: true, + selModel: sm, + handler: function () { + var rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.QUESTION, + msg: gettext('Start the selected backup job now?'), + buttons: Ext.Msg.YESNO, + callback: function (btn) { + if (btn !== 'yes') { + return; + } + run_backup_now(rec.data); + }, + }); + }, + }); + + var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/cluster/backup', + callback: function () { + reload(); + }, + }); + + var detail_btn = new Proxmox.button.Button({ + text: gettext('Job Detail'), + disabled: true, + tooltip: gettext( + 'Show job details and which guests and volumes are affected by the backup job', + ), + selModel: sm, + handler: run_detail, + }); + + noBackupJobInfoButton = new Proxmox.button.Button({ + text: `${gettext('Show')}: ${gettext('Guests Without Backup Job')}`, + tooltip: gettext('Some guests are not covered by any backup job.'), + iconCls: 'fa fa-fw fa-exclamation-circle', + hidden: true, + handler: () => { + Ext.create('Ext.window.Window', { + autoShow: true, + modal: true, + width: 600, + height: 500, + resizable: true, + layout: 'fit', + title: gettext('Guests Without Backup Job'), + items: [ + { + xtype: 'panel', + region: 'center', + layout: { + type: 'vbox', + align: 'stretch', + }, + items: [ + { + xtype: 'pveBackedGuests', + flex: 1, + layout: 'fit', + store: not_backed_store, + }, + ], + }, + ], + }); + }, + }); + + Proxmox.Utils.monStoreErrors(me, store); + + Ext.apply(me, { + store: store, + selModel: sm, + stateful: true, + stateId: 'grid-dc-backup', + viewConfig: { + trackOver: false, + }, + dockedItems: [ + { + xtype: 'toolbar', + overflowHandler: 'scroller', + dock: 'top', + items: [ + { + text: gettext('Add'), + handler: function () { + var win = Ext.create('PVE.dc.BackupEdit', {}); + win.on('destroy', reload); + win.show(); + }, + }, + '-', + remove_btn, + edit_btn, + detail_btn, + '-', + run_btn, + '->', + noBackupJobInfoButton, + '-', + { + xtype: 'proxmoxButton', + selModel: null, + text: gettext('Schedule Simulator'), + handler: () => { + let record = sm.getSelection()[0]; + let schedule; + if (record) { + schedule = record.data.schedule; + } + Ext.create('PVE.window.ScheduleSimulator', { + autoShow: true, + schedule, + }); + }, + }, + ], + }, + ], + columns: [ + { + header: gettext('Enabled'), + width: 80, + dataIndex: 'enabled', + align: 'center', + renderer: Proxmox.Utils.renderEnabledIcon, + sortable: true, + }, + { + header: gettext('ID'), + dataIndex: 'id', + hidden: true, + }, + { + header: gettext('Node'), + width: 100, + sortable: true, + dataIndex: 'node', + renderer: function (value) { + if (value) { + return value; + } + return me.allText; + }, + }, + { + header: gettext('Schedule'), + width: 150, + dataIndex: 'schedule', + }, + { + text: gettext('Next Run'), + dataIndex: 'next-run', + width: 150, + renderer: PVE.Utils.render_next_event, + }, + { + header: gettext('Storage'), + width: 100, + sortable: true, + dataIndex: 'storage', + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.htmlEncode, + sorter: (a, b) => + (a.data.comment || '').localeCompare(b.data.comment || ''), + flex: 1, + }, + { + header: gettext('Retention'), + dataIndex: 'prune-backups', + renderer: (v) => + v + ? PVE.Parser.printPropertyString(v) + : gettext('Fallback from storage config'), + flex: 2, + }, + { + header: gettext('Selection'), + flex: 4, + sortable: false, + dataIndex: 'vmid', + renderer: PVE.Utils.render_backup_selection, + }, + ], + listeners: { + activate: reload, + itemdblclick: run_editor, + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-cluster-backup', { + extend: 'Ext.data.Model', + fields: [ + 'id', + 'compress', + 'dow', + 'exclude', + 'mailto', + 'mode', + 'node', + 'pool', + 'prune-backups', + 'starttime', + 'storage', + 'vmid', + { name: 'enabled', type: 'boolean' }, + { name: 'all', type: 'boolean' }, + ], + }); + }, +); +Ext.define('pve-cluster-nodes', { + extend: 'Ext.data.Model', + fields: [ + 'node', + { type: 'integer', name: 'nodeid' }, + 'ring0_addr', + 'ring1_addr', + { type: 'integer', name: 'quorum_votes' }, + ], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/config/nodes', + }, + idProperty: 'nodeid', +}); + +Ext.define('pve-cluster-info', { + extend: 'Ext.data.Model', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/config/join', + }, +}); + +Ext.define('PVE.ClusterAdministration', { + extend: 'Ext.panel.Panel', + xtype: 'pveClusterAdministration', + + title: gettext('Cluster Administration'), + onlineHelp: 'chapter_pvecm', + + border: false, + defaults: { border: false }, + + viewModel: { + parent: null, + data: { + totem: {}, + nodelist: [], + preferred_node: { + name: '', + fp: '', + addr: '', + }, + isInCluster: false, + nodecount: 0, + }, + }, + + items: [ + { + xtype: 'panel', + title: gettext('Cluster Information'), + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + view.store = Ext.create('Proxmox.data.UpdateStore', { + autoStart: true, + interval: 15 * 1000, + storeid: 'pve-cluster-info', + model: 'pve-cluster-info', + }); + view.store.on('load', this.onLoad, this); + view.on('destroy', view.store.stopUpdate); + }, + + onLoad: function (store, records, success, operation) { + let vm = this.getViewModel(); + + let data = records?.[0]?.data; + if (!success || !data || !data.nodelist?.length) { + let error = operation.getError(); + if (error) { + let msg = Proxmox.Utils.getResponseErrorMessage(error); + if (error.status !== 424 && !msg.match(/node is not in a cluster/i)) { + // an actual error, not just the "not in a cluster one", so show it! + Proxmox.Utils.setErrorMask(this.getView(), msg); + } + } + vm.set('totem', {}); + vm.set('isInCluster', false); + vm.set('nodelist', []); + vm.set('preferred_node', { + name: '', + addr: '', + fp: '', + }); + return; + } + vm.set('totem', data.totem); + vm.set('isInCluster', !!data.totem.cluster_name); + vm.set('nodelist', data.nodelist); + + let nodeinfo = data.nodelist.find((el) => el.name === data.preferred_node); + + let links = {}; + let ring_addr = []; + PVE.Utils.forEachCorosyncLink(nodeinfo, (num, link) => { + links[num] = link; + ring_addr.push(link); + }); + + vm.set('preferred_node', { + name: data.preferred_node, + addr: nodeinfo.pve_addr, + peerLinks: links, + ring_addr: ring_addr, + fp: nodeinfo.pve_fp, + }); + }, + + onCreate: function () { + let view = this.getView(); + view.store.stopUpdate(); + Ext.create('PVE.ClusterCreateWindow', { + autoShow: true, + listeners: { + destroy: function () { + view.store.startUpdate(); + }, + }, + }); + }, + + onClusterInfo: function () { + let vm = this.getViewModel(); + Ext.create('PVE.ClusterInfoWindow', { + autoShow: true, + joinInfo: { + ipAddress: vm.get('preferred_node.addr'), + fingerprint: vm.get('preferred_node.fp'), + peerLinks: vm.get('preferred_node.peerLinks'), + ring_addr: vm.get('preferred_node.ring_addr'), + totem: vm.get('totem'), + }, + }); + }, + + onJoin: function () { + let view = this.getView(); + view.store.stopUpdate(); + Ext.create('PVE.ClusterJoinNodeWindow', { + autoShow: true, + listeners: { + destroy: function () { + view.store.startUpdate(); + }, + }, + }); + }, + }, + tbar: [ + { + text: gettext('Create Cluster'), + reference: 'createButton', + handler: 'onCreate', + bind: { + disabled: '{isInCluster}', + }, + }, + { + text: gettext('Join Information'), + reference: 'addButton', + handler: 'onClusterInfo', + bind: { + disabled: '{!isInCluster}', + }, + }, + { + text: gettext('Join Cluster'), + reference: 'joinButton', + handler: 'onJoin', + bind: { + disabled: '{isInCluster}', + }, + }, + ], + layout: 'hbox', + bodyPadding: 5, + items: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Cluster Name'), + bind: { + value: '{totem.cluster_name}', + hidden: '{!isInCluster}', + }, + flex: 1, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Config Version'), + bind: { + value: '{totem.config_version}', + hidden: '{!isInCluster}', + }, + flex: 1, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Number of Nodes'), + labelWidth: 120, + bind: { + value: '{nodecount}', + hidden: '{!isInCluster}', + }, + flex: 1, + }, + { + xtype: 'displayfield', + value: gettext('Standalone node - no cluster defined'), + bind: { + hidden: '{isInCluster}', + }, + flex: 1, + }, + ], + }, + { + xtype: 'grid', + title: gettext('Cluster Nodes'), + autoScroll: true, + enableColumnHide: false, + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + view.rstore = Ext.create('Proxmox.data.UpdateStore', { + autoLoad: true, + xtype: 'update', + interval: 5 * 1000, + autoStart: true, + storeid: 'pve-cluster-nodes', + model: 'pve-cluster-nodes', + }); + view.setStore( + Ext.create('Proxmox.data.DiffStore', { + rstore: view.rstore, + sorters: { + property: 'nodeid', + direction: 'ASC', + }, + }), + ); + Proxmox.Utils.monStoreErrors(view, view.rstore); + view.rstore.on('load', this.onLoad, this); + view.on('destroy', view.rstore.stopUpdate); + }, + + onLoad: function (store, records, success) { + let view = this.getView(); + let vm = this.getViewModel(); + + if (!success || !records || !records.length) { + vm.set('nodecount', 0); + return; + } + vm.set('nodecount', records.length); + + // show/hide columns according to used links + let linkIndex = view.columns.length; + Ext.each(view.columns, (col, i) => { + if (col.linkNumber !== undefined) { + col.setHidden(true); + // save offset at which link columns start, so we can address them directly below + if (i < linkIndex) { + linkIndex = i; + } + } + }); + + PVE.Utils.forEachCorosyncLink(records[0].data, (linknum, val) => { + if (linknum > 7) { + return; + } + view.columns[linkIndex + linknum].setHidden(false); + }); + }, + }, + columns: { + items: [ + { + header: gettext('Nodename'), + hidden: false, + dataIndex: 'name', + }, + { + header: gettext('ID'), + minWidth: 100, + width: 100, + flex: 0, + hidden: false, + dataIndex: 'nodeid', + }, + { + header: gettext('Votes'), + minWidth: 100, + width: 100, + flex: 0, + hidden: false, + dataIndex: 'quorum_votes', + }, + { + header: Ext.String.format(gettext('Link {0}'), 0), + dataIndex: 'ring0_addr', + linkNumber: 0, + }, + { + header: Ext.String.format(gettext('Link {0}'), 1), + dataIndex: 'ring1_addr', + linkNumber: 1, + }, + { + header: Ext.String.format(gettext('Link {0}'), 2), + dataIndex: 'ring2_addr', + linkNumber: 2, + }, + { + header: Ext.String.format(gettext('Link {0}'), 3), + dataIndex: 'ring3_addr', + linkNumber: 3, + }, + { + header: Ext.String.format(gettext('Link {0}'), 4), + dataIndex: 'ring4_addr', + linkNumber: 4, + }, + { + header: Ext.String.format(gettext('Link {0}'), 5), + dataIndex: 'ring5_addr', + linkNumber: 5, + }, + { + header: Ext.String.format(gettext('Link {0}'), 6), + dataIndex: 'ring6_addr', + linkNumber: 6, + }, + { + header: Ext.String.format(gettext('Link {0}'), 7), + dataIndex: 'ring7_addr', + linkNumber: 7, + }, + ], + defaults: { + flex: 1, + hidden: true, + minWidth: 150, + }, + }, + }, + ], +}); +Ext.define('PVE.ClusterCreateWindow', { + extend: 'Proxmox.window.Edit', + xtype: 'pveClusterCreateWindow', + + title: gettext('Create Cluster'), + width: 600, + + method: 'POST', + url: '/cluster/config', + + isCreate: true, + subject: gettext('Cluster'), + showTaskViewer: true, + + onlineHelp: 'pvecm_create_cluster', + + items: { + xtype: 'inputpanel', + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Cluster Name'), + allowBlank: false, + maxLength: 15, + name: 'clustername', + }, + { + xtype: 'fieldcontainer', + fieldLabel: gettext('Cluster Network'), + items: [ + { + xtype: 'pveCorosyncLinkEditor', + infoText: gettext( + 'Multiple links are used as failover, lower numbers have higher priority.', + ), + name: 'links', + }, + ], + }, + ], + }, +}); + +Ext.define('PVE.ClusterInfoWindow', { + extend: 'Ext.window.Window', + xtype: 'pveClusterInfoWindow', + mixins: ['Proxmox.Mixin.CBind'], + + width: 800, + modal: true, + resizable: false, + title: gettext('Cluster Join Information'), + + joinInfo: { + ipAddress: undefined, + fingerprint: undefined, + totem: {}, + }, + + items: [ + { + xtype: 'component', + border: false, + padding: '10 10 10 10', + html: gettext('Copy the Join Information here and use it on the node you want to add.'), + }, + { + xtype: 'container', + layout: 'form', + border: false, + padding: '0 10 10 10', + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('IP Address'), + cbind: { + value: '{joinInfo.ipAddress}', + }, + editable: false, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Fingerprint'), + cbind: { + value: '{joinInfo.fingerprint}', + }, + editable: false, + }, + { + xtype: 'textarea', + inputId: 'pveSerializedClusterInfo', + fieldLabel: gettext('Join Information'), + grow: true, + cbind: { + joinInfo: '{joinInfo}', + }, + editable: false, + listeners: { + afterrender: function (field) { + if (!field.joinInfo) { + return; + } + var jsons = Ext.JSON.encode(field.joinInfo); + var base64s = Ext.util.Base64.encode(jsons); + field.setValue(base64s); + }, + }, + }, + ], + }, + ], + dockedItems: [ + { + dock: 'bottom', + xtype: 'toolbar', + items: [ + { + xtype: 'button', + handler: function (b) { + var el = document.getElementById('pveSerializedClusterInfo'); + el.select(); + document.execCommand('copy'); + }, + text: gettext('Copy Information'), + iconCls: 'fa fa-clipboard', + }, + ], + }, + ], +}); + +Ext.define('PVE.ClusterJoinNodeWindow', { + extend: 'Proxmox.window.Edit', + xtype: 'pveClusterJoinNodeWindow', + + title: gettext('Cluster Join'), + width: 800, + + method: 'POST', + url: '/cluster/config/join', + + defaultFocus: 'textarea[name=serializedinfo]', + isCreate: true, + bind: { + submitText: '{submittxt}', + }, + showTaskViewer: true, + + onlineHelp: 'pvecm_join_node_to_cluster', + + viewModel: { + parent: null, + data: { + info: { + fp: '', + ip: '', + clusterName: '', + }, + hasAssistedInfo: false, + }, + formulas: { + submittxt: function (get) { + let cn = get('info.clusterName'); + if (cn) { + return Ext.String.format(gettext('Join {0}'), `'${cn}'`); + } + return gettext('Join'); + }, + showClusterFields: (get) => { + let manualMode = !get('assistedEntry.checked'); + return get('hasAssistedInfo') || manualMode; + }, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + '#': { + close: function () { + delete PVE.Utils.silenceAuthFailures; + }, + }, + 'proxmoxcheckbox[name=assistedEntry]': { + change: 'onInputTypeChange', + }, + 'textarea[name=serializedinfo]': { + change: 'recomputeSerializedInfo', + enable: 'resetField', + }, + textfield: { + disable: 'resetField', + }, + }, + resetField: function (field) { + field.reset(); + }, + onInputTypeChange: function (field, assistedInput) { + let linkEditor = this.lookup('linkEditor'); + + // this also clears all links + linkEditor.setAllowNumberEdit(!assistedInput); + + if (!assistedInput) { + linkEditor.setInfoText(); + linkEditor.setDefaultLinks(); + } + }, + recomputeSerializedInfo: function (field, value) { + let vm = this.getViewModel(); + + let assistedEntryBox = this.lookup('assistedEntry'); + + if (!assistedEntryBox.getValue()) { + // not in assisted entry mode, nothing to do + vm.set('hasAssistedInfo', false); + return; + } + + let linkEditor = this.lookup('linkEditor'); + + let jsons = Ext.util.Base64.decode(value); + let joinInfo = Ext.JSON.decode(jsons, true); + + let info = { + fp: '', + ip: '', + clusterName: '', + }; + + if (!(joinInfo && joinInfo.totem)) { + field.valid = false; + linkEditor.setLinks([]); + linkEditor.setInfoText(); + vm.set('hasAssistedInfo', false); + } else { + let interfaces = joinInfo.totem.interface; + let links = Object.values(interfaces).map((iface) => { + let linkNumber = iface.linknumber; + let peerLink; + if (joinInfo.peerLinks) { + peerLink = joinInfo.peerLinks[linkNumber]; + } + return { + number: linkNumber, + value: '', + text: peerLink + ? Ext.String.format(gettext("peer's link address: {0}"), peerLink) + : '', + allowBlank: false, + }; + }); + + linkEditor.setInfoText(); + if ( + links.length === 1 && + joinInfo.ring_addr !== undefined && + joinInfo.ring_addr[0] === joinInfo.ipAddress + ) { + links[0].allowBlank = true; + links[0].emptyText = gettext("IP resolved by node's hostname"); + } + + linkEditor.setLinks(links); + + info = { + ip: joinInfo.ipAddress, + fp: joinInfo.fingerprint, + clusterName: joinInfo.totem.cluster_name, + }; + field.valid = true; + vm.set('hasAssistedInfo', true); + } + vm.set('info', info); + }, + }, + + submit: function () { + // joining may produce temporarily auth failures, ignore as long the task runs + PVE.Utils.silenceAuthFailures = true; + this.callParent(); + }, + + taskDone: function (success) { + delete PVE.Utils.silenceAuthFailures; + if (success) { + // reload always (if user wasn't faster), but wait a bit for pveproxy + Ext.defer(function () { + window.location.reload(true); + }, 5000); + let txt = gettext( + 'Cluster join task finished, node certificate may have changed, reload GUI!', + ); + // ensure user cannot do harm + Ext.getBody().mask(txt, ['pve-static-mask']); + // TaskView may hide above mask, so tell him directly + Ext.Msg.show({ + title: gettext('Join Task Finished'), + icon: Ext.Msg.INFO, + msg: txt, + }); + } + }, + + items: [ + { + xtype: 'proxmoxcheckbox', + reference: 'assistedEntry', + name: 'assistedEntry', + itemId: 'assistedEntry', + submitValue: false, + value: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Select if join information should be extracted from pasted cluster information, deselect for manual entering', + ), + }, + boxLabel: gettext( + 'Assisted join: Paste encoded cluster join information and enter password.', + ), + }, + { + xtype: 'textarea', + name: 'serializedinfo', + submitValue: false, + allowBlank: false, + fieldLabel: gettext('Information'), + emptyText: gettext('Paste encoded Cluster Information here'), + validator: function (val) { + return ( + val === '' || + this.valid || + gettext('Does not seem like a valid encoded Cluster Information!') + ); + }, + bind: { + disabled: '{!assistedEntry.checked}', + hidden: '{!assistedEntry.checked}', + }, + value: '', + }, + { + xtype: 'panel', + width: 776, + layout: { + type: 'hbox', + align: 'center', + }, + bind: { + hidden: '{!showClusterFields}', + }, + items: [ + { + xtype: 'textfield', + flex: 1, + margin: '0 5px 0 0', + fieldLabel: gettext('Peer Address'), + allowBlank: false, + bind: { + value: '{info.ip}', + readOnly: '{assistedEntry.checked}', + }, + name: 'hostname', + }, + { + xtype: 'textfield', + flex: 1, + margin: '0 0 10px 5px', + inputType: 'password', + emptyText: gettext("Peer's root password"), + fieldLabel: gettext('Password'), + allowBlank: false, + name: 'password', + }, + ], + }, + { + xtype: 'textfield', + fieldLabel: gettext('Fingerprint'), + allowBlank: false, + bind: { + value: '{info.fp}', + readOnly: '{assistedEntry.checked}', + hidden: '{!showClusterFields}', + }, + name: 'fingerprint', + }, + { + xtype: 'fieldcontainer', + fieldLabel: gettext('Cluster Network'), + bind: { + hidden: '{!showClusterFields}', + }, + items: [ + { + xtype: 'pveCorosyncLinkEditor', + itemId: 'linkEditor', + reference: 'linkEditor', + allowNumberEdit: false, + }, + ], + }, + ], +}); +Ext.define('PVE.dc.CmdMenu', { + extend: 'Ext.menu.Menu', + xtype: 'datacenterCmdMenu', + + showSeparator: false, + + extraHandlerArgs: {}, + + items: [ + { + text: gettext('Bulk Start'), + itemId: 'bulkstart', + iconCls: 'fa fa-fw fa-play', + handler: function () { + let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Start'), + btnText: gettext('Start'), + action: 'start', + ...extraArgs, + }); + }, + }, + { + text: gettext('Bulk Shutdown'), + itemId: 'bulkstop', + iconCls: 'fa fa-fw fa-stop', + handler: function () { + let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Shutdown'), + btnText: gettext('Shutdown'), + action: 'shutdown', + ...extraArgs, + }); + }, + }, + { + text: gettext('Bulk Suspend'), + itemId: 'bulksuspend', + iconCls: 'fa fa-fw fa-download', + handler: function () { + let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Suspend'), + btnText: gettext('Suspend'), + action: 'suspend', + ...extraArgs, + }); + }, + }, + { + text: gettext('Bulk Migrate'), + itemId: 'bulkmigrate', + iconCls: 'fa fa-fw fa-send-o', + handler: function () { + let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Migrate'), + btnText: gettext('Migrate'), + action: 'migrate', + ...extraArgs, + }); + }, + }, + ], + + initComponent: function () { + let me = this; + + if (!me.title) { + me.title = gettext('Datacenter'); + if (PVE.ClusterName?.length) { + me.title += ` (${PVE.ClusterName})`; + me.minWidth = 220; + } + } + + me.callParent(); + + let caps = Ext.state.Manager.get('GuiCap'); + + if (!caps.vms['VM.Migrate']) { + me.getComponent('bulkmigrate').setDisabled(true); + } + if (!caps.vms['VM.PowerMgmt']) { + me.getComponent('bulkstart').setDisabled(true); + me.getComponent('bulkstop').setDisabled(true); + me.getComponent('bulksuspend').setDisabled(true); + } + if (PVE.Utils.isStandaloneNode()) { + me.getComponent('bulkmigrate').setVisible(false); + } + }, +}); + +Ext.define('PVE.dc.TagCmdMenu', { + extend: 'PVE.dc.CmdMenu', + xtype: 'tagCmdMenu', + + minWidth: 220, + + initComponent: function () { + let me = this; + + if (!me.tag) { + throw 'no tag specified'; + } + + me.title = `${gettext('Tag')} '${me.tag}'`; + if (PVE.ClusterName?.length) { + me.title += ` (${me.nodename})`; + } + + me.extraHandlerArgs = { + prefilterIncludeTag: me.tag, + }; + + me.callParent(); + }, +}); +/* + * Datacenter config panel, located in the center of the ViewPort after the Datacenter view is selected + */ + +Ext.define('PVE.dc.Config', { + extend: 'PVE.panel.Config', + alias: 'widget.PVE.dc.Config', + + onlineHelp: 'pve_admin_guide', + + initComponent: function () { + var me = this; + + var caps = Ext.state.Manager.get('GuiCap'); + + me.items = []; + + let actionBtn = Ext.create('Ext.Button', { + text: gettext('Bulk Actions'), + iconCls: 'fa fa-fw fa-ellipsis-v', + disabled: !caps.vms['VM.PowerMgmt'] && !caps.vms['VM.Migrate'], + menu: new Ext.menu.Menu({ + items: [ + { + text: gettext('Bulk Start'), + iconCls: 'fa fa-fw fa-play', + disabled: !caps.vms['VM.PowerMgmt'], + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Start'), + btnText: gettext('Start'), + action: 'start', + }); + }, + }, + { + text: gettext('Bulk Shutdown'), + iconCls: 'fa fa-fw fa-stop', + disabled: !caps.vms['VM.PowerMgmt'], + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Shutdown'), + btnText: gettext('Shutdown'), + action: 'shutdown', + }); + }, + }, + { + text: gettext('Bulk Suspend'), + iconCls: 'fa fa-fw fa-download', + disabled: !caps.vms['VM.PowerMgmt'], + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Suspend'), + btnText: gettext('Suspend'), + action: 'suspend', + }); + }, + }, + { + text: gettext('Bulk Migrate'), + iconCls: 'fa fa-fw fa-send-o', + disabled: !caps.vms['VM.Migrate'], + hidden: PVE.Utils.isStandaloneNode(), + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + vmsAsArray: true, + title: gettext('Bulk Migrate'), + btnText: gettext('Migrate'), + action: 'migrate', + }); + }, + }, + ], + }), + }); + + Ext.apply(me, { + title: gettext('Datacenter'), + hstateid: 'dctab', + tbar: [actionBtn], + }); + + if (caps.dc['Sys.Audit']) { + me.items.push( + { + title: gettext('Summary'), + xtype: 'pveDcSummary', + iconCls: 'fa fa-book', + itemId: 'summary', + }, + { + xtype: 'pmxNotesView', + title: gettext('Notes'), + iconCls: 'fa fa-sticky-note-o', + itemId: 'notes', + }, + { + title: gettext('Cluster'), + xtype: 'pveClusterAdministration', + iconCls: 'fa fa-server', + itemId: 'cluster', + }, + { + title: 'Ceph', + itemId: 'ceph', + iconCls: 'fa fa-ceph', + xtype: 'pveNodeCephStatus', + }, + { + xtype: 'pveDcOptionView', + title: gettext('Options'), + iconCls: 'fa fa-gear', + itemId: 'options', + }, + ); + } + + if (caps.storage['Datastore.Allocate'] || caps.dc['Sys.Audit']) { + me.items.push({ + xtype: 'pveStorageView', + title: gettext('Storage'), + iconCls: 'fa fa-database', + itemId: 'storage', + }); + } + + if (caps.dc['Sys.Audit']) { + me.items.push( + { + xtype: 'pveDcBackupView', + iconCls: 'fa fa-floppy-o', + title: gettext('Backup'), + itemId: 'backup', + }, + { + xtype: 'pveReplicaView', + iconCls: 'fa fa-retweet', + title: gettext('Replication'), + itemId: 'replication', + }, + { + xtype: 'pveACLView', + title: gettext('Permissions'), + iconCls: 'fa fa-unlock', + itemId: 'permissions', + expandedOnInit: true, + }, + ); + } + + me.items.push({ + xtype: 'pveUserView', + groups: ['permissions'], + iconCls: 'fa fa-user', + title: gettext('Users'), + itemId: 'users', + }); + + me.items.push({ + xtype: 'pveTokenView', + groups: ['permissions'], + iconCls: 'fa fa-user-o', + title: gettext('API Tokens'), + itemId: 'apitokens', + }); + + me.items.push({ + xtype: 'pmxTfaView', + title: gettext('Two Factor'), + groups: ['permissions'], + iconCls: 'fa fa-key', + itemId: 'tfa', + yubicoEnabled: true, + issuerName: `Proxmox VE - ${PVE.ClusterName || Proxmox.NodeName}`, + }); + + if (caps.dc['Sys.Audit']) { + me.items.push( + { + xtype: 'pveGroupView', + title: gettext('Groups'), + iconCls: 'fa fa-users', + groups: ['permissions'], + itemId: 'groups', + }, + { + xtype: 'pvePoolView', + title: gettext('Pools'), + iconCls: 'fa fa-tags', + groups: ['permissions'], + itemId: 'pools', + }, + { + xtype: 'pveRoleView', + title: gettext('Roles'), + iconCls: 'fa fa-male', + groups: ['permissions'], + itemId: 'roles', + }, + { + title: gettext('Realms'), + xtype: 'panel', + layout: { + type: 'border', + }, + groups: ['permissions'], + iconCls: 'fa fa-address-book-o', + itemId: 'domains', + items: [ + { + xtype: 'pveAuthView', + region: 'center', + border: false, + }, + { + xtype: 'pveRealmSyncJobView', + title: gettext('Realm Sync Jobs'), + region: 'south', + collapsible: true, + animCollapse: false, + border: false, + height: '50%', + }, + ], + }, + { + xtype: 'pveHAStatus', + title: 'HA', + iconCls: 'fa fa-heartbeat', + itemId: 'ha', + }, + { + title: gettext('Affinity Rules'), + groups: ['ha'], + xtype: 'pveHARulesView', + iconCls: 'fa fa-gears', + itemId: 'ha-rules', + }, + { + title: gettext('Fencing'), + groups: ['ha'], + iconCls: 'fa fa-bolt', + xtype: 'pveFencingView', + itemId: 'ha-fencing', + }, + ); + // always show on initial load, will be hiddea later if the SDN API calls don't exist, + // else it won't be shown at first if the user initially loads with DC selected + if (PVE.SDNInfo || PVE.SDNInfo === undefined) { + me.items.push( + { + xtype: 'pveSDNStatus', + title: gettext('SDN'), + iconCls: 'fa fa-sdn x-fa-sdn-treelist', + hidden: true, + itemId: 'sdn', + expandedOnInit: true, + }, + { + xtype: 'pveSDNZoneView', + groups: ['sdn'], + title: gettext('Zones'), + hidden: true, + iconCls: 'fa fa-th', + itemId: 'sdnzone', + }, + { + xtype: 'pveSDNVnet', + groups: ['sdn'], + title: 'VNets', + hidden: true, + iconCls: 'fa fa-network-wired x-fa-sdn-treelist', + itemId: 'sdnvnet', + }, + { + xtype: 'pveSDNOptions', + groups: ['sdn'], + title: gettext('Options'), + hidden: true, + iconCls: 'fa fa-gear', + itemId: 'sdnoptions', + }, + { + xtype: 'pveDhcpTree', + groups: ['sdn'], + title: gettext('IPAM'), + hidden: true, + iconCls: 'fa fa-map-signs', + itemId: 'sdnmappings', + }, + { + xtype: 'pveSDNFirewall', + groups: ['sdn'], + title: gettext('VNet Firewall'), + hidden: true, + iconCls: 'fa fa-shield', + itemId: 'sdnfirewall', + }, + { + xtype: 'pveSDNFabricView', + groups: ['sdn'], + title: gettext('Fabrics'), + hidden: true, + iconCls: 'fa fa-road', + itemId: 'sdnfabrics', + }, + ); + } + + if (Proxmox.UserName === 'root@pam') { + me.items.push({ + xtype: 'pveACMEClusterView', + title: 'ACME', + iconCls: 'fa fa-certificate', + itemId: 'acme', + }); + } + + me.items.push( + { + xtype: 'pveFirewallRules', + title: gettext('Firewall'), + allow_iface: true, + base_url: '/cluster/firewall/rules', + list_refs_url: '/cluster/firewall/refs', + iconCls: 'fa fa-shield', + itemId: 'firewall', + firewall_type: 'dc', + }, + { + xtype: 'pveFirewallOptions', + title: gettext('Options'), + groups: ['firewall'], + iconCls: 'fa fa-gear', + base_url: '/cluster/firewall/options', + onlineHelp: 'pve_firewall_cluster_wide_setup', + fwtype: 'dc', + itemId: 'firewall-options', + }, + { + xtype: 'pveSecurityGroups', + title: gettext('Security Group'), + groups: ['firewall'], + iconCls: 'fa fa-group', + itemId: 'firewall-sg', + }, + { + xtype: 'pveFirewallAliases', + title: gettext('Alias'), + groups: ['firewall'], + iconCls: 'fa fa-external-link', + base_url: '/cluster/firewall/aliases', + itemId: 'firewall-aliases', + }, + { + xtype: 'pveIPSet', + title: 'IPSet', + groups: ['firewall'], + iconCls: 'fa fa-list-ol', + base_url: '/cluster/firewall/ipset', + list_refs_url: '/cluster/firewall/refs', + itemId: 'firewall-ipset', + }, + { + xtype: 'pveMetricServerView', + title: gettext('Metric Server'), + iconCls: 'fa fa-bar-chart', + itemId: 'metricservers', + onlineHelp: 'external_metric_server', + }, + ); + } + + if ( + caps.mapping['Mapping.Audit'] || + caps.mapping['Mapping.Use'] || + caps.mapping['Mapping.Modify'] + ) { + me.items.push( + { + xtype: 'container', + onlineHelp: 'resource_mapping', + title: gettext('Resource Mappings'), + itemId: 'resources', + iconCls: 'fa fa-folder-o', + layout: { + type: 'vbox', + align: 'stretch', + multi: true, + }, + scrollable: true, + defaults: { + border: false, + }, + items: [ + { + xtype: 'pveDcPCIMapView', + title: gettext('PCI Devices'), + flex: 1, + }, + { + xtype: 'splitter', + collapsible: false, + performCollapse: false, + }, + { + xtype: 'pveDcUSBMapView', + title: gettext('USB Devices'), + flex: 1, + }, + ], + }, + { + xtype: 'pveDcDirMapView', + itemId: 'directories', + title: gettext('Directory Mappings'), + iconCls: 'fa fa-folder', + }, + ); + } + + if ( + caps.mapping['Mapping.Audit'] || + caps.mapping['Mapping.Use'] || + caps.mapping['Mapping.Modify'] + ) { + me.items.push({ + xtype: 'pmxNotificationConfigView', + title: gettext('Notifications'), + itemId: 'notification-targets', + iconCls: 'fa fa-bell-o', + baseUrl: '/cluster/notifications', + }); + } + + if (caps.dc['Sys.Audit']) { + me.items.push({ + xtype: 'pveDcSupport', + title: gettext('Support'), + itemId: 'support', + iconCls: 'fa fa-comments-o', + }); + } + + me.callParent(); + }, +}); +Ext.define('PVE.form.CorosyncLinkEditorController', { + extend: 'Ext.app.ViewController', + alias: 'controller.pveCorosyncLinkEditorController', + + addLinkIfEmpty: function () { + let view = this.getView(); + if (view.items || view.items.length === 0) { + this.addLink(); + } + }, + + addEmptyLink: function () { + this.addLink(); // discard parameters to allow being called from 'handler' + }, + + addLink: function (link) { + let me = this; + let view = me.getView(); + let vm = view.getViewModel(); + + let linkCount = vm.get('linkCount'); + if (linkCount >= vm.get('maxLinkCount')) { + return; + } + + link = link || {}; + + if (link.number === undefined) { + link.number = me.getNextFreeNumber(); + } + if (link.value === undefined) { + link.value = me.getNextFreeNetwork(); + } + + let linkSelector = Ext.create('PVE.form.CorosyncLinkSelector', { + maxLinkNumber: vm.get('maxLinkCount') - 1, + allowNumberEdit: vm.get('allowNumberEdit'), + allowBlankNetwork: link.allowBlank, + initNumber: link.number, + initNetwork: link.value, + text: link.text, + emptyText: link.emptyText, + + // needs to be set here, because we need to update the viewmodel + removeBtnHandler: function () { + let curLinkCount = vm.get('linkCount'); + + if (curLinkCount <= 1) { + return; + } + + vm.set('linkCount', curLinkCount - 1); + + // 'this' is the linkSelector here + view.remove(this); + + me.updateDeleteButtonState(); + }, + }); + + view.add(linkSelector); + + linkCount++; + vm.set('linkCount', linkCount); + + me.updateDeleteButtonState(); + }, + + // ExtJS trips on binding this for some reason, so do it manually + updateDeleteButtonState: function () { + let view = this.getView(); + let vm = view.getViewModel(); + + let disabled = vm.get('linkCount') <= 1; + + let deleteButtons = view.query('button[cls=removeLinkBtn]'); + Ext.Array.each(deleteButtons, (btn) => { + btn.setDisabled(disabled); + }); + }, + + getNextFreeNetwork: function () { + let view = this.getView(); + let vm = view.getViewModel(); + + let networksInUse = view.query('proxmoxNetworkSelector').map((selector) => selector.value); + + for (const network of vm.get('networks')) { + if (!networksInUse.includes(network)) { + return network; + } + } + return undefined; // default to empty field, user has to set up link manually + }, + + getNextFreeNumber: function () { + let view = this.getView(); + let vm = view.getViewModel(); + + let numbersInUse = view.query('numberfield').map((field) => field.value); + + for (let i = 0; i < vm.get('maxLinkCount'); i++) { + if (!numbersInUse.includes(i)) { + return i; + } + } + // all numbers in use, this should never happen since add button is disabled automatically + return 0; + }, +}); + +Ext.define('PVE.form.CorosyncLinkSelector', { + extend: 'Ext.panel.Panel', + xtype: 'pveCorosyncLinkSelector', + + mixins: ['Proxmox.Mixin.CBind'], + cbindData: [], + + // config + maxLinkNumber: 7, + allowNumberEdit: true, + allowBlankNetwork: false, + removeBtnHandler: undefined, + emptyText: '', + + // values + initNumber: 0, + initNetwork: '', + text: '', + + layout: 'hbox', + bodyPadding: 5, + border: 0, + + items: [ + { + xtype: 'displayfield', + fieldLabel: 'Link', + cbind: { + hidden: '{allowNumberEdit}', + value: '{initNumber}', + }, + width: 45, + labelWidth: 30, + allowBlank: false, + }, + { + xtype: 'numberfield', + fieldLabel: 'Link', + cbind: { + maxValue: '{maxLinkNumber}', + hidden: '{!allowNumberEdit}', + value: '{initNumber}', + }, + width: 80, + labelWidth: 30, + minValue: 0, + submitValue: false, // see getSubmitValue of network selector + allowBlank: false, + }, + { + xtype: 'proxmoxNetworkSelector', + cbind: { + allowBlank: '{allowBlankNetwork}', + value: '{initNetwork}', + emptyText: '{emptyText}', + }, + autoSelect: false, + valueField: 'address', + displayField: 'address', + width: 220, + margin: '0 5px 0 5px', + getSubmitValue: function () { + let me = this; + // link number is encoded into key, so we need to set field name before value retrieval + let linkNumber = me.prev('numberfield').getValue(); // always the correct one + me.name = 'link' + linkNumber; + return me.getValue(); + }, + }, + { + xtype: 'button', + iconCls: 'fa fa-trash-o', + cls: 'removeLinkBtn', + cbind: { + hidden: '{!allowNumberEdit}', + }, + handler: function () { + let me = this; + let parent = me.up('pveCorosyncLinkSelector'); + if (parent.removeBtnHandler !== undefined) { + parent.removeBtnHandler(); + } + }, + }, + { + xtype: 'label', + margin: '-1px 0 0 5px', + + // for muted effect + cls: 'x-form-item-label-default', + + cbind: { + text: '{text}', + }, + }, + ], + + initComponent: function () { + let me = this; + + me.callParent(); + + let numSelect = me.down('numberfield'); + let netSelect = me.down('proxmoxNetworkSelector'); + + numSelect.validator = me.createNoDuplicatesValidator( + 'numberfield', + gettext('Duplicate link number not allowed.'), + ); + + netSelect.validator = me.createNoDuplicatesValidator( + 'proxmoxNetworkSelector', + gettext('Duplicate link address not allowed.'), + ); + }, + + createNoDuplicatesValidator: function (queryString, errorMsg) { + // linkSelector generator + let view = this; + /** @this is the field itself, as the validator this is called from scopes it that way */ + return function (val) { + let me = this; + let form = view.up('form'); + let linkEditor = view.up('pveCorosyncLinkEditor'); + + if (!form.validating) { + // avoid recursion/double validation by setting temporary states + me.validating = true; + form.validating = true; + + // validate all other fields as well, to always mark both + // parties involved in a 'duplicate' error + form.isValid(); + + form.validating = false; + me.validating = false; + } else if (me.validating) { + // we'll be validated by the original call in the other if-branch, avoid double work + return true; + } + + if (val === undefined || (val instanceof String && val.length === 0)) { + return true; // let this be caught by allowBlank, if at all + } + + let allFields = linkEditor.query(queryString); + for (const field of allFields) { + if (field !== me && String(field.getValue()) === String(val)) { + return errorMsg; + } + } + return true; + }; + }, +}); + +Ext.define('PVE.form.CorosyncLinkEditor', { + extend: 'Ext.panel.Panel', + xtype: 'pveCorosyncLinkEditor', + + controller: 'pveCorosyncLinkEditorController', + + // only initial config, use setter otherwise + allowNumberEdit: true, + + viewModel: { + data: { + linkCount: 0, + maxLinkCount: 8, + networks: null, + allowNumberEdit: true, + infoText: '', + }, + formulas: { + addDisabled: function (get) { + return !get('allowNumberEdit') || get('linkCount') >= get('maxLinkCount'); + }, + dockHidden: function (get) { + return !(get('allowNumberEdit') || get('infoText')); + }, + }, + }, + + dockedItems: [ + { + xtype: 'toolbar', + dock: 'bottom', + defaultButtonUI: 'default', + border: false, + padding: '6 0 6 0', + bind: { + hidden: '{dockHidden}', + }, + items: [ + { + xtype: 'button', + text: gettext('Add'), + bind: { + disabled: '{addDisabled}', + hidden: '{!allowNumberEdit}', + }, + handler: 'addEmptyLink', + }, + { + xtype: 'label', + bind: { + text: '{infoText}', + }, + }, + ], + }, + ], + + setInfoText: function (text) { + let me = this; + let vm = me.getViewModel(); + + vm.set('infoText', text || ''); + }, + + setLinks: function (links) { + let me = this; + let controller = me.getController(); + let vm = me.getViewModel(); + + me.removeAll(); + vm.set('linkCount', 0); + + Ext.Array.each(links, (link) => controller.addLink(link)); + }, + + setDefaultLinks: function () { + let me = this; + let controller = me.getController(); + let vm = me.getViewModel(); + + me.removeAll(); + vm.set('linkCount', 0); + controller.addLink(); + }, + + // clears all links + setAllowNumberEdit: function (allow) { + let me = this; + let vm = me.getViewModel(); + vm.set('allowNumberEdit', allow); + me.removeAll(); + vm.set('linkCount', 0); + }, + + items: [ + { + // No links is never a valid scenario, but can occur during a slow load + xtype: 'hiddenfield', + submitValue: false, + isValid: function () { + let me = this; + let vm = me.up('pveCorosyncLinkEditor').getViewModel(); + return vm.get('linkCount') > 0; + }, + }, + ], + + initComponent: function () { + let me = this; + let vm = me.getViewModel(); + let controller = me.getController(); + + vm.set('allowNumberEdit', me.allowNumberEdit); + vm.set('infoText', me.infoText || ''); + + me.callParent(); + + // Request local node networks to pre-populate first link. + Proxmox.Utils.API2Request({ + url: '/nodes/localhost/network', + method: 'GET', + waitMsgTarget: me, + success: (response) => { + let data = response.result.data; + if (data.length > 0) { + data.sort((a, b) => a.iface.localeCompare(b.iface)); + let addresses = []; + for (let net of data) { + if (net.address) { + addresses.push(net.address); + } + if (net.address6) { + addresses.push(net.address6); + } + } + + vm.set('networks', addresses); + } + + // Always have at least one link, but account for delay in API, + // someone might have called 'setLinks' in the meantime - + // except if 'allowNumberEdit' is false, in which case we're + // probably waiting for the user to input the join info + if (vm.get('allowNumberEdit')) { + controller.addLinkIfEmpty(); + } + }, + failure: () => { + if (vm.get('allowNumberEdit')) { + controller.addLinkIfEmpty(); + } + }, + }); + }, +}); +Ext.define('PVE.dc.GroupEdit', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveDcGroupEdit'], + + initComponent: function () { + var me = this; + + me.isCreate = !me.groupid; + + var url; + var method; + + if (me.isCreate) { + url = '/api2/extjs/access/groups'; + method = 'POST'; + } else { + url = '/api2/extjs/access/groups/' + me.groupid; + method = 'PUT'; + } + + Ext.applyIf(me, { + subject: gettext('Group'), + url: url, + method: method, + items: [ + { + xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield', + fieldLabel: gettext('Name'), + name: 'groupid', + value: me.groupid, + allowBlank: false, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Comment'), + name: 'comment', + allowBlank: true, + }, + ], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load(); + } + }, +}); +Ext.define('PVE.dc.GroupView', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveGroupView'], + + onlineHelp: 'pveum_groups', + + stateful: true, + stateId: 'grid-groups', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-groups', + sorters: { + property: 'groupid', + direction: 'ASC', + }, + }); + + var reload = function () { + store.load(); + }; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + callback: function () { + reload(); + }, + baseurl: '/access/groups/', + }); + + var run_editor = function () { + var rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + var win = Ext.create('PVE.dc.GroupEdit', { + groupid: rec.data.groupid, + }); + win.on('destroy', reload); + win.show(); + }; + + var edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + var tbar = [ + { + text: gettext('Create'), + handler: function () { + var win = Ext.create('PVE.dc.GroupEdit', {}); + win.on('destroy', reload); + win.show(); + }, + }, + edit_btn, + remove_btn, + ]; + + Proxmox.Utils.monStoreErrors(me, store); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: tbar, + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: gettext('Name'), + width: 200, + sortable: true, + dataIndex: 'groupid', + }, + { + header: gettext('Comment'), + sortable: false, + renderer: Ext.String.htmlEncode, + dataIndex: 'comment', + flex: 1, + }, + { + header: gettext('Users'), + sortable: false, + dataIndex: 'users', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + listeners: { + activate: reload, + itemdblclick: run_editor, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.dc.Guests', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveDcGuests', + + title: gettext('Guests'), + height: 250, + layout: { + type: 'table', + columns: 2, + tableAttrs: { + style: { + width: '100%', + }, + }, + }, + bodyPadding: '0 20 20 20', + + defaults: { + xtype: 'box', + padding: '0 50 0 50', + style: { + 'text-align': 'center', + 'line-height': '1.5em', + 'font-size': '14px', + }, + }, + items: [ + { + itemId: 'qemu', + data: { + running: 0, + paused: 0, + stopped: 0, + template: 0, + }, + cls: 'centered-flex-column', + tpl: [ + '

    ' + gettext('Virtual Machines') + '

    ', + '
    ', + '
    ', + ' ', + gettext('Running'), + '
    ', + '
    {running}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Paused'), + '
    ', + '
    {paused}
    ', + '
    ', + '
    ', + '
    ', + '
    ', + ' ', + gettext('Stopped'), + '
    ', + '
    {stopped}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Templates'), + '
    ', + '
    {template}
    ', + '
    ', + '
    ', + ], + }, + { + itemId: 'lxc', + data: { + running: 0, + paused: 0, + stopped: 0, + template: 0, + }, + cls: 'centered-flex-column', + tpl: [ + '

    ' + gettext('LXC Container') + '

    ', + '
    ', + '
    ', + ' ', + gettext('Running'), + '
    ', + '
    {running}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Paused'), + '
    ', + '
    {paused}
    ', + '
    ', + '
    ', + '
    ', + '
    ', + ' ', + gettext('Stopped'), + '
    ', + '
    {stopped}
    ', + '
    ', + '', + '
    ', + '
    ', + ' ', + gettext('Templates'), + '
    ', + '
    {template}
    ', + '
    ', + '
    ', + ], + }, + { + itemId: 'error', + colspan: 2, + data: { + num: 0, + }, + columnWidth: 1, + padding: '10 250 0 250', + tpl: [ + '', + '
    ', + ' ', + gettext('Error'), + '
    ', + '
    {num}
    ', + '
    ', + ], + }, + ], + + updateValues: function (qemu, lxc, error) { + let me = this; + + let lazyUpdate = (query, newData) => { + let el = me.getComponent(query); + let currentData = el.data; + + let keys = Object.keys(newData); + if (keys.length === Object.keys(currentData).length) { + if (keys.every((k) => newData[k] === currentData[k])) { + return; // all stayed the same here, return early to avoid bogus regeneration + } + } + el.update(newData); + }; + lazyUpdate('qemu', qemu); + lazyUpdate('lxc', lxc); + lazyUpdate('error', { num: error }); + }, +}); +Ext.define('PVE.dc.Health', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveDcHealth', + + title: gettext('Health'), + + bodyPadding: 10, + height: 250, + layout: { + type: 'hbox', + align: 'stretch', + }, + + defaults: { + flex: 1, + xtype: 'box', + style: { + 'text-align': 'center', + }, + }, + + nodeList: [], + nodeIndex: 0, + + updateStatus: function (store, records, success) { + let me = this; + if (!success) { + return; + } + + let cluster = { + iconCls: PVE.Utils.get_health_icon('good', true), + text: gettext('Standalone node - no cluster defined'), + }; + let nodes = { + online: 0, + offline: 0, + }; + let numNodes = 1; // by default we have one node + for (const { data } of records) { + if (data.type === 'node') { + nodes[data.online === 1 ? 'online' : 'offline']++; + } else if (data.type === 'cluster') { + cluster.text = `${gettext('Cluster')}: ${data.name}, ${gettext('Quorate')}: `; + cluster.text += Proxmox.Utils.format_boolean(data.quorate); + if (data.quorate !== 1) { + cluster.iconCls = PVE.Utils.get_health_icon('critical', true); + } + numNodes = data.nodes; + } + } + + if (numNodes !== nodes.online + nodes.offline) { + nodes.offline = numNodes - nodes.online; + } + + me.getComponent('clusterstatus').updateHealth(cluster); + me.getComponent('nodestatus').update(nodes); + }, + + updateCeph: function (store, records, success) { + let me = this; + let cephstatus = me.getComponent('ceph'); + if (!success || records.length < 1) { + if (cephstatus.isVisible()) { + return; // if ceph status is already visible don't stop to update + } + // try all nodes until we either get a successful api call, or we tried all nodes + if (++me.nodeIndex >= me.nodeList.length) { + me.cephstore.stopUpdate(); + } else { + store + .getProxy() + .setUrl(`/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`); + } + return; + } + + let state = PVE.Utils.render_ceph_health(records[0].data.health || {}); + cephstatus.updateHealth(state); + cephstatus.setVisible(true); + }, + + listeners: { + destroy: function () { + let me = this; + me.cephstore.stopUpdate(); + }, + }, + + items: [ + { + itemId: 'clusterstatus', + xtype: 'pveHealthWidget', + title: gettext('Status'), + }, + { + itemId: 'nodestatus', + data: { + online: 0, + offline: 0, + }, + tpl: [ + '

    ' + gettext('Nodes') + '


    ', + '
    ', + '
    ', + ' ', + gettext('Online'), + '
    ', + '
    {online}
    ', + '

    ', + '
    ', + ' ', + gettext('Offline'), + '
    ', + '
    {offline}
    ', + '
    ', + ], + }, + { + itemId: 'ceph', + width: 250, + columnWidth: undefined, + userCls: 'pointer', + title: 'Ceph', + xtype: 'pveHealthWidget', + hidden: true, + listeners: { + element: 'el', + click: function () { + Ext.state.Manager.getProvider().set('dctab', { value: 'ceph' }, true); + }, + }, + }, + ], + + initComponent: function () { + let me = this; + + me.nodeList = PVE.data.ResourceStore.getNodes(); + me.nodeIndex = 0; + me.cephstore = Ext.create('Proxmox.data.UpdateStore', { + interval: 3000, + storeid: 'pve-cluster-ceph', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`, + }, + }); + me.callParent(); + me.mon(me.cephstore, 'load', me.updateCeph, me); + me.cephstore.startUpdate(); + }, +}); +/* This class defines the "Cluster log" tab of the bottom status panel + * A log entry is a timestamp associated with an action on a cluster + */ + +Ext.define('PVE.dc.Log', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveClusterLog'], + + initComponent: function () { + let me = this; + + let logstore = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'pve-cluster-log', + model: 'proxmox-cluster-log', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/log', + }, + }); + let store = Ext.create('Proxmox.data.DiffStore', { + rstore: logstore, + appendAtStart: true, + }); + + Ext.apply(me, { + store: store, + stateful: false, + + viewConfig: { + trackOver: false, + stripeRows: true, + getRowClass: function (record, index) { + let pri = record.get('pri'); + if (pri && pri <= 3) { + return 'proxmox-invalid-row'; + } + return undefined; + }, + }, + sortableColumns: false, + columns: [ + { + header: gettext('Time'), + dataIndex: 'time', + width: 150, + renderer: function (value) { + return Ext.Date.format(value, 'M d H:i:s'); + }, + }, + { + header: gettext('Node'), + dataIndex: 'node', + width: 150, + }, + { + header: gettext('Service'), + dataIndex: 'tag', + width: 100, + }, + { + header: 'PID', + dataIndex: 'pid', + width: 100, + }, + { + header: gettext('User name'), + dataIndex: 'user', + renderer: Ext.String.htmlEncode, + width: 150, + }, + { + header: gettext('Severity'), + dataIndex: 'pri', + renderer: PVE.Utils.render_serverity, + width: 100, + }, + { + header: gettext('Message'), + dataIndex: 'msg', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + listeners: { + activate: () => logstore.startUpdate(), + deactivate: () => logstore.stopUpdate(), + destroy: () => logstore.stopUpdate(), + }, + }); + + me.callParent(); + }, +}); +Ext.define( + 'PVE.dc.NodeView', + { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveDcNodeView', + + title: gettext('Nodes'), + disableSelection: true, + scrollable: true, + + columns: [ + { + header: gettext('Name'), + flex: 1, + sortable: true, + dataIndex: 'name', + }, + { + header: 'ID', + width: 40, + sortable: true, + dataIndex: 'nodeid', + }, + { + header: gettext('Online'), + width: 60, + sortable: true, + dataIndex: 'online', + renderer: function (value) { + var cls = value ? 'good' : 'critical'; + return ''; + }, + }, + { + header: gettext('Support'), + width: 100, + sortable: true, + dataIndex: 'level', + renderer: PVE.Utils.render_support_level, + }, + { + header: gettext('Server Address'), + width: 115, + sortable: true, + dataIndex: 'ip', + }, + { + header: gettext('CPU usage'), + sortable: true, + width: 110, + dataIndex: 'cpuusage', + tdCls: 'x-progressbar-default-cell', + xtype: 'widgetcolumn', + widget: { + xtype: 'pveProgressBar', + }, + }, + { + header: gettext('Memory usage'), + width: 110, + sortable: true, + tdCls: 'x-progressbar-default-cell', + dataIndex: 'memoryusage', + xtype: 'widgetcolumn', + widget: { + xtype: 'pveProgressBar', + warningThreshold: 0.9, + criticalThreshold: 0.975, + }, + }, + { + header: gettext('Uptime'), + sortable: true, + dataIndex: 'uptime', + align: 'right', + renderer: Proxmox.Utils.render_uptime, + }, + ], + + stateful: true, + stateId: 'grid-cluster-nodes', + tools: [ + { + type: 'up', + handler: function () { + let view = this.up('grid'); + view.setHeight(Math.max(view.getHeight() - 50, 250)); + }, + }, + { + type: 'down', + handler: function () { + let view = this.up('grid'); + view.setHeight(view.getHeight() + 50); + }, + }, + ], + }, + function () { + Ext.define('pve-dc-nodes', { + extend: 'Ext.data.Model', + fields: ['id', 'type', 'name', 'nodeid', 'ip', 'level', 'local', 'online'], + idProperty: 'id', + }); + }, +); + +Ext.define('PVE.widget.ProgressBar', { + extend: 'Ext.Progress', + alias: 'widget.pveProgressBar', + + animate: true, + textTpl: ['{percent}%'], + + warningThreshold: 0.8, + criticalThreshold: 0.9, + + setValue: function (value) { + let me = this; + + me.callParent([value]); + + me.removeCls(['warning', 'critical']); + + if (value >= me.criticalThreshold) { + me.addCls('critical'); + } else if (value >= me.warningThreshold) { + me.addCls('warning'); + } + }, +}); +Ext.define('PVE.dc.OptionView', { + extend: 'Proxmox.grid.ObjectGrid', + alias: ['widget.pveDcOptionView'], + + onlineHelp: 'datacenter_configuration_file', + + monStoreErrors: true, + userCls: 'proxmox-tags-full', + + add_inputpanel_row: function (name, text, opts) { + var me = this; + + opts = opts || {}; + me.rows = me.rows || {}; + + let canEdit = !Object.hasOwn(opts, 'caps') || opts.caps; + me.rows[name] = { + required: true, + defaultValue: opts.defaultValue, + header: text, + renderer: opts.renderer, + editor: canEdit + ? { + xtype: 'proxmoxWindowEdit', + width: opts.width || 350, + subject: text, + onlineHelp: opts.onlineHelp, + fieldDefaults: { + labelWidth: opts.labelWidth || 100, + }, + setValues: function (values) { + var edit_value = values[name]; + + if (opts.parseBeforeSet) { + edit_value = PVE.Parser.parsePropertyString(edit_value); + } + + Ext.Array.each(this.query('inputpanel'), function (panel) { + panel.setValues(edit_value); + }); + }, + url: opts.url, + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + if (values === undefined || Object.keys(values).length === 0) { + return { delete: name }; + } + var ret_val = {}; + ret_val[name] = PVE.Parser.printPropertyString(values); + return ret_val; + }, + items: opts.items, + }, + ], + } + : undefined, + }; + }, + + render_bwlimits: function (value) { + if (!value) { + return gettext('None'); + } + + let parsed = PVE.Parser.parsePropertyString(value); + return Object.entries(parsed) + .map(([k, v]) => k + ': ' + Proxmox.Utils.format_size(v * 1024) + '/s') + .join(','); + }, + + initComponent: function () { + var me = this; + + me.add_combobox_row('keyboard', gettext('Keyboard Layout'), { + renderer: PVE.Utils.render_kvm_language, + comboItems: Object.entries(PVE.Utils.kvm_keymaps), + defaultValue: '__default__', + deleteEmpty: true, + }); + me.add_text_row('http_proxy', gettext('HTTP proxy'), { + renderer: Ext.htmlEncode, + defaultValue: Proxmox.Utils.noneText, + vtype: 'HttpProxy', + deleteEmpty: true, + }); + me.add_combobox_row('console', gettext('Console Viewer'), { + renderer: PVE.Utils.render_console_viewer, + comboItems: Object.entries(PVE.Utils.console_map), + defaultValue: '__default__', + deleteEmpty: true, + }); + me.add_text_row('email_from', gettext('Email from address'), { + deleteEmpty: true, + vtype: 'proxmoxMail', + defaultValue: 'root@$hostname', + }); + me.add_text_row('mac_prefix', gettext('MAC address prefix'), { + deleteEmpty: true, + vtype: 'MacPrefix', + defaultValue: 'BC:24:11', + }); + me.add_inputpanel_row('migration', gettext('Migration Settings'), { + renderer: PVE.Utils.render_as_property_string, + labelWidth: 120, + url: '/api2/extjs/cluster/options', + defaultKey: 'type', + items: [ + { + xtype: 'displayfield', + name: 'type', + fieldLabel: gettext('Type'), + value: 'secure', + submitValue: true, + }, + { + xtype: 'proxmoxNetworkSelector', + name: 'network', + fieldLabel: gettext('Network'), + value: null, + emptyText: Proxmox.Utils.defaultText, + autoSelect: false, + skipEmptyText: true, + editable: true, + notFoundIsValid: true, + vtype: 'IP64CIDRAddress', + type: 'include_sdn', + }, + ], + }); + me.add_inputpanel_row('replication', gettext('Replication Settings'), { + renderer: PVE.Utils.render_as_property_string, + labelWidth: 120, + url: '/api2/extjs/cluster/options', + defaultKey: 'type', + items: [ + { + xtype: 'displayfield', + name: 'type', + fieldLabel: gettext('Type'), + value: 'secure', + submitValue: true, + }, + { + xtype: 'proxmoxNetworkSelector', + name: 'network', + fieldLabel: gettext('Network'), + value: null, + emptyText: Proxmox.Utils.defaultText, + autoSelect: false, + skipEmptyText: true, + editable: true, + notFoundIsValid: true, + vtype: 'IP64CIDRAddress', + type: 'include_sdn', + }, + ], + }); + me.add_inputpanel_row('ha', gettext('HA Settings'), { + renderer: PVE.Utils.render_dc_ha_opts, + labelWidth: 120, + url: '/api2/extjs/cluster/options', + onlineHelp: 'ha_manager_shutdown_policy', + items: [ + { + xtype: 'proxmoxKVComboBox', + name: 'shutdown_policy', + fieldLabel: gettext('Shutdown Policy'), + deleteEmpty: false, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (conditional)'], + ['freeze', 'freeze'], + ['failover', 'failover'], + ['migrate', 'migrate'], + ['conditional', 'conditional'], + ], + defaultValue: '__default__', + }, + ], + }); + me.add_inputpanel_row('crs', gettext('Cluster Resource Scheduling'), { + renderer: PVE.Utils.render_as_property_string, + width: 450, + labelWidth: 120, + url: '/api2/extjs/cluster/options', + onlineHelp: 'ha_manager_crs', + items: [ + { + xtype: 'proxmoxKVComboBox', + name: 'ha', + fieldLabel: gettext('HA Scheduling'), + deleteEmpty: false, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (basic)'], + ['basic', 'Basic (Resource Count)'], + ['static', 'Static Load'], + ], + defaultValue: '__default__', + }, + { + xtype: 'proxmoxcheckbox', + name: 'ha-rebalance-on-start', + fieldLabel: gettext('Rebalance on Start'), + boxLabel: gettext( + 'Use CRS to select the least loaded node when starting an HA service', + ), + value: 0, + }, + ], + }); + me.add_inputpanel_row('u2f', gettext('U2F Settings'), { + renderer: (v) => + !v ? Proxmox.Utils.NoneText : Ext.htmlEncode(PVE.Parser.printPropertyString(v)), + width: 450, + url: '/api2/extjs/cluster/options', + onlineHelp: 'pveum_configure_u2f', + items: [ + { + xtype: 'textfield', + name: 'appid', + fieldLabel: gettext('U2F AppID URL'), + emptyText: gettext('Defaults to origin'), + value: '', + deleteEmpty: true, + skipEmptyText: true, + submitEmptyText: false, + }, + { + xtype: 'textfield', + name: 'origin', + fieldLabel: gettext('U2F Origin'), + emptyText: gettext('Defaults to requesting host URI'), + value: '', + deleteEmpty: true, + skipEmptyText: true, + submitEmptyText: false, + }, + { + xtype: 'box', + height: 25, + html: + `${gettext('Note:')} ` + + gettext('U2F is deprecated, use WebAuthn'), + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('NOTE: Changing an AppID breaks existing U2F registrations!'), + }, + ], + }); + me.add_inputpanel_row('webauthn', gettext('WebAuthn Settings'), { + renderer: (v) => + !v ? Proxmox.Utils.NoneText : Ext.htmlEncode(PVE.Parser.printPropertyString(v)), + width: 450, + url: '/api2/extjs/cluster/options', + onlineHelp: 'pveum_configure_webauthn', + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Name'), + name: 'rp', // NOTE: relying party consists of name and id, this is the name + allowBlank: false, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Origin'), + emptyText: Ext.String.format( + gettext('Domain Lockdown (e.g., {0})'), + document.location.origin, + ), + name: 'origin', + allowBlank: true, + }, + { + xtype: 'textfield', + fieldLabel: 'ID', + name: 'id', + allowBlank: false, + listeners: { + dirtychange: (f, isDirty) => + f + .up('panel') + .down('box[id=idChangeWarning]') + .setHidden(!f.originalValue || !isDirty), + }, + }, + { + xtype: 'container', + layout: 'hbox', + items: [ + { + xtype: 'box', + flex: 1, + }, + { + xtype: 'button', + text: gettext('Auto-fill'), + iconCls: 'fa fa-fw fa-pencil-square-o', + handler: function (button, ev) { + let panel = this.up('panel'); + let fqdn = document.location.hostname; + + panel.down('field[name=rp]').setValue(fqdn); + + let idField = panel.down('field[name=id]'); + let currentID = idField.getValue(); + if (!currentID || currentID.length === 0) { + idField.setValue(fqdn); + } + }, + }, + ], + }, + { + xtype: 'box', + height: 25, + html: + `${gettext('Note:')} ` + + gettext('WebAuthn requires using a trusted certificate.'), + }, + { + xtype: 'box', + id: 'idChangeWarning', + hidden: true, + padding: '5 0 0 0', + html: + ' ' + + gettext('Changing the ID breaks existing WebAuthn TFA entries.'), + }, + ], + }); + me.add_inputpanel_row('bwlimit', gettext('Bandwidth Limits'), { + renderer: me.render_bwlimits, + width: 450, + url: '/api2/extjs/cluster/options', + parseBeforeSet: true, + labelWidth: 120, + items: [ + { + xtype: 'pveBandwidthField', + name: 'default', + fieldLabel: gettext('Default'), + emptyText: gettext('none'), + backendUnit: 'KiB', + }, + { + xtype: 'pveBandwidthField', + name: 'restore', + fieldLabel: gettext('Backup Restore'), + emptyText: gettext('default'), + backendUnit: 'KiB', + }, + { + xtype: 'pveBandwidthField', + name: 'migration', + fieldLabel: gettext('Migration'), + emptyText: gettext('default'), + backendUnit: 'KiB', + }, + { + xtype: 'pveBandwidthField', + name: 'clone', + fieldLabel: gettext('Clone'), + emptyText: gettext('default'), + backendUnit: 'KiB', + }, + { + xtype: 'pveBandwidthField', + name: 'move', + fieldLabel: gettext('Disk Move'), + emptyText: gettext('default'), + backendUnit: 'KiB', + }, + ], + }); + me.add_integer_row('max_workers', gettext('Maximal Workers/bulk-action'), { + deleteEmpty: true, + defaultValue: 4, + minValue: 1, + maxValue: 64, // arbitrary but generous limit as limits are good + }); + me.add_inputpanel_row('next-id', gettext('Next Free VMID Range'), { + renderer: PVE.Utils.render_as_property_string, + url: '/api2/extjs/cluster/options', + items: [ + { + xtype: 'proxmoxintegerfield', + name: 'lower', + fieldLabel: gettext('Lower'), + emptyText: '100', + minValue: 100, + maxValue: 1000 * 1000 * 1000 - 1, + submitValue: true, + }, + { + xtype: 'proxmoxintegerfield', + name: 'upper', + fieldLabel: gettext('Upper'), + emptyText: '1.000.000', + minValue: 100, + maxValue: 1000 * 1000 * 1000 - 1, + submitValue: true, + }, + ], + }); + me.rows['tag-style'] = { + required: true, + renderer: (value) => { + if (value === undefined) { + return gettext('No Overrides'); + } + let colors = PVE.UIOptions.parseTagOverrides(value?.['color-map']); + let shape = value.shape; + let shapeText = PVE.UIOptions.tagTreeStyles[shape ?? '__default__']; + let txt = Ext.String.format(gettext('Tree Shape: {0}'), shapeText); + let orderText = PVE.UIOptions.tagOrderOptions[value.ordering ?? '__default__']; + txt += `, ${Ext.String.format(gettext('Ordering: {0}'), orderText)}`; + if (value['case-sensitive']) { + txt += `, ${gettext('Case-Sensitive')}`; + } + if (Object.keys(colors).length > 0) { + txt += `, ${gettext('Color Overrides')}: `; + for (const tag of Object.keys(colors)) { + txt += Proxmox.Utils.getTagElement(tag, colors); + } + } + return txt; + }, + header: gettext('Tag Style Override'), + editor: { + xtype: 'proxmoxWindowEdit', + width: 800, + subject: gettext('Tag Color Override'), + onlineHelp: 'gui_tags', + fieldDefaults: { + labelWidth: 100, + }, + url: '/api2/extjs/cluster/options', + items: [ + { + xtype: 'inputpanel', + setValues: function (values) { + if (values === undefined) { + return undefined; + } + values = values?.['tag-style'] ?? {}; + values.shape = values.shape || '__default__'; + values.colors = values['color-map']; + return Proxmox.panel.InputPanel.prototype.setValues.call(this, values); + }, + onGetValues: function (values) { + let style = {}; + if (values.colors) { + style['color-map'] = values.colors; + } + if (values.shape && values.shape !== '__default__') { + style.shape = values.shape; + } + if (values.ordering) { + style.ordering = values.ordering; + } + if (values['case-sensitive']) { + style['case-sensitive'] = 1; + } + let value = PVE.Parser.printPropertyString(style); + if (value === '') { + return { + delete: 'tag-style', + }; + } + return { + 'tag-style': value, + }; + }, + items: [ + { + name: 'shape', + xtype: 'proxmoxComboGrid', + fieldLabel: gettext('Tree Shape'), + valueField: 'value', + displayField: 'display', + allowBlank: false, + listConfig: { + columns: [ + { + header: gettext('Option'), + dataIndex: 'display', + flex: 1, + }, + { + header: gettext('Preview'), + dataIndex: 'value', + renderer: function (value) { + let cls = value ?? '__default__'; + if (value === '__default__') { + cls = 'circle'; + } + let tags = PVE.Utils.renderTags('preview'); + return `
    ${tags}
    `; + }, + flex: 1, + }, + ], + }, + store: { + data: Object.entries(PVE.UIOptions.tagTreeStyles).map((v) => ({ + value: v[0], + display: v[1], + })), + }, + deleteDefault: true, + defaultValue: '__default__', + deleteEmpty: true, + }, + { + name: 'ordering', + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Ordering'), + comboItems: Object.entries(PVE.UIOptions.tagOrderOptions), + defaultValue: '__default__', + value: '__default__', + deleteEmpty: true, + }, + { + name: 'case-sensitive', + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Case-Sensitive'), + boxLabel: gettext('Applies to new edits'), + value: 0, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Color Overrides'), + }, + { + name: 'colors', + xtype: 'pveTagColorGrid', + deleteEmpty: true, + height: 300, + }, + ], + }, + ], + }, + }; + + me.rows['user-tag-access'] = { + required: true, + renderer: (value) => { + if (value === undefined) { + return Ext.String.format(gettext('Mode: {0}'), 'free'); + } + let mode = value?.['user-allow'] ?? 'free'; + let list = value?.['user-allow-list']?.join(',') ?? ''; + let modeTxt = Ext.String.format(gettext('Mode: {0}'), mode); + let overrides = PVE.UIOptions.tagOverrides; + let tags = PVE.Utils.renderTags(list, overrides); + let listTxt = tags !== '' ? `, ${gettext('Pre-defined:')} ${tags}` : ''; + return `${modeTxt}${listTxt}`; + }, + header: gettext('User Tag Access'), + editor: { + xtype: 'pveUserTagAccessEdit', + }, + }; + + me.rows['registered-tags'] = { + required: true, + renderer: (value) => { + if (value === undefined) { + return gettext('No Registered Tags'); + } + let overrides = PVE.UIOptions.tagOverrides; + return PVE.Utils.renderTags(value.join(','), overrides); + }, + header: gettext('Registered Tags'), + editor: { + xtype: 'pveRegisteredTagEdit', + }, + }; + + me.add_textareafield_row('consent-text', gettext('Consent Text'), { + deleteEmpty: true, + fieldOpts: { + maxLength: 64 * 1024, + }, + onlineHelp: 'gui_consent_banner', + }); + + me.selModel = Ext.create('Ext.selection.RowModel', {}); + + Ext.apply(me, { + tbar: [ + { + text: gettext('Edit'), + xtype: 'proxmoxButton', + disabled: true, + handler: function () { + me.run_editor(); + }, + selModel: me.selModel, + }, + ], + url: '/api2/json/cluster/options', + editorConfig: { + url: '/api2/extjs/cluster/options', + }, + interval: 5000, + cwidth1: 200, + listeners: { + itemdblclick: me.run_editor, + }, + }); + + me.callParent(); + + // set the new value for the default console + me.mon(me.rstore, 'load', function (store, records, success) { + if (!success) { + return; + } + + var rec = store.getById('console'); + PVE.UIOptions.options.console = rec.data.value; + if (rec.data.value === '__default__') { + delete PVE.UIOptions.options.console; + } + + PVE.UIOptions.options['tag-style'] = store.getById('tag-style')?.data?.value; + PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']); + PVE.UIOptions.fireUIConfigChanged(); + }); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + me.on('deactivate', me.rstore.stopUpdate); + }, +}); +Ext.define('pve-permissions', { + extend: 'Ext.data.TreeModel', + fields: [ + 'text', + 'type', + { + type: 'boolean', + name: 'propagate', + }, + ], +}); + +Ext.define('PVE.dc.PermissionGridPanel', { + extend: 'Ext.tree.Panel', + alias: 'widget.pveUserPermissionGrid', + + onlineHelp: 'chapter_user_management', + + scrollable: true, + layout: 'fit', + rootVisible: false, + animate: false, + sortableColumns: false, + + columns: [ + { + xtype: 'treecolumn', + header: gettext('Path') + '/' + gettext('Permission'), + dataIndex: 'text', + flex: 6, + }, + { + header: gettext('Propagate'), + dataIndex: 'propagate', + flex: 1, + renderer: function (value) { + if (Ext.isDefined(value)) { + return Proxmox.Utils.format_boolean(value); + } + return ''; + }, + }, + ], + + initComponent: function () { + let me = this; + + Proxmox.Utils.API2Request({ + url: '/access/permissions?userid=' + me.userid, + method: 'GET', + failure: function (response, opts) { + Proxmox.Utils.setErrorMask(me, response.htmlStatus); + me.load_task.delay(me.load_delay); + }, + success: function (response, opts) { + Proxmox.Utils.setErrorMask(me, false); + let result = Ext.decode(response.responseText); + let data = result.data || {}; + + let root = { + name: '__root', + expanded: true, + children: [], + }; + let idhash = { + '/': { + children: [], + text: '/', + type: 'path', + }, + }; + Ext.Object.each(data, function (path, perms) { + let path_item = { + text: path, + type: 'path', + children: [], + }; + Ext.Object.each(perms, function (perm, propagate) { + let perm_item = { + text: perm, + type: 'perm', + propagate: propagate === 1, + iconCls: 'fa fa-fw fa-unlock', + leaf: true, + }; + path_item.children.push(perm_item); + path_item.expandable = true; + }); + idhash[path] = path_item; + }); + + Ext.Object.each(idhash, function (path, item) { + let parent_item = idhash['/']; + if (path === '/') { + parent_item = root; + item.expanded = true; + } else { + let split_path = path.split('/'); + while (split_path.pop()) { + let parent_path = split_path.join('/'); + if (idhash[parent_path]) { + parent_item = idhash[parent_path]; + break; + } + } + } + parent_item.children.push(item); + }); + + me.setRootNode(root); + }, + }); + + me.callParent(); + + me.store.sorters.add( + new Ext.util.Sorter({ + sorterFn: function (rec1, rec2) { + let v1 = rec1.data.text, + v2 = rec2.data.text; + if (rec1.data.type !== rec2.data.type) { + v2 = rec1.data.type; + v1 = rec2.data.type; + } + if (v1 > v2) { + return 1; + } else if (v1 < v2) { + return -1; + } + return 0; + }, + }), + ); + }, +}); + +Ext.define('PVE.dc.PermissionView', { + extend: 'Ext.window.Window', + alias: 'widget.userShowPermissionWindow', + mixins: ['Proxmox.Mixin.CBind'], + + scrollable: true, + width: 800, + height: 600, + layout: 'fit', + cbind: { + title: (get) => + Ext.String.htmlEncode(get('userid')) + ` - ${gettext('Granted Permissions')}`, + }, + items: [ + { + xtype: 'pveUserPermissionGrid', + cbind: { + userid: '{userid}', + }, + }, + ], +}); +Ext.define('PVE.dc.PoolEdit', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveDcPoolEdit'], + mixins: ['Proxmox.Mixin.CBind'], + + subject: gettext('Pool'), + + cbindData: { + poolid: '', + isCreate: (cfg) => !cfg.poolid, + }, + + cbind: { + url: (get) => `/api2/extjs/pools/${!get('isCreate') ? '?poolid=' + get('poolid') : ''}`, + method: (get) => (get('isCreate') ? 'POST' : 'PUT'), + }, + + items: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + cbind: { + editable: '{isCreate}', + value: '{poolid}', + }, + name: 'poolid', + allowBlank: false, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Comment'), + name: 'comment', + allowBlank: true, + }, + ], + + initComponent: function () { + let me = this; + me.callParent(); + if (me.poolid) { + me.load({ + success: function (response) { + let data = response.result.data; + if (Ext.isArray(data)) { + me.setValues(data[0]); + } else { + me.setValues(data); + } + }, + }); + } + }, +}); +Ext.define('PVE.dc.PoolView', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pvePoolView'], + + onlineHelp: 'pveum_pools', + + stateful: true, + stateId: 'grid-pools', + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-pools', + sorters: { + property: 'poolid', + direction: 'ASC', + }, + }); + + var reload = function () { + store.load(); + }; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/pools/', + callback: function () { + reload(); + }, + getUrl: function (rec) { + return '/pools/?poolid=' + rec.getId(); + }, + }); + + var run_editor = function () { + var rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + var win = Ext.create('PVE.dc.PoolEdit', { + poolid: rec.data.poolid, + }); + win.on('destroy', reload); + win.show(); + }; + + var edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + var tbar = [ + { + text: gettext('Create'), + handler: function () { + var win = Ext.create('PVE.dc.PoolEdit', {}); + win.on('destroy', reload); + win.show(); + }, + }, + edit_btn, + remove_btn, + ]; + + Proxmox.Utils.monStoreErrors(me, store); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: tbar, + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: gettext('Name'), + width: 200, + sortable: true, + dataIndex: 'poolid', + }, + { + header: gettext('Comment'), + sortable: false, + renderer: Ext.String.htmlEncode, + dataIndex: 'comment', + flex: 1, + }, + ], + listeners: { + activate: reload, + itemdblclick: run_editor, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.dc.RoleEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveDcRoleEdit', + + width: 400, + + initComponent: function () { + var me = this; + + me.isCreate = !me.roleid; + + var url; + var method; + + if (me.isCreate) { + url = '/api2/extjs/access/roles'; + method = 'POST'; + } else { + url = '/api2/extjs/access/roles/' + me.roleid; + method = 'PUT'; + } + + Ext.applyIf(me, { + subject: gettext('Role'), + url: url, + method: method, + items: [ + { + xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield', + name: 'roleid', + value: me.roleid, + allowBlank: false, + fieldLabel: gettext('Name'), + }, + { + xtype: 'pvePrivilegesSelector', + name: 'privs', + value: me.privs, + allowBlank: false, + fieldLabel: gettext('Privileges'), + }, + ], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response) { + var data = response.result.data; + var keys = Ext.Object.getKeys(data); + + me.setValues({ + privs: keys, + roleid: me.roleid, + }); + }, + }); + } + }, +}); +Ext.define('PVE.dc.RoleView', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveRoleView'], + + onlineHelp: 'pveum_roles', + + stateful: true, + stateId: 'grid-roles', + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pmx-roles', + sorters: { + property: 'roleid', + direction: 'ASC', + }, + }); + Proxmox.Utils.monStoreErrors(me, store); + + let sm = Ext.create('Ext.selection.RowModel', {}); + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + if (rec.data.special) { + return; + } + Ext.create('PVE.dc.RoleEdit', { + roleid: rec.data.roleid, + privs: rec.data.privs, + listeners: { + destroy: () => store.load(), + }, + autoShow: true, + }); + }; + + Ext.apply(me, { + store: store, + selModel: sm, + + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: gettext('Built-In'), + width: 65, + sortable: true, + dataIndex: 'special', + renderer: Proxmox.Utils.format_boolean, + }, + { + header: gettext('Name'), + width: 150, + sortable: true, + dataIndex: 'roleid', + }, + { + itemid: 'privs', + header: gettext('Privileges'), + sortable: false, + renderer: (value, metaData) => { + if (!value) { + return '-'; + } + metaData.style = 'white-space:normal;'; // allow word wrap + return value.replace(/,/g, ' '); + }, + variableRowHeight: true, + dataIndex: 'privs', + flex: 1, + }, + ], + listeners: { + activate: function () { + store.load(); + }, + itemdblclick: run_editor, + }, + tbar: [ + { + text: gettext('Create'), + handler: function () { + Ext.create('PVE.dc.RoleEdit', { + listeners: { + destroy: () => store.load(), + }, + autoShow: true, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + enableFn: (rec) => !rec.data.special, + }, + { + xtype: 'proxmoxStdRemoveButton', + selModel: sm, + callback: () => store.load(), + baseurl: '/access/roles/', + enableFn: (rec) => !rec.data.special, + }, + ], + }); + + me.callParent(); + }, +}); +Ext.define('pve-security-groups', { + extend: 'Ext.data.Model', + + fields: ['group', 'comment', 'digest'], + idProperty: 'group', +}); + +Ext.define('PVE.SecurityGroupEdit', { + extend: 'Proxmox.window.Edit', + + base_url: '/cluster/firewall/groups', + + allow_iface: false, + + initComponent: function () { + var me = this; + + me.isCreate = me.group_name === undefined; + + var subject; + + me.url = '/api2/extjs' + me.base_url; + me.method = 'POST'; + + var items = [ + { + xtype: 'textfield', + name: 'group', + value: me.group_name || '', + fieldLabel: gettext('Name'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'comment', + value: me.group_comment || '', + fieldLabel: gettext('Comment'), + }, + ]; + + if (me.isCreate) { + subject = gettext('Security Group'); + } else { + subject = gettext('Security Group') + " '" + me.group_name + "'"; + items.push({ + xtype: 'hiddenfield', + name: 'rename', + value: me.group_name, + }); + } + + var ipanel = Ext.create('Proxmox.panel.InputPanel', { + // InputPanel does not have a 'create' property, does it need a 'isCreate' + isCreate: me.isCreate, + items: items, + }); + + Ext.apply(me, { + subject: subject, + items: [ipanel], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.SecurityGroupList', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveSecurityGroupList', + + stateful: true, + stateId: 'grid-securitygroups', + + rulePanel: undefined, + + addBtn: undefined, + removeBtn: undefined, + editBtn: undefined, + + base_url: '/cluster/firewall/groups', + + initComponent: function () { + let me = this; + if (!me.base_url) { + throw 'no base_url specified'; + } + + let store = new Ext.data.Store({ + model: 'pve-security-groups', + proxy: { + type: 'proxmox', + url: '/api2/json' + me.base_url, + }, + sorters: { + property: 'group', + direction: 'ASC', + }, + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let caps = Ext.state.Manager.get('GuiCap'); + let canEdit = !!caps.dc['Sys.Modify']; + + let reload = function () { + let oldrec = sm.getSelection()[0]; + store.load((records, operation, success) => { + if (oldrec) { + let rec = store.findRecord('group', oldrec.data.group, 0, false, true, true); + if (rec) { + sm.select(rec); + } + } + }); + }; + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec || !canEdit) { + return; + } + Ext.create('PVE.SecurityGroupEdit', { + digest: rec.data.digest, + group_name: rec.data.group, + group_comment: rec.data.comment, + listeners: { + destroy: () => reload(), + }, + autoShow: true, + }); + }; + + me.editBtn = new Proxmox.button.Button({ + text: gettext('Edit'), + enableFn: (rec) => canEdit, + disabled: true, + selModel: sm, + handler: run_editor, + }); + me.addBtn = new Proxmox.button.Button({ + text: gettext('Create'), + disabled: !canEdit, + handler: function () { + sm.deselectAll(); + var win = Ext.create('PVE.SecurityGroupEdit', {}); + win.show(); + win.on('destroy', reload); + }, + }); + + me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: me.base_url + '/', + enableFn: (rec) => canEdit && rec && me.base_url, + callback: () => reload(), + }); + + Ext.apply(me, { + store: store, + tbar: ['' + gettext('Group') + ':', me.addBtn, me.removeBtn, me.editBtn], + selModel: sm, + columns: [ + { + header: gettext('Group'), + dataIndex: 'group', + width: '100', + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 1, + }, + ], + listeners: { + itemdblclick: run_editor, + select: function (_sm, rec) { + if (!me.rulePanel) { + me.rulePanel = me.up('panel').down('pveFirewallRules'); + } + me.rulePanel.setBaseUrl(`/cluster/firewall/groups/${rec.data.group}`); + }, + deselect: function () { + if (!me.rulePanel) { + me.rulePanel = me.up('panel').down('pveFirewallRules'); + } + me.rulePanel.setBaseUrl(undefined); + }, + show: reload, + }, + }); + + me.callParent(); + + store.load(); + }, +}); + +Ext.define('PVE.SecurityGroups', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSecurityGroups', + + title: 'Security Groups', + onlineHelp: 'pve_firewall_security_groups', + + layout: 'border', + + items: [ + { + xtype: 'pveFirewallRules', + region: 'center', + allow_groups: false, + list_refs_url: '/cluster/firewall/refs', + tbar_prefix: '' + gettext('Rules') + ':', + border: false, + firewall_type: 'group', + }, + { + xtype: 'pveSecurityGroupList', + region: 'west', + width: '25%', + border: false, + split: true, + }, + ], + listeners: { + show: function () { + let sglist = this.down('pveSecurityGroupList'); + sglist.fireEvent('show', sglist); + }, + }, +}); +Ext.define( + 'PVE.dc.StorageView', + { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveStorageView'], + + onlineHelp: 'chapter_storage', + + stateful: true, + stateId: 'grid-dc-storage', + + createStorageEditWindow: function (type, sid) { + let schema = PVE.Utils.storageSchema[type]; + if (!schema || !schema.ipanel) { + throw 'no editor registered for storage type: ' + type; + } + + Ext.create('PVE.storage.BaseEdit', { + paneltype: 'PVE.storage.' + schema.ipanel, + type: type, + storageId: sid, + canDoBackups: schema.backups, + autoShow: true, + listeners: { + destroy: this.reloadStore, + }, + }); + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-storage', + proxy: { + type: 'proxmox', + url: '/api2/json/storage', + }, + sorters: { + property: 'storage', + direction: 'ASC', + }, + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let { type, storage } = rec.data; + me.createStorageEditWindow(type, storage); + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/storage/', + callback: () => store.load(), + }); + + // else we cannot dynamically generate the add menu handlers + let addHandleGenerator = function (type) { + return function () { + me.createStorageEditWindow(type); + }; + }; + let addMenuItems = []; + for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) { + if (storage.hideAdd) { + continue; + } + addMenuItems.push({ + text: PVE.Utils.format_storage_type(type), + iconCls: 'fa fa-fw fa-' + storage.faIcon, + handler: addHandleGenerator(type), + }); + } + + Ext.apply(me, { + store: store, + reloadStore: () => store.load(), + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + items: addMenuItems, + }), + }, + remove_btn, + edit_btn, + ], + columns: [ + { + header: 'ID', + flex: 2, + sortable: true, + dataIndex: 'storage', + }, + { + header: gettext('Type'), + flex: 1, + sortable: true, + dataIndex: 'type', + renderer: PVE.Utils.format_storage_type, + }, + { + header: gettext('Content'), + flex: 3, + sortable: true, + dataIndex: 'content', + renderer: PVE.Utils.format_content_types, + }, + { + header: gettext('Path') + '/' + gettext('Target'), + flex: 2, + sortable: true, + dataIndex: 'path', + renderer: function (value, metaData, record) { + if (record.data.target) { + return record.data.target; + } + return value; + }, + }, + { + header: gettext('Shared'), + flex: 1, + sortable: true, + dataIndex: 'shared', + renderer: Proxmox.Utils.format_boolean, + }, + { + header: gettext('Enabled'), + flex: 1, + sortable: true, + dataIndex: 'disable', + renderer: Proxmox.Utils.format_neg_boolean, + }, + { + header: gettext('Bandwidth Limit'), + flex: 2, + sortable: true, + dataIndex: 'bwlimit', + }, + ], + listeners: { + activate: () => store.load(), + itemdblclick: run_editor, + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-storage', { + extend: 'Ext.data.Model', + fields: [ + 'path', + 'type', + 'content', + 'server', + 'portal', + 'target', + 'export', + 'storage', + { name: 'shared', type: 'boolean' }, + { name: 'disable', type: 'boolean' }, + ], + idProperty: 'storage', + }); + }, +); +Ext.define('PVE.dc.Summary', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveDcSummary', + + scrollable: true, + + bodyPadding: 5, + + layout: 'column', + + defaults: { + padding: 5, + columnWidth: 1, + }, + + items: [ + { + itemId: 'dcHealth', + xtype: 'pveDcHealth', + }, + { + itemId: 'dcGuests', + xtype: 'pveDcGuests', + }, + { + title: gettext('Resources'), + xtype: 'panel', + minHeight: 250, + bodyPadding: 5, + layout: 'hbox', + defaults: { + xtype: 'proxmoxGauge', + flex: 1, + }, + items: [ + { + title: gettext('CPU'), + itemId: 'cpu', + }, + { + title: gettext('Memory'), + itemId: 'memory', + warningThreshold: 0.9, + criticalThreshold: 0.975, + }, + { + title: gettext('Storage'), + itemId: 'storage', + }, + ], + }, + { + itemId: 'nodeview', + xtype: 'pveDcNodeView', + height: 250, + }, + { + title: gettext('Subscriptions'), + height: 220, + items: [ + { + xtype: 'pveHealthWidget', + itemId: 'subscriptions', + userCls: 'pointer', + listeners: { + element: 'el', + click: function () { + if (this.component.userCls === 'pointer') { + window.open( + 'https://www.proxmox.com/en/proxmox-virtual-environment/pricing', + '_blank', + ); + } + }, + }, + }, + ], + }, + ], + + listeners: { + resize: function (panel) { + Proxmox.Utils.updateColumns(panel); + }, + }, + + initComponent: function () { + var me = this; + + var rstore = Ext.create('Proxmox.data.UpdateStore', { + interval: 3000, + storeid: 'pve-cluster-status', + model: 'pve-dc-nodes', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/status', + }, + }); + + var gridstore = Ext.create('Proxmox.data.DiffStore', { + rstore: rstore, + filters: { + property: 'type', + value: 'node', + }, + sorters: { + property: 'id', + direction: 'ASC', + }, + }); + + me.callParent(); + + me.getComponent('nodeview').setStore(gridstore); + + var gueststatus = me.getComponent('dcGuests'); + + var cpustat = me.down('#cpu'); + var memorystat = me.down('#memory'); + var storagestat = me.down('#storage'); + var sp = Ext.state.Manager.getProvider(); + + me.mon(PVE.data.ResourceStore, 'load', function (curstore, results) { + me.suspendLayout = true; + + let cpu = 0, + maxcpu = 0; + let memory = 0, + maxmem = 0; + + let used = 0, + total = 0; + let countedStorage = {}, + usableStorages = {}; + let storages = sp.get('dash-storages') || ''; + storages + .split(',') + .filter((v) => v !== '') + .forEach((storage) => { + usableStorages[storage] = true; + }); + + let qemu = { + running: 0, + paused: 0, + stopped: 0, + template: 0, + }; + let lxc = { + running: 0, + paused: 0, + stopped: 0, + template: 0, + }; + let error = 0; + + for (const { data } of results) { + switch (data.type) { + case 'node': + cpu += data.cpu * data.maxcpu; + maxcpu += data.maxcpu || 0; + memory += data.mem || 0; + maxmem += data.maxmem || 0; + + if (gridstore.getById(data.id)) { + let griditem = gridstore.getById(data.id); + griditem.set('cpuusage', data.cpu); + let max = data.maxmem || 1; + let val = data.mem || 0; + griditem.set('memoryusage', val / max); + griditem.set('uptime', data.uptime); + griditem.commit(); // else the store marks the field as dirty + } + break; + case 'storage': { + let sid = !data.shared || data.storage === 'local' ? data.id : data.storage; + if (!Ext.Object.isEmpty(usableStorages)) { + if (usableStorages[data.id] !== true) { + break; + } + sid = data.id; + } else if (countedStorage[sid]) { + break; + } + + if (data.status === 'unknown') { + break; + } + + used += data.disk; + total += data.maxdisk; + countedStorage[sid] = true; + break; + } + case 'qemu': + qemu[data.template ? 'template' : data.status]++; + if (data.hastate === 'error') { + error++; + } + break; + case 'lxc': + lxc[data.template ? 'template' : data.status]++; + if (data.hastate === 'error') { + error++; + } + break; + default: + break; + } + } + + let text = Ext.String.format(gettext('of {0} CPU(s)'), maxcpu); + cpustat.updateValue(cpu / maxcpu, text); + + text = Ext.String.format( + gettext('{0} of {1}'), + Proxmox.Utils.render_size(memory), + Proxmox.Utils.render_size(maxmem), + ); + memorystat.updateValue(memory / maxmem, text); + + text = Ext.String.format( + gettext('{0} of {1}'), + Proxmox.Utils.render_size(used), + Proxmox.Utils.render_size(total), + ); + storagestat.updateValue(used / total, text); + + gueststatus.updateValues(qemu, lxc, error); + + me.suspendLayout = false; + me.updateLayout(true); + }); + + let dcHealth = me.getComponent('dcHealth'); + me.mon(rstore, 'load', dcHealth.updateStatus, dcHealth); + + let subs = me.down('#subscriptions'); + me.mon(rstore, 'load', function (store, records, success) { + var level; + var mixed = false; + for (let i = 0; i < records.length; i++) { + let node = records[i]; + if (node.get('type') !== 'node' || node.get('status') === 'offline') { + continue; + } + + let curlevel = node.get('level'); + if (curlevel === '') { + // no subscription beats all, set it and break the loop + level = ''; + break; + } + + if (level === undefined) { + // save level + level = curlevel; + } else if (level !== curlevel) { + // detect different levels + mixed = true; + } + } + + let data = { + title: Proxmox.Utils.unknownText, + text: Proxmox.Utils.unknownText, + iconCls: PVE.Utils.get_health_icon(undefined, true), + }; + if (level === '') { + data = { + title: gettext('No Subscription'), + iconCls: PVE.Utils.get_health_icon('critical', true), + text: gettext('You have at least one node without subscription.'), + }; + subs.setUserCls('pointer'); + } else if (mixed) { + data = { + title: gettext('Mixed Subscriptions'), + iconCls: PVE.Utils.get_health_icon('warning', true), + text: gettext('Warning: Your subscription levels are not the same.'), + }; + subs.setUserCls('pointer'); + } else if (level) { + data = { + title: PVE.Utils.render_support_level(level), + iconCls: PVE.Utils.get_health_icon('good', true), + text: gettext('Your subscription status is valid.'), + }; + subs.setUserCls(''); + } + + subs.setData(data); + }); + + me.on('destroy', function () { + rstore.stopUpdate(); + }); + + me.mon(sp, 'statechange', function (provider, key, value) { + if (key !== 'summarycolumns') { + return; + } + Proxmox.Utils.updateColumns(me); + }); + + rstore.startUpdate(); + }, +}); +Ext.define('PVE.dc.Support', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveDcSupport', + pveGuidePath: '/pve-docs/index.html', + onlineHelp: 'getting_help', + + invalidHtml: '

    No valid subscription

    ' + PVE.Utils.noSubKeyHtml, + + communityHtml: + 'Please use the public community forum for any questions.', + + activeHtml: + 'Please use our support portal for any questions. You can also use the public community forum to get additional information.', + + bugzillaHtml: + '

    Bug Tracking

    Our bug tracking system is available here.', + + docuHtml: function () { + var me = this; + var guideUrl = window.location.origin + me.pveGuidePath; + var text = Ext.String.format( + '

    Documentation

    ' + + 'The official Proxmox VE Administration Guide' + + ' is included with this installation and can be browsed at ' + + '{0}', + guideUrl, + ); + return text; + }, + + updateActive: function (data) { + var me = this; + + var html = '

    ' + data.productname + '

    ' + me.activeHtml; + html += '

    ' + me.docuHtml(); + html += '

    ' + me.bugzillaHtml; + + me.update(html); + }, + + updateCommunity: function (data) { + var me = this; + + var html = '

    ' + data.productname + '

    ' + me.communityHtml; + html += '

    ' + me.docuHtml(); + html += '

    ' + me.bugzillaHtml; + + me.update(html); + }, + + updateInactive: function (data) { + var me = this; + me.update(me.invalidHtml); + }, + + initComponent: function () { + let me = this; + + let reload = function () { + Proxmox.Utils.API2Request({ + url: '/nodes/localhost/subscription', + method: 'GET', + waitMsgTarget: me, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + me.update( + `${gettext('Unable to load subscription status')}: ${response.htmlStatus}`, + ); + }, + success: function (response, opts) { + let data = response.result.data; + if (data?.status.toLowerCase() === 'active') { + if (data.level === 'c') { + me.updateCommunity(data); + } else { + me.updateActive(data); + } + } else { + me.updateInactive(data); + } + }, + }); + }; + + Ext.apply(me, { + autoScroll: true, + bodyStyle: 'padding:10px', + listeners: { + activate: reload, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.dc.SyncWindow', { + extend: 'Ext.window.Window', + + title: gettext('Realm Sync'), + + width: 600, + bodyPadding: 10, + modal: true, + resizable: false, + + controller: { + xclass: 'Ext.app.ViewController', + + control: { + form: { + validitychange: function (field, valid) { + let me = this; + me.lookup('preview_btn').setDisabled(!valid); + me.lookup('sync_btn').setDisabled(!valid); + }, + }, + button: { + click: function (btn) { + if (btn.reference === 'help_btn') { + return; + } + this.sync_realm(btn.reference === 'preview_btn'); + }, + }, + }, + + sync_realm: function (is_preview) { + let me = this; + let view = me.getView(); + let ipanel = me.lookup('ipanel'); + let params = ipanel.getValues(); + + let vanished_opts = []; + ['acl', 'entry', 'properties'].forEach((prop) => { + if (params[`remove-vanished-${prop}`]) { + vanished_opts.push(prop); + } + delete params[`remove-vanished-${prop}`]; + }); + if (vanished_opts.length > 0) { + params['remove-vanished'] = vanished_opts.join(';'); + } else { + params['remove-vanished'] = 'none'; + } + + params['dry-run'] = is_preview ? 1 : 0; + Proxmox.Utils.API2Request({ + url: `/access/domains/${view.realm}/sync`, + waitMsgTarget: view, + method: 'POST', + params, + failure: function (response) { + view.show(); + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response) { + view.hide(); + Ext.create('Proxmox.window.TaskViewer', { + upid: response.result.data, + listeners: { + destroy: function () { + if (is_preview) { + view.show(); + } else { + view.close(); + } + }, + }, + }).show(); + }, + }); + }, + }, + + items: [ + { + xtype: 'form', + reference: 'form', + border: false, + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + items: [ + { + xtype: 'inputpanel', + reference: 'ipanel', + column1: [ + { + xtype: 'proxmoxKVComboBox', + name: 'scope', + fieldLabel: gettext('Scope'), + value: '', + emptyText: gettext('No default available'), + deleteEmpty: false, + allowBlank: false, + comboItems: [ + ['users', gettext('Users')], + ['groups', gettext('Groups')], + ['both', gettext('Users and Groups')], + ], + }, + ], + + column2: [ + { + xtype: 'proxmoxKVComboBox', + value: '1', + deleteEmpty: false, + allowBlank: false, + comboItems: [ + ['1', Proxmox.Utils.yesText], + ['0', Proxmox.Utils.noText], + ], + name: 'enable-new', + fieldLabel: gettext('Enable new'), + }, + ], + + columnB: [ + { + xtype: 'fieldset', + title: gettext('Remove Vanished Options'), + items: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('ACL'), + name: 'remove-vanished-acl', + boxLabel: gettext('Remove ACLs of vanished users and groups.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Entry'), + name: 'remove-vanished-entry', + boxLabel: gettext('Remove vanished user and group entries.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Properties'), + name: 'remove-vanished-properties', + boxLabel: gettext( + 'Remove vanished properties from synced users.', + ), + }, + ], + }, + { + xtype: 'displayfield', + reference: 'defaulthint', + value: gettext('Default sync options can be set by editing the realm.'), + userCls: 'pmx-hint', + hidden: true, + }, + ], + }, + ], + }, + ], + + buttons: [ + { + xtype: 'proxmoxHelpButton', + reference: 'help_btn', + onlineHelp: 'pveum_ldap_sync', + hidden: false, + }, + '->', + { + text: gettext('Preview'), + reference: 'preview_btn', + }, + { + text: gettext('Sync'), + reference: 'sync_btn', + }, + ], + + initComponent: function () { + let me = this; + + if (!me.realm) { + throw 'no realm defined'; + } + + me.callParent(); + + Proxmox.Utils.API2Request({ + url: `/access/domains/${me.realm}`, + waitMsgTarget: me, + method: 'GET', + failure: function (response) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + me.close(); + }, + success: function (response) { + let default_options = response.result.data['sync-defaults-options']; + if (default_options) { + let options = PVE.Parser.parsePropertyString(default_options); + if (options['remove-vanished']) { + let opts = options['remove-vanished'].split(';'); + for (const opt of opts) { + options[`remove-vanished-${opt}`] = 1; + } + } + let ipanel = me.lookup('ipanel'); + ipanel.setValues(options); + } else { + me.lookup('defaulthint').setVisible(true); + } + + // check validity for button state + me.lookup('form').isValid(); + }, + }); + }, +}); +/* This class defines the "Tasks" tab of the bottom status panel + * Tasks are jobs with a start, end and log output + */ + +Ext.define('PVE.dc.Tasks', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveClusterTasks'], + + initComponent: function () { + let me = this; + + let taskstore = Ext.create('Proxmox.data.UpdateStore', { + storeId: 'pve-cluster-tasks', + model: 'proxmox-tasks', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/tasks', + }, + }); + let store = Ext.create('Proxmox.data.DiffStore', { + rstore: taskstore, + sortAfterUpdate: true, + appendAtStart: true, + sorters: [ + { + property: 'pid', + direction: 'DESC', + }, + { + property: 'starttime', + direction: 'DESC', + }, + ], + }); + + let run_task_viewer = function () { + var sm = me.getSelectionModel(); + var rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + Ext.create('Proxmox.window.TaskViewer', { + autoShow: true, + upid: rec.data.upid, + endtime: rec.data.endtime, + }); + }; + + Ext.apply(me, { + store: store, + stateful: false, + viewConfig: { + trackOver: false, + stripeRows: true, // does not work with getRowClass() + getRowClass: function (record, index) { + let taskState = record.get('status'); + if (taskState) { + let parsed = Proxmox.Utils.parse_task_status(taskState); + if (parsed === 'warning') { + return 'proxmox-warning-row'; + } else if (parsed !== 'ok') { + return 'proxmox-invalid-row'; + } + } + return ''; + }, + }, + sortableColumns: false, + columns: [ + { + header: gettext('Start Time'), + dataIndex: 'starttime', + width: 150, + renderer: function (value) { + return Ext.Date.format(value, 'M d H:i:s'); + }, + }, + { + header: gettext('End Time'), + dataIndex: 'endtime', + width: 150, + align: 'inherit', + renderer: function (value, metaData, record) { + metaData.tdStyle = 'text-align: left;'; + if (record.data.pid) { + if ( + record.data.type === 'vncproxy' || + record.data.type === 'vncshell' || + record.data.type === 'spiceproxy' + ) { + metaData.tdStyle = 'text-align: center;'; + return ''; + } else { + metaData.tdCls = 'x-grid-row-loading'; + } + return ''; + } + + return Ext.Date.format(value, 'M d H:i:s'); + }, + }, + { + header: gettext('Node'), + dataIndex: 'node', + width: 100, + }, + { + header: gettext('User name'), + dataIndex: 'user', + renderer: Ext.String.htmlEncode, + width: 150, + }, + { + header: gettext('Description'), + dataIndex: 'upid', + flex: 1, + renderer: Proxmox.Utils.render_upid, + }, + { + header: gettext('Status'), + dataIndex: 'status', + width: 200, + renderer: function (value, metaData, record) { + if (record.data.pid) { + if (record.data.type !== 'vncproxy') { + metaData.tdCls = 'x-grid-row-loading'; + } + return ''; + } + return Proxmox.Utils.format_task_status(value); + }, + }, + { + xtype: 'actioncolumn', + width: 30, + align: 'center', + tooltip: gettext('Actions'), + items: [ + { + iconCls: 'fa fa-chevron-right', + tooltip: gettext('View Task'), + handler: function (_grid, _rowIndex, _colIndex, _item, _e, rec) { + Ext.create('Proxmox.window.TaskViewer', { + autoShow: true, + upid: rec.data.upid, + endtime: rec.data.endtime, + }); + }, + }, + ], + }, + ], + listeners: { + itemdblclick: run_task_viewer, + show: () => taskstore.startUpdate(), + destroy: () => taskstore.stopUpdate(), + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.dc.TokenEdit', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveDcTokenEdit'], + mixins: ['Proxmox.Mixin.CBind'], + + subject: gettext('Token'), + onlineHelp: 'pveum_tokens', + + isAdd: true, + isCreate: false, + + method: 'POST', + url: '/api2/extjs/access/users/', + + defaultFocus: 'field[disabled=false][hidden=false][name=tokenid]', + + items: { + xtype: 'inputpanel', + onGetValues: function (values) { + let me = this; + let win = me.up('pveDcTokenEdit'); + win.url = '/api2/extjs/access/users/'; + let uid = encodeURIComponent(values.userid); + let tid = encodeURIComponent(values.tokenid); + delete values.userid; + delete values.tokenid; + + win.url += `${uid}/token/${tid}`; + return values; + }, + column1: [ + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{isCreate}', + }, + submitValue: true, + editConfig: { + xtype: 'pmxUserSelector', + allowBlank: false, + }, + name: 'userid', + value: Proxmox.UserName, + renderer: Ext.String.htmlEncode, + fieldLabel: gettext('User'), + }, + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{isCreate}', + }, + name: 'tokenid', + fieldLabel: gettext('Token ID'), + submitValue: true, + minLength: 2, + allowBlank: false, + }, + ], + column2: [ + { + xtype: 'proxmoxcheckbox', + name: 'privsep', + checked: true, + uncheckedValue: 0, + fieldLabel: gettext('Privilege Separation'), + }, + { + xtype: 'pmxExpireDate', + name: 'expire', + }, + ], + columnB: [ + { + xtype: 'textfield', + name: 'comment', + fieldLabel: gettext('Comment'), + }, + ], + }, + + initComponent: function () { + let me = this; + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + me.setValues(response.result.data); + }, + }); + } + }, + apiCallDone: function (success, response, options) { + let res = response.result.data; + if (!success || !res.value) { + return; + } + + Ext.create('PVE.dc.TokenShow', { + autoShow: true, + tokenid: res['full-tokenid'], + secret: res.value, + }); + }, +}); + +Ext.define('PVE.dc.TokenShow', { + extend: 'Ext.window.Window', + alias: ['widget.pveTokenShow'], + mixins: ['Proxmox.Mixin.CBind'], + + width: 600, + modal: true, + resizable: false, + title: gettext('Token Secret'), + + items: [ + { + xtype: 'container', + layout: 'form', + bodyPadding: 10, + border: false, + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + padding: '0 10 10 10', + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Token ID'), + cbind: { + value: '{tokenid}', + }, + editable: false, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Secret'), + inputId: 'token-secret-value', + cbind: { + value: '{secret}', + }, + editable: false, + }, + ], + }, + { + xtype: 'component', + border: false, + padding: '10 10 10 10', + userCls: 'pmx-hint', + html: gettext('Please record the API token secret - it will only be displayed now'), + }, + ], + buttons: [ + { + handler: function (b) { + document.getElementById('token-secret-value').select(); + document.execCommand('copy'); + }, + text: gettext('Copy Secret Value'), + iconCls: 'fa fa-clipboard', + }, + ], +}); +Ext.define('PVE.dc.TokenView', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveTokenView'], + + onlineHelp: 'chapter_user_management', + + stateful: true, + stateId: 'grid-tokens', + + initComponent: function () { + let me = this; + + let caps = Ext.state.Manager.get('GuiCap'); + + let store = new Ext.data.Store({ + id: 'tokens', + model: 'pve-tokens', + sorters: 'id', + }); + + let reload = function () { + Proxmox.Utils.API2Request({ + url: '/access/users/?full=1', + method: 'GET', + failure: function (response, opts) { + Proxmox.Utils.setErrorMask(me, response.htmlStatus); + me.load_task.delay(me.load_delay); + }, + success: function (response, opts) { + Proxmox.Utils.setErrorMask(me, false); + let result = Ext.decode(response.responseText); + let data = result.data || []; + let records = []; + Ext.Array.each(data, function (user) { + let tokens = user.tokens || []; + Ext.Array.each(tokens, function (token) { + let r = {}; + r.id = user.userid + '!' + token.tokenid; + r.userid = user.userid; + r.tokenid = token.tokenid; + r.comment = token.comment; + r.expire = token.expire; + r.privsep = token.privsep === 1; + records.push(r); + }); + }); + store.loadData(records); + }, + }); + }; + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let urlFromRecord = (rec) => { + let uid = encodeURIComponent(rec.data.userid); + let tid = encodeURIComponent(rec.data.tokenid); + return `/access/users/${uid}/token/${tid}`; + }; + + let hasTokenCRUDPermissions = function (userid) { + return userid === Proxmox.UserName || !!caps.access['User.Modify']; + }; + + let run_editor = function (rec) { + if (!hasTokenCRUDPermissions(rec.data.userid)) { + return; + } + + let win = Ext.create('PVE.dc.TokenEdit', { + method: 'PUT', + url: urlFromRecord(rec), + }); + win.setValues(rec.data); + win.on('destroy', reload); + win.show(); + }; + + let tbar = [ + { + text: gettext('Add'), + handler: function (btn, e) { + let data = {}; + let win = Ext.create('PVE.dc.TokenEdit', { + isCreate: true, + }); + win.setValues(data); + win.on('destroy', reload); + win.show(); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + enableFn: (rec) => hasTokenCRUDPermissions(rec.data.userid), + selModel: sm, + handler: (btn, e, rec) => run_editor(rec), + }, + { + xtype: 'proxmoxStdRemoveButton', + selModel: sm, + enableFn: (rec) => hasTokenCRUDPermissions(rec.data.userid), + callback: reload, + getUrl: urlFromRecord, + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Show Permissions'), + disabled: true, + selModel: sm, + handler: function (btn, event, rec) { + Ext.create('PVE.dc.PermissionView', { + autoShow: true, + userid: rec.data.id, + }); + }, + }, + ]; + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: tbar, + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: gettext('User name'), + dataIndex: 'userid', + renderer: (uid) => { + let realmIndex = uid.lastIndexOf('@'); + let user = Ext.String.htmlEncode(uid.substr(0, realmIndex)); + let realm = Ext.String.htmlEncode(uid.substr(realmIndex)); + return `${user} ${realm}`; + }, + flex: 2, + }, + { + header: gettext('Token Name'), + dataIndex: 'tokenid', + hideable: false, + flex: 1, + }, + { + header: gettext('Expire'), + dataIndex: 'expire', + hideable: false, + renderer: Proxmox.Utils.format_expire, + flex: 1, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.String.htmlEncode, + flex: 3, + }, + { + header: gettext('Privilege Separation'), + dataIndex: 'privsep', + hideable: false, + renderer: Proxmox.Utils.format_boolean, + flex: 1, + }, + ], + listeners: { + activate: reload, + itemdblclick: (view, rec) => run_editor(rec), + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.dc.UserEdit', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveDcUserEdit'], + + isAdd: true, + + initComponent: function () { + let me = this; + + me.isCreate = !me.userid; + + let url = '/api2/extjs/access/users'; + let method = 'POST'; + if (!me.isCreate) { + url += '/' + encodeURIComponent(me.userid); + method = 'PUT'; + } + + let verifypw, pwfield; + let validate_pw = function () { + if (verifypw.getValue() !== pwfield.getValue()) { + return gettext('Passwords do not match'); + } + return true; + }; + verifypw = Ext.createWidget('textfield', { + inputType: 'password', + fieldLabel: gettext('Confirm password'), + name: 'verifypassword', + submitValue: false, + disabled: true, + hidden: true, + validator: validate_pw, + }); + + pwfield = Ext.createWidget('textfield', { + inputType: 'password', + fieldLabel: gettext('Password'), + minLength: 8, + name: 'password', + disabled: true, + hidden: true, + validator: validate_pw, + }); + + let column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'userid', + fieldLabel: gettext('User name'), + value: me.userid, + renderer: Ext.String.htmlEncode, + allowBlank: false, + submitValue: !!me.isCreate, + }, + pwfield, + verifypw, + { + xtype: 'pveGroupSelector', + name: 'groups', + multiSelect: true, + allowBlank: true, + fieldLabel: gettext('Group'), + }, + { + xtype: 'pmxExpireDate', + name: 'expire', + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enabled'), + name: 'enable', + uncheckedValue: 0, + defaultValue: 1, + checked: true, + }, + ]; + + let column2 = [ + { + xtype: 'textfield', + name: 'firstname', + fieldLabel: gettext('First Name'), + }, + { + xtype: 'textfield', + name: 'lastname', + fieldLabel: gettext('Last Name'), + }, + { + xtype: 'textfield', + name: 'email', + fieldLabel: gettext('E-Mail'), + vtype: 'proxmoxMail', + }, + ]; + + if (me.isCreate) { + column1.splice(1, 0, { + xtype: 'pmxRealmComboBox', + name: 'realm', + fieldLabel: gettext('Realm'), + allowBlank: false, + matchFieldWidth: false, + listConfig: { width: 300 }, + listeners: { + change: function (combo, realm) { + me.realm = realm; + pwfield.setVisible(realm === 'pve'); + pwfield.setDisabled(realm !== 'pve'); + verifypw.setVisible(realm === 'pve'); + verifypw.setDisabled(realm !== 'pve'); + }, + }, + submitValue: false, + }); + } + + var ipanel = Ext.create('Proxmox.panel.InputPanel', { + column1: column1, + column2: column2, + columnB: [ + { + xtype: 'textfield', + name: 'comment', + fieldLabel: gettext('Comment'), + }, + ], + advancedItems: [ + { + xtype: 'textfield', + name: 'keys', + fieldLabel: gettext('Key IDs'), + }, + ], + onGetValues: function (values) { + if (me.realm) { + values.userid = values.userid + '@' + me.realm; + } + if (!values.password) { + delete values.password; + } + return values; + }, + }); + + Ext.applyIf(me, { + subject: gettext('User'), + url: url, + method: method, + fieldDefaults: { + labelWidth: 110, // some translation are quite long (e.g., Spanish) + }, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var data = response.result.data; + me.setValues(data); + if (data.keys) { + if ( + data.keys === 'x' || + data.keys === 'x!oath' || + data.keys === 'x!u2f' || + data.keys === 'x!yubico' + ) { + me.down('[name="keys"]').setDisabled(1); + } + } + }, + }); + } + }, +}); +Ext.define('PVE.dc.UserView', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveUserView'], + + onlineHelp: 'pveum_users', + + stateful: true, + stateId: 'grid-users', + + initComponent: function () { + var me = this; + + var caps = Ext.state.Manager.get('GuiCap'); + + var store = new Ext.data.Store({ + id: 'users', + model: 'pmx-users', + sorters: { + property: 'userid', + direction: 'ASC', + }, + }); + let reload = () => store.load(); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/access/users/', + dangerous: true, + enableFn: (rec) => caps.access['User.Modify'] && rec.data.userid !== 'root@pam', + callback: () => reload(), + }); + let run_editor = function () { + var rec = sm.getSelection()[0]; + if (!rec || !caps.access['User.Modify']) { + return; + } + Ext.create('PVE.dc.UserEdit', { + userid: rec.data.userid, + autoShow: true, + listeners: { + destroy: () => reload(), + }, + }); + }; + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + enableFn: function (rec) { + return !!caps.access['User.Modify']; + }, + selModel: sm, + handler: run_editor, + }); + let pwchange_btn = new Proxmox.button.Button({ + text: gettext('Password'), + disabled: true, + selModel: sm, + enableFn: function (record) { + let type = record.data['realm-type']; + if (type) { + if (PVE.Utils.authSchema[type]) { + return !!PVE.Utils.authSchema[type].pwchange; + } + } + return false; + }, + handler: function (btn, event, rec) { + let hintHtml; + if (rec.data['realm-type'] === 'pam') { + hintHtml = gettext( + 'For the PAM realm, this applies only to the connected node.', + ); + } + + Ext.create('Proxmox.window.PasswordEdit', { + userid: rec.data.userid, + confirmCurrentPassword: Proxmox.UserName !== 'root@pam', + autoShow: true, + hintHtml, + minLength: 8, + listeners: { + destroy: () => reload(), + }, + }); + }, + }); + + var perm_btn = new Proxmox.button.Button({ + text: gettext('Permissions'), + disabled: true, + selModel: sm, + handler: function (btn, event, rec) { + Ext.create('PVE.dc.PermissionView', { + userid: rec.data.userid, + autoShow: true, + listeners: { + destroy: () => reload(), + }, + }); + }, + }); + + let unlock_btn = new Proxmox.button.Button({ + text: gettext('Unlock TFA'), + disabled: true, + selModel: sm, + enableFn: (rec) => + !!( + caps.access['User.Modify'] && + (rec.data['totp-locked'] || rec.data['tfa-locked-until']) + ), + handler: function (btn, event, rec) { + Ext.Msg.confirm( + Ext.String.format( + gettext('Unlock TFA authentication for {0}'), + rec.data.userid, + ), + gettext( + "Locked 2nd factors can happen if the user's password was leaked. Are you sure you want to unlock the user?", + ), + function (btn_response) { + if (btn_response === 'yes') { + Proxmox.Utils.API2Request({ + url: `/access/users/${rec.data.userid}/unlock-tfa`, + waitMsgTarget: me, + method: 'PUT', + failure: function (response, options) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, options) { + reload(); + }, + }); + } + }, + ); + }, + }); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + { + text: gettext('Add'), + disabled: !caps.access['User.Modify'], + handler: function () { + Ext.create('PVE.dc.UserEdit', { + autoShow: true, + listeners: { + destroy: () => reload(), + }, + }); + }, + }, + '-', + edit_btn, + remove_btn, + '-', + pwchange_btn, + '-', + perm_btn, + '-', + unlock_btn, + ], + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: gettext('User name'), + width: 200, + sortable: true, + renderer: Proxmox.Utils.render_username, + dataIndex: 'userid', + }, + { + header: gettext('Realm'), + width: 100, + sortable: true, + renderer: Proxmox.Utils.render_realm, + dataIndex: 'userid', + }, + { + header: gettext('Enabled'), + width: 80, + sortable: true, + renderer: Proxmox.Utils.format_boolean, + dataIndex: 'enable', + }, + { + header: gettext('Expire'), + width: 80, + sortable: true, + renderer: Proxmox.Utils.format_expire, + dataIndex: 'expire', + }, + { + header: gettext('Name'), + width: 150, + sortable: true, + renderer: PVE.Utils.render_full_name, + dataIndex: 'firstname', + }, + { + header: 'TFA', + width: 120, + sortable: true, + renderer: function (v, metaData, record) { + let tfa_type = PVE.Parser.parseTfaType(v); + if (tfa_type === undefined) { + return Proxmox.Utils.noText; + } + + if (tfa_type !== 1) { + return tfa_type; + } + + let locked_until = record.data['tfa-locked-until']; + if (locked_until !== undefined) { + let now = new Date().getTime() / 1000; + if (locked_until > now) { + return gettext('Locked'); + } + } + + if (record.data['totp-locked']) { + return gettext('TOTP Locked'); + } + + return Proxmox.Utils.yesText; + }, + dataIndex: 'keys', + }, + { + header: gettext('Groups'), + dataIndex: 'groups', + renderer: Ext.htmlEncode, + flex: 2, + }, + { + header: gettext('Comment'), + sortable: false, + renderer: Ext.String.htmlEncode, + dataIndex: 'comment', + flex: 3, + }, + ], + listeners: { + activate: reload, + itemdblclick: run_editor, + }, + }); + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, store); + }, +}); +Ext.define('PVE.dc.MetricServerView', { + extend: 'Ext.grid.Panel', + alias: ['widget.pveMetricServerView'], + + stateful: true, + stateId: 'grid-metricserver', + + controller: { + xclass: 'Ext.app.ViewController', + + render_type: function (value) { + switch (value) { + case 'influxdb': + return 'InfluxDB'; + case 'graphite': + return 'Graphite'; + case 'opentelemetry': + return 'OpenTelemetry'; + default: + return Proxmox.Utils.unknownText; + } + }, + + editWindow: function (xtype, id) { + let me = this; + Ext.create(`PVE.dc.${xtype}Edit`, { + serverid: id, + autoShow: true, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + + addServer: function (button) { + this.editWindow(button.text); + }, + + editServer: function () { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || selection.length < 1) { + return; + } + + let cfg = selection[0].data; + + let xtype = me.render_type(cfg.type); + me.editWindow(xtype, cfg.id); + }, + + reload: function () { + this.getView().getStore().load(); + }, + }, + + store: { + autoLoad: true, + id: 'metricservers', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/metrics/server', + }, + }, + + columns: [ + { + text: gettext('Name'), + flex: 2, + dataIndex: 'id', + }, + { + text: gettext('Type'), + flex: 1, + dataIndex: 'type', + renderer: 'render_type', + }, + { + text: gettext('Enabled'), + dataIndex: 'disable', + width: 100, + renderer: Proxmox.Utils.format_neg_boolean, + }, + { + text: gettext('Server'), + width: 200, + dataIndex: 'server', + }, + { + text: gettext('Port'), + width: 100, + dataIndex: 'port', + }, + ], + + tbar: [ + { + text: gettext('Add'), + menu: [ + { + text: 'Graphite', + iconCls: 'fa fa-fw fa-bar-chart', + handler: 'addServer', + }, + { + text: 'InfluxDB', + iconCls: 'fa fa-fw fa-bar-chart', + handler: 'addServer', + }, + { + text: 'OpenTelemetry', + iconCls: 'fa fa-fw fa-bar-chart', + handler: 'addServer', + }, + ], + }, + { + text: gettext('Edit'), + xtype: 'proxmoxButton', + handler: 'editServer', + disabled: true, + }, + { + xtype: 'proxmoxStdRemoveButton', + baseurl: `/api2/extjs/cluster/metrics/server`, + callback: 'reload', + }, + ], + + listeners: { + itemdblclick: 'editServer', + }, + + initComponent: function () { + var me = this; + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore()); + }, +}); + +Ext.define('PVE.dc.MetricServerBaseEdit', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: function () { + let me = this; + me.isCreate = !me.serverid; + me.serverid = me.serverid || ''; + me.url = `/api2/extjs/cluster/metrics/server/${me.serverid}`; + me.method = me.isCreate ? 'POST' : 'PUT'; + if (!me.isCreate) { + me.subject = `${me.subject}: ${me.serverid}`; + } + return {}; + }, + + submitUrl: function (url, values) { + return this.isCreate ? `${url}/${values.id}` : url; + }, + + initComponent: function () { + let me = this; + + me.callParent(); + + if (me.serverid) { + me.load({ + success: function (response, options) { + let values = response.result.data; + values.enable = !values.disable; + + // Handle OpenTelemetry advanced fields conversion + if (values.type === 'opentelemetry') { + if (values['otel-headers']) { + values.headers_advanced = Ext.util.Base64.decode( + values['otel-headers'], + ); + } + if (values['otel-resource-attributes']) { + values.resource_attributes_advanced = Ext.util.Base64.decode( + values['otel-resource-attributes'], + ); + } + } + + me.down('inputpanel').setValues(values); + }, + }); + } + }, +}); + +Ext.define('PVE.dc.InfluxDBEdit', { + extend: 'PVE.dc.MetricServerBaseEdit', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'metric_server_influxdb', + + subject: 'InfluxDB', + + cbindData: function () { + let me = this; + me.callParent(); + me.tokenEmptyText = me.isCreate ? '' : gettext('unchanged'); + return {}; + }, + + items: [ + { + xtype: 'inputpanel', + cbind: { + isCreate: '{isCreate}', + }, + onGetValues: function (values) { + let me = this; + values.disable = values.enable ? 0 : 1; + delete values.enable; + PVE.Utils.delete_if_default(values, 'verify-certificate', '1', me.isCreate); + return values; + }, + + column1: [ + { + xtype: 'hidden', + name: 'type', + value: 'influxdb', + cbind: { + submitValue: '{isCreate}', + }, + }, + { + xtype: 'pmxDisplayEditField', + name: 'id', + fieldLabel: gettext('Name'), + allowBlank: false, + cbind: { + editable: '{isCreate}', + value: '{serverid}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'server', + fieldLabel: gettext('Server'), + allowBlank: false, + }, + { + xtype: 'proxmoxintegerfield', + name: 'port', + fieldLabel: gettext('Port'), + value: 8089, + minValue: 1, + maximum: 65536, + allowBlank: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'influxdbproto', + fieldLabel: gettext('Protocol'), + value: '__default__', + cbind: { + deleteEmpty: '{!isCreate}', + }, + comboItems: [ + ['__default__', 'UDP'], + ['http', 'HTTP'], + ['https', 'HTTPS'], + ], + listeners: { + change: function (field, value) { + let me = this; + let view = me.up('inputpanel'); + let isUdp = value !== 'http' && value !== 'https'; + view.down('field[name=organization]').setDisabled(isUdp); + view.down('field[name=bucket]').setDisabled(isUdp); + view.down('field[name=token]').setDisabled(isUdp); + view.down('field[name=api-path-prefix]').setDisabled(isUdp); + view.down('field[name=mtu]').setDisabled(!isUdp); + view.down('field[name=timeout]').setDisabled(isUdp); + view.down('field[name=max-body-size]').setDisabled(isUdp); + view.down('field[name=verify-certificate]').setDisabled( + value !== 'https', + ); + }, + }, + }, + ], + + column2: [ + { + xtype: 'checkbox', + name: 'enable', + fieldLabel: gettext('Enabled'), + inputValue: 1, + uncheckedValue: 0, + checked: true, + }, + { + xtype: 'proxmoxtextfield', + name: 'organization', + fieldLabel: gettext('Organization'), + emptyText: 'proxmox', + disabled: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'bucket', + fieldLabel: gettext('Bucket'), + emptyText: 'proxmox', + disabled: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'token', + fieldLabel: gettext('Token'), + disabled: true, + allowBlank: true, + deleteEmpty: false, + submitEmpty: false, + cbind: { + disabled: '{!isCreate}', + emptyText: '{tokenEmptyText}', + }, + }, + ], + + advancedColumn1: [ + { + xtype: 'proxmoxtextfield', + name: 'api-path-prefix', + fieldLabel: gettext('API Path Prefix'), + allowBlank: true, + disabled: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'timeout', + fieldLabel: gettext('Timeout (s)'), + disabled: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + minValue: 1, + emptyText: 1, + }, + { + xtype: 'proxmoxcheckbox', + name: 'verify-certificate', + fieldLabel: gettext('Verify Certificate'), + value: 1, + uncheckedValue: 0, + disabled: true, + }, + ], + + advancedColumn2: [ + { + xtype: 'proxmoxintegerfield', + name: 'max-body-size', + fieldLabel: gettext('Batch Size (bytes)'), + minValue: 1, + emptyText: '25000000', + submitEmpty: false, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'mtu', + fieldLabel: 'MTU', + minValue: 1, + emptyText: '1500', + submitEmpty: false, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + }, + ], +}); + +Ext.define('PVE.dc.GraphiteEdit', { + extend: 'PVE.dc.MetricServerBaseEdit', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'metric_server_graphite', + + subject: 'Graphite', + + items: [ + { + xtype: 'inputpanel', + + onGetValues: function (values) { + values.disable = values.enable ? 0 : 1; + delete values.enable; + return values; + }, + + column1: [ + { + xtype: 'hidden', + name: 'type', + value: 'graphite', + cbind: { + submitValue: '{isCreate}', + }, + }, + { + xtype: 'pmxDisplayEditField', + name: 'id', + fieldLabel: gettext('Name'), + allowBlank: false, + cbind: { + editable: '{isCreate}', + value: '{serverid}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'server', + fieldLabel: gettext('Server'), + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'checkbox', + name: 'enable', + fieldLabel: gettext('Enabled'), + inputValue: 1, + uncheckedValue: 0, + checked: true, + }, + { + xtype: 'proxmoxintegerfield', + name: 'port', + fieldLabel: gettext('Port'), + value: 2003, + minimum: 1, + maximum: 65536, + allowBlank: false, + }, + { + fieldLabel: gettext('Path'), + xtype: 'proxmoxtextfield', + emptyText: 'proxmox', + name: 'path', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + + advancedColumn1: [ + { + xtype: 'proxmoxKVComboBox', + name: 'proto', + fieldLabel: gettext('Protocol'), + value: '__default__', + cbind: { + deleteEmpty: '{!isCreate}', + }, + comboItems: [ + ['__default__', 'UDP'], + ['tcp', 'TCP'], + ], + listeners: { + change: function (field, value) { + let me = this; + me.up('inputpanel') + .down('field[name=timeout]') + .setDisabled(value !== 'tcp'); + me.up('inputpanel') + .down('field[name=mtu]') + .setDisabled(value === 'tcp'); + }, + }, + }, + ], + + advancedColumn2: [ + { + xtype: 'proxmoxintegerfield', + name: 'mtu', + fieldLabel: 'MTU', + minimum: 1, + emptyText: '1500', + submitEmpty: false, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'timeout', + fieldLabel: gettext('TCP Timeout'), + disabled: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + minValue: 1, + emptyText: 1, + }, + ], + }, + ], +}); + +Ext.define('PVE.dc.OpenTelemetryEdit', { + extend: 'PVE.dc.MetricServerBaseEdit', + xtype: 'pveOpenTelemetryEdit', + + subject: gettext('OpenTelemetry Server'), + + items: [ + { + xtype: 'inputpanel', + cbind: { + isCreate: '{isCreate}', + }, + onGetValues: function (values) { + values.disable = values.enable ? 0 : 1; + delete values.enable; + + // Rename advanced fields to their final names and encode as base64 (same as webhook) + if (values.headers_advanced && values.headers_advanced.trim()) { + values['otel-headers'] = Ext.util.Base64.encode(values.headers_advanced); + } else { + values['otel-headers'] = ''; + } + delete values.headers_advanced; + + if ( + values.resource_attributes_advanced && + values.resource_attributes_advanced.trim() + ) { + values['otel-resource-attributes'] = Ext.util.Base64.encode( + values.resource_attributes_advanced, + ); + } else { + values['otel-resource-attributes'] = ''; + } + delete values.resource_attributes_advanced; + + return values; + }, + + column1: [ + { + xtype: 'hidden', + name: 'type', + value: 'opentelemetry', + cbind: { + submitValue: '{isCreate}', + }, + }, + { + xtype: 'pmxDisplayEditField', + name: 'id', + fieldLabel: gettext('Name'), + allowBlank: false, + cbind: { + editable: '{isCreate}', + value: '{serverid}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'server', + fieldLabel: gettext('Server'), + allowBlank: false, + // TRANSLATORS: otel-collector is an OpenTelemetry endpoint. + emptyText: gettext('otel-collector.example.com'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'port', + fieldLabel: gettext('Port'), + value: 4318, + minValue: 1, + maxValue: 65535, + allowBlank: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'otel-protocol', + fieldLabel: gettext('Protocol'), + value: 'https', + comboItems: [ + ['http', 'HTTP'], + ['https', 'HTTPS'], + ], + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'otel-path', + fieldLabel: gettext('Path'), + value: '/v1/metrics', + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'checkbox', + name: 'enable', + fieldLabel: gettext('Enabled'), + inputValue: 1, + uncheckedValue: 0, + checked: true, + }, + { + xtype: 'proxmoxintegerfield', + name: 'otel-timeout', + fieldLabel: gettext('Timeout (s)'), + value: 5, + minValue: 1, + maxValue: 300, + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'otel-verify-ssl', + fieldLabel: gettext('Verify SSL'), + inputValue: 1, + uncheckedValue: 0, + defaultValue: 1, + cbind: { + value: function (get) { + return get('isCreate') ? 1 : undefined; + }, + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'otel-max-body-size', + fieldLabel: gettext('Max Body Size (bytes)'), + value: 10000000, + minValue: 1024, + allowBlank: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'otel-compression', + fieldLabel: gettext('Compression'), + value: 'gzip', + comboItems: [ + ['none', gettext('None')], + ['gzip', 'Gzip'], + ], + allowBlank: false, + }, + ], + + columnB: [ + { + xtype: 'fieldset', + title: gettext('Advanced JSON Configuration'), + collapsible: true, + collapsed: true, + items: [ + { + xtype: 'textarea', + name: 'headers_advanced', + fieldLabel: gettext('HTTP Headers (JSON)'), + labelAlign: 'top', + // TRANSLATORS: These are sample json payloads, only translate the values + emptyText: gettext( + '{\n "Authorization": "Bearer token",\n "X-Custom-Header": "value"\n}', + ), + rows: 4, + validator: function (value) { + if (!value || value.trim() === '') { + return true; + } + try { + JSON.parse(value); + return true; + } catch (_e) { + return gettext('Invalid JSON format'); + } + }, + }, + { + xtype: 'textarea', + name: 'resource_attributes_advanced', + fieldLabel: gettext('Resource Attributes (JSON)'), + labelAlign: 'top', + // TRANSLATORS: These are sample json payloads, only translate the values + emptyText: gettext( + '{\n "environment": "production",\n "datacenter": "dc1",\n "region": "us-east-1"\n}', + ), + rows: 4, + validator: function (value) { + if (!value || value.trim() === '') { + return true; + } + try { + JSON.parse(value); + return true; + } catch (_e) { + return gettext('Invalid JSON format'); + } + }, + }, + ], + }, + ], + }, + ], + + initComponent: function () { + var me = this; + var initialLoad = true; + + me.callParent(); + + // Auto-adjust port when protocol changes (only for user interaction) + me.on('afterrender', function () { + var protocolField = me.down('[name=otel-protocol]'); + var portField = me.down('[name=port]'); + + if (protocolField && portField) { + // Set flag to false after initial load + me.on('loadrecord', function () { + setTimeout(function () { + initialLoad = false; + }, 100); + }); + + protocolField.on('change', function (field, newValue) { + // Only auto-adjust port if this is user interaction, not initial load + if (!initialLoad) { + if (newValue === 'https') { + portField.setValue(4318); + } else { + portField.setValue(4317); + } + } + }); + } + }); + }, +}); +Ext.define('PVE.dc.UserTagAccessEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveUserTagAccessEdit', + + subject: gettext('User Tag Access'), + onlineHelp: 'gui_tags', + + url: '/api2/extjs/cluster/options', + + hintText: gettext('NOTE: The following tags are also defined as registered tags.'), + + controller: { + xclass: 'Ext.app.ViewController', + + tagChange: function (field, value) { + let me = this; + let view = me.getView(); + let also_registered = []; + value = Ext.isArray(value) ? value : value.split(';'); + value.forEach((tag) => { + if (view.registered_tags.indexOf(tag) !== -1) { + also_registered.push(tag); + } + }); + let hint_field = me.lookup('hintField'); + hint_field.setVisible(also_registered.length > 0); + if (also_registered.length > 0) { + hint_field.setValue(`${view.hintText} ${also_registered.join(', ')}`); + } + }, + }, + + items: [ + { + xtype: 'inputpanel', + setValues: function (values) { + this.up('pveUserTagAccessEdit').registered_tags = values?.['registered-tags'] ?? []; + let data = values?.['user-tag-access'] ?? {}; + return Proxmox.panel.InputPanel.prototype.setValues.call(this, data); + }, + onGetValues: function (values) { + if (values === undefined || Object.keys(values).length === 0) { + return { delete: 'user-tag-access' }; + } + return { + 'user-tag-access': PVE.Parser.printPropertyString(values), + }; + }, + items: [ + { + name: 'user-allow', + fieldLabel: gettext('Mode'), + xtype: 'proxmoxKVComboBox', + deleteEmpty: false, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (free)'], + ['free', 'free'], + ['existing', 'existing'], + ['list', 'list'], + ['none', 'none'], + ], + defaultValue: '__default__', + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Predefined Tags'), + }, + { + name: 'user-allow-list', + xtype: 'pveListField', + emptyText: gettext('No Tags defined'), + fieldTitle: gettext('Tag'), + maskRe: PVE.Utils.tagCharRegex, + gridConfig: { + height: 200, + scrollable: true, + }, + listeners: { + change: 'tagChange', + }, + }, + { + hidden: true, + xtype: 'displayfield', + reference: 'hintField', + userCls: 'pmx-hint', + }, + ], + }, + ], +}); +Ext.define('PVE.dc.RegisteredTagsEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveRegisteredTagEdit', + + subject: gettext('Registered Tags'), + onlineHelp: 'gui_tags', + + url: '/api2/extjs/cluster/options', + + hintText: gettext('NOTE: The following tags are also defined in the user allow list.'), + + controller: { + xclass: 'Ext.app.ViewController', + + tagChange: function (field, value) { + let me = this; + let view = me.getView(); + let also_allowed = []; + value = Ext.isArray(value) ? value : value.split(';'); + value.forEach((tag) => { + if (view.allowed_tags.indexOf(tag) !== -1) { + also_allowed.push(tag); + } + }); + let hint_field = me.lookup('hintField'); + hint_field.setVisible(also_allowed.length > 0); + if (also_allowed.length > 0) { + hint_field.setValue(`${view.hintText} ${also_allowed.join(', ')}`); + } + }, + }, + + items: [ + { + xtype: 'inputpanel', + setValues: function (values) { + let allowed_tags = values?.['user-tag-access']?.['user-allow-list'] ?? []; + this.up('pveRegisteredTagEdit').allowed_tags = allowed_tags; + let tags = values?.['registered-tags']; + return Proxmox.panel.InputPanel.prototype.setValues.call(this, { tags }); + }, + onGetValues: function (values) { + if (!values.tags) { + return { + delete: 'registered-tags', + }; + } else { + return { + 'registered-tags': values.tags, + }; + } + }, + items: [ + { + name: 'tags', + xtype: 'pveListField', + maskRe: PVE.Utils.tagCharRegex, + gridConfig: { + height: 200, + scrollable: true, + emptyText: gettext('No Tags defined'), + }, + listeners: { + change: 'tagChange', + }, + }, + { + hidden: true, + xtype: 'displayfield', + reference: 'hintField', + userCls: 'pmx-hint', + }, + ], + }, + ], +}); +Ext.define('PVE.dc.RealmSyncJobView', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveRealmSyncJobView', + + stateful: true, + stateId: 'grid-realmsyncjobs', + + emptyText: Ext.String.format(gettext('No {0} configured'), gettext('Realm Sync Job')), + + controller: { + xclass: 'Ext.app.ViewController', + + addRealmSyncJob: function (button) { + let me = this; + Ext.create(`PVE.dc.RealmSyncJobEdit`, { + autoShow: true, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + + editRealmSyncJob: function () { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || selection.length < 1) { + return; + } + + Ext.create(`PVE.dc.RealmSyncJobEdit`, { + jobid: selection[0].data.id, + autoShow: true, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + + runNow: function () { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || selection.length < 1) { + return; + } + + let params = selection[0].data; + let realm = params.realm; + + let propertiesToDelete = [ + 'comment', + 'realm', + 'id', + 'type', + 'schedule', + 'last-run', + 'next-run', + 'enabled', + ]; + for (const prop of propertiesToDelete) { + delete params[prop]; + } + + Proxmox.Utils.API2Request({ + url: `/access/domains/${realm}/sync`, + params, + waitMsgTarget: view, + method: 'POST', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function (response, options) { + Ext.create('Proxmox.window.TaskProgress', { + autoShow: true, + upid: response.result.data, + taskDone: () => { + me.reload(); + }, + }); + }, + }); + }, + + reload: function () { + this.getView().getStore().load(); + }, + }, + + store: { + autoLoad: true, + id: 'realm-syncs', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/jobs/realm-sync', + }, + }, + + viewConfig: { + getRowClass: (record, _index) => (record.get('enabled') ? '' : 'proxmox-disabled-row'), + }, + + columns: [ + { + header: gettext('Enabled'), + width: 80, + dataIndex: 'enabled', + sortable: true, + align: 'center', + stopSelection: false, + renderer: Proxmox.Utils.renderEnabledIcon, + }, + { + text: gettext('Name'), + flex: 1, + dataIndex: 'id', + hidden: true, + }, + { + text: gettext('Realm'), + width: 200, + dataIndex: 'realm', + }, + { + header: gettext('Schedule'), + width: 150, + dataIndex: 'schedule', + }, + { + text: gettext('Next Run'), + dataIndex: 'next-run', + width: 150, + renderer: PVE.Utils.render_next_event, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.htmlEncode, + sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''), + flex: 1, + }, + ], + + tbar: [ + { + text: gettext('Add'), + handler: 'addRealmSyncJob', + }, + { + text: gettext('Edit'), + xtype: 'proxmoxButton', + handler: 'editRealmSyncJob', + disabled: true, + }, + { + xtype: 'proxmoxStdRemoveButton', + baseurl: `/api2/extjs/cluster/jobs/realm-sync`, + callback: 'reload', + }, + { + xtype: 'proxmoxButton', + handler: 'runNow', + disabled: true, + text: gettext('Run Now'), + }, + ], + + listeners: { + itemdblclick: 'editRealmSyncJob', + }, + + initComponent: function () { + var me = this; + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore()); + }, +}); + +Ext.define('PVE.dc.RealmSyncJobEdit', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + subject: gettext('Realm Sync Job'), + onlineHelp: 'pveum_ldap_sync', + + // don't focus the schedule field on edit + defaultFocus: 'field[name=id]', + + cbindData: function () { + let me = this; + me.isCreate = !me.jobid; + me.jobid = me.jobid || ''; + let url = '/api2/extjs/cluster/jobs/realm-sync'; + me.url = me.jobid ? `${url}/${me.jobid}` : url; + me.method = me.isCreate ? 'POST' : 'PUT'; + if (!me.isCreate) { + me.subject = `${me.subject}: ${me.jobid}`; + } + return {}; + }, + + submitUrl: function (url, values) { + return this.isCreate ? `${url}/${values.id}` : url; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + updateDefaults: function (_field, newValue) { + let me = this; + + ['scope', 'enable-new', 'schedule'].forEach((reference) => { + me.lookup(reference)?.setDisabled(false); + }); + + // only update on create + if (!me.getView().isCreate) { + return; + } + Proxmox.Utils.API2Request({ + url: `/access/domains/${newValue}`, + success: function (response) { + // first reset the fields to their default + ['acl', 'entry', 'properties'].forEach((opt) => { + me.lookup(`remove-vanished-${opt}`)?.setValue(false); + }); + me.lookup('enable-new')?.setValue('1'); + me.lookup('scope')?.setValue(undefined); + + let options = response?.result?.data?.['sync-defaults-options']; + if (options) { + let parsed = PVE.Parser.parsePropertyString(options); + if (parsed['remove-vanished']) { + let opts = parsed['remove-vanished'].split(';'); + for (const opt of opts) { + me.lookup(`remove-vanished-${opt}`)?.setValue(true); + } + delete parsed['remove-vanished']; + } + for (const [name, value] of Object.entries(parsed)) { + me.lookup(name)?.setValue(value); + } + } + }, + }); + }, + }, + + items: [ + { + xtype: 'inputpanel', + + cbind: { + isCreate: '{isCreate}', + }, + + onGetValues: function (values) { + let me = this; + + let vanished_opts = []; + ['acl', 'entry', 'properties'].forEach((prop) => { + if (values[`remove-vanished-${prop}`]) { + vanished_opts.push(prop); + } + delete values[`remove-vanished-${prop}`]; + }); + + if (!values.id && me.isCreate) { + values.id = + 'realmsync-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); + } + + if (vanished_opts.length > 0) { + values['remove-vanished'] = vanished_opts.join(';'); + } else { + values['remove-vanished'] = 'none'; + } + + PVE.Utils.delete_if_default(values, 'node', ''); + + if (me.isCreate) { + delete values.delete; // on create we cannot delete values + } + + return values; + }, + + column1: [ + { + xtype: 'pmxDisplayEditField', + editConfig: { + xtype: 'pmxRealmComboBox', + storeFilter: (rec) => rec.data.type === 'ldap' || rec.data.type === 'ad', + }, + listConfig: { + emptyText: `
    ${gettext('No LDAP/AD Realm found')}
    `, + }, + cbind: { + editable: '{isCreate}', + }, + listeners: { + change: 'updateDefaults', + }, + fieldLabel: gettext('Realm'), + name: 'realm', + reference: 'realm', + }, + { + xtype: 'pveCalendarEvent', + fieldLabel: gettext('Schedule'), + disabled: true, + allowBlank: false, + name: 'schedule', + reference: 'schedule', + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enable Job'), + name: 'enabled', + reference: 'enabled', + uncheckedValue: 0, + defaultValue: 1, + checked: true, + }, + ], + + column2: [ + { + xtype: 'proxmoxKVComboBox', + name: 'scope', + reference: 'scope', + disabled: true, + fieldLabel: gettext('Scope'), + value: '', + emptyText: gettext('No default available'), + deleteEmpty: false, + allowBlank: false, + comboItems: [ + ['users', gettext('Users')], + ['groups', gettext('Groups')], + ['both', gettext('Users and Groups')], + ], + }, + { + xtype: 'proxmoxKVComboBox', + value: '1', + deleteEmpty: false, + disabled: true, + allowBlank: false, + comboItems: [ + ['1', Proxmox.Utils.yesText], + ['0', Proxmox.Utils.noText], + ], + name: 'enable-new', + reference: 'enable-new', + fieldLabel: gettext('Enable New'), + }, + ], + + columnB: [ + { + xtype: 'fieldset', + title: gettext('Remove Vanished Options'), + items: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('ACL'), + name: 'remove-vanished-acl', + reference: 'remove-vanished-acl', + boxLabel: gettext('Remove ACLs of vanished users and groups.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Entry'), + name: 'remove-vanished-entry', + reference: 'remove-vanished-entry', + boxLabel: gettext('Remove vanished user and group entries.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Properties'), + name: 'remove-vanished-properties', + reference: 'remove-vanished-properties', + boxLabel: gettext('Remove vanished properties from synced users.'), + }, + ], + }, + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Job Comment'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Description of the job'), + }, + }, + { + xtype: 'displayfield', + reference: 'defaulthint', + value: gettext('Default sync options can be set by editing the realm.'), + userCls: 'pmx-hint', + hidden: true, + }, + ], + }, + ], + + initComponent: function () { + let me = this; + me.callParent(); + if (me.jobid) { + me.load({ + success: function (response, options) { + let values = response.result.data; + + if (values['remove-vanished']) { + let opts = values['remove-vanished'].split(';'); + for (const opt of opts) { + values[`remove-vanished-${opt}`] = 1; + } + } + me.down('inputpanel').setValues(values); + }, + }); + } + }, +}); +Ext.define('pve-resource-pci-tree', { + extend: 'Ext.data.Model', + idProperty: 'internalId', + fields: ['type', 'text', 'path', 'id', 'subsystem-id', 'iommugroup', 'description', 'digest'], +}); + +Ext.define('PVE.dc.PCIMapView', { + extend: 'PVE.tree.ResourceMapTree', + alias: 'widget.pveDcPCIMapView', + + editWindowClass: 'PVE.window.PCIMapEditWindow', + baseUrl: '/cluster/mapping/pci', + mapIconCls: 'pve-itype-icon-pci', + getStatusCheckUrl: (node) => `/nodes/${node}/hardware/pci?pci-class-blacklist=`, + entryIdProperty: 'path', + + checkValidity: function (data, node) { + let me = this; + let ids = {}; + data.forEach((entry) => { + ids[entry.id] = entry; + }); + // extract the mdev property from the global entry and insert to the individiual entries, + // so we can reuse the normal checking logic + let mdev; + me.getRootNode()?.cascade(function (rec) { + if (rec.data.type === 'entry') { + mdev = rec.data.mdev ?? 0; + } + if (rec.data.node !== node || rec.data.type !== 'map') { + return; + } + rec.data.mdev = mdev; + + let id = rec.data.path; + if (!id.match(/\.\d$/)) { + id += '.0'; + } + let device = ids[id]; + if (!device) { + rec.set('valid', 0); + rec.set('errmsg', Ext.String.format(gettext('Cannot find PCI id {0}'), id)); + rec.commit(); + return; + } + + let deviceId = `${device.vendor}:${device.device}`.replace(/0x/g, ''); + let subId = `${device.subsystem_vendor}:${device.subsystem_device}`.replace(/0x/g, ''); + + let toCheck = { + id: deviceId, + 'subsystem-id': subId, + mdev: device.mdev ?? 0, + iommugroup: device.iommugroup !== -1 ? device.iommugroup : undefined, + }; + + let valid = 1; + let errors = []; + let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')"); + for (const [key, validValue] of Object.entries(toCheck)) { + if (`${rec.data[key]}` !== `${validValue}`) { + errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue)); + valid = 0; + } + } + + rec.set('valid', valid); + rec.set('errmsg', errors.join('
    ')); + rec.commit(); + }); + }, + + store: { + sorters: 'text', + model: 'pve-resource-pci-tree', + data: {}, + }, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('ID/Node/Path'), + dataIndex: 'text', + width: 200, + }, + { + text: gettext('Vendor/Device'), + dataIndex: 'id', + }, + { + text: gettext('Subsystem Vendor/Device'), + dataIndex: 'subsystem-id', + }, + { + text: gettext('IOMMU-Group'), + dataIndex: 'iommugroup', + }, + { + header: gettext('Status'), + dataIndex: 'valid', + flex: 1, + renderer: 'renderStatus', + }, + { + header: gettext('Comment'), + dataIndex: 'description', + renderer: function (value, _meta, record) { + return Ext.String.htmlEncode(value ?? record.data.comment); + }, + flex: 1, + }, + ], +}); +Ext.define('pve-resource-usb-tree', { + extend: 'Ext.data.Model', + idProperty: 'internalId', + fields: ['type', 'text', 'path', 'id', 'description', 'digest'], +}); + +Ext.define('PVE.dc.USBMapView', { + extend: 'PVE.tree.ResourceMapTree', + alias: 'widget.pveDcUSBMapView', + + editWindowClass: 'PVE.window.USBMapEditWindow', + baseUrl: '/cluster/mapping/usb', + mapIconCls: 'fa fa-usb', + getStatusCheckUrl: (node) => `/nodes/${node}/hardware/usb`, + entryIdProperty: 'id', + + checkValidity: function (data, node) { + let me = this; + let ids = {}; + let paths = {}; + data.forEach((entry) => { + ids[`${entry.vendid}:${entry.prodid}`] = entry; + paths[`${entry.busnum}-${entry.usbpath}`] = entry; + }); + me.getRootNode()?.cascade(function (rec) { + if (rec.data.node !== node || rec.data.type !== 'map') { + return; + } + + let device; + if (rec.data.path) { + device = paths[rec.data.path]; + } + device ??= ids[rec.data.id]; + + if (!device) { + rec.set('valid', 0); + rec.set( + 'errmsg', + Ext.String.format(gettext('Cannot find USB device {0}'), rec.data.id), + ); + rec.commit(); + return; + } + + let deviceId = `${device.vendid}:${device.prodid}`.replace(/0x/g, ''); + + let toCheck = { + id: deviceId, + }; + + let valid = 1; + let errors = []; + let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')"); + for (const [key, validValue] of Object.entries(toCheck)) { + if (rec.data[key] !== validValue) { + errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue)); + valid = 0; + } + } + + rec.set('valid', valid); + rec.set('errmsg', errors.join('
    ')); + rec.commit(); + }); + }, + + store: { + sorters: 'text', + model: 'pve-resource-usb-tree', + data: {}, + }, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('ID/Node/Vendor&Device'), + dataIndex: 'text', + width: 200, + }, + { + text: gettext('Path'), + dataIndex: 'path', + }, + { + header: gettext('Status'), + dataIndex: 'valid', + flex: 1, + renderer: 'renderStatus', + }, + { + header: gettext('Comment'), + dataIndex: 'description', + renderer: function (value, _meta, record) { + return Ext.String.htmlEncode(value ?? record.data.comment); + }, + flex: 1, + }, + ], +}); +Ext.define('pve-resource-dir-tree', { + extend: 'Ext.data.Model', + idProperty: 'internalId', + fields: ['type', 'text', 'path', 'id', 'description', 'digest'], +}); + +Ext.define('PVE.dc.DirMapView', { + extend: 'PVE.tree.ResourceMapTree', + alias: 'widget.pveDcDirMapView', + + editWindowClass: 'PVE.window.DirMapEditWindow', + baseUrl: '/cluster/mapping/dir', + mapIconCls: 'fa fa-folder', + entryIdProperty: 'path', + + store: { + sorters: 'text', + model: 'pve-resource-dir-tree', + data: {}, + }, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('ID/Node'), + dataIndex: 'text', + width: 200, + }, + { + header: gettext('Comment'), + dataIndex: 'description', + renderer: function (value, _meta, record) { + return Ext.String.htmlEncode(value ?? record.data.comment); + }, + flex: 1, + }, + ], +}); +Ext.define('PVE.lxc.CmdMenu', { + extend: 'Ext.menu.Menu', + + showSeparator: false, + initComponent: function () { + let me = this; + + let info = me.pveSelNode.data; + if (!info.node) { + throw 'no node name specified'; + } + if (!info.vmid) { + throw 'no CT ID specified'; + } + + let vm_command = function (cmd, params) { + Proxmox.Utils.API2Request({ + params: params, + url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`, + method: 'POST', + failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }; + let confirmedVMCommand = (cmd, params) => { + let msg = PVE.Utils.formatGuestTaskConfirmation(`vz${cmd}`, info.vmid, info.name); + Ext.Msg.confirm(gettext('Confirm'), msg, (btn) => { + if (btn === 'yes') { + vm_command(cmd, params); + } + }); + }; + + let caps = Ext.state.Manager.get('GuiCap'); + let standalone = PVE.Utils.isStandaloneNode(); + + let running = false, + stopped = true, + suspended = false; + switch (info.status) { + case 'running': + running = true; + stopped = false; + break; + case 'paused': + stopped = false; + suspended = true; + break; + default: + break; + } + + me.title = 'CT ' + info.vmid; + + me.items = [ + { + text: gettext('Start'), + iconCls: 'fa fa-fw fa-play', + disabled: running, + handler: () => vm_command('start'), + }, + { + text: gettext('Shutdown'), + iconCls: 'fa fa-fw fa-power-off', + disabled: stopped || suspended, + handler: () => confirmedVMCommand('shutdown'), + }, + { + text: gettext('Stop'), + iconCls: 'fa fa-fw fa-stop', + disabled: stopped, + tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'), + handler: () => { + Ext.create('PVE.GuestStop', { + nodename: info.node, + vm: info, + autoShow: true, + }); + }, + }, + { + text: gettext('Reboot'), + iconCls: 'fa fa-fw fa-refresh', + disabled: stopped, + tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'), + handler: () => confirmedVMCommand('reboot'), + }, + { + xtype: 'menuseparator', + hidden: + (standalone || !caps.vms['VM.Migrate']) && + !caps.vms['VM.Allocate'] && + !caps.vms['VM.Clone'], + }, + { + text: gettext('Clone'), + iconCls: 'fa fa-fw fa-clone', + hidden: !caps.vms['VM.Clone'], + handler: () => + PVE.window.Clone.wrap(info.node, info.vmid, info.name, me.isTemplate, 'lxc'), + }, + { + text: gettext('Migrate'), + iconCls: 'fa fa-fw fa-send-o', + hidden: standalone || !caps.vms['VM.Migrate'], + handler: function () { + Ext.create('PVE.window.Migrate', { + vmtype: 'lxc', + nodename: info.node, + vmid: info.vmid, + vmname: info.name, + autoShow: true, + }); + }, + }, + { + text: gettext('Convert to template'), + iconCls: 'fa fa-fw fa-file-o', + handler: function () { + let msg = PVE.Utils.formatGuestTaskConfirmation( + 'vztemplate', + info.vmid, + info.name, + ); + Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) { + if (btn === 'yes') { + Proxmox.Utils.API2Request({ + url: `/nodes/${info.node}/lxc/${info.vmid}/template`, + method: 'POST', + failure: (response, opts) => + Ext.Msg.alert('Error', response.htmlStatus), + }); + } + }); + }, + }, + { xtype: 'menuseparator' }, + { + text: gettext('Take Snapshot'), + iconCls: 'fa fa-fw fa-history', + itemId: 'takeSnapshotBtn', + disabled: true, + handler: () => { + Ext.create('PVE.window.Snapshot', { + nodename: info.node, + vmid: info.vmid, + vmname: info.name, + viewonly: false, + type: info.type, + isCreate: true, + submitText: gettext('Take Snapshot'), + autoShow: true, + running: running, + }); + }, + }, + { + text: gettext('Backup now'), + iconCls: 'fa fa-fw fa-floppy-o', + disabled: !caps.vms['VM.Backup'], + handler: () => { + Ext.create('PVE.window.Backup', { + nodename: info.node, + vmid: info.vmid, + vmtype: info.type, + vmname: info.name, + autoShow: true, + }); + }, + }, + { xtype: 'menuseparator' }, + { + text: gettext('Console'), + iconCls: 'fa fa-fw fa-terminal', + handler: () => + PVE.Utils.openDefaultConsoleWindow( + true, + 'lxc', + info.vmid, + info.node, + info.vmname, + ), + }, + ]; + + me.callParent(); + + if (caps.vms['VM.Snapshot']) { + Proxmox.Utils.API2Request({ + url: `/nodes/${info.node}/${info.type}/${info.vmid}/feature`, + params: { feature: 'snapshot' }, + method: 'GET', + success: (response) => { + let hasFeature = response.result.data.hasFeature; + let btn = me.down('#takeSnapshotBtn'); + if (btn) { + btn.setDisabled(!hasFeature); + } + }, + }); + } + }, +}); +Ext.define('PVE.lxc.Config', { + extend: 'PVE.panel.Config', + alias: 'widget.pveLXCConfig', + + onlineHelp: 'chapter_pct', + + userCls: 'proxmox-tags-full', + + initComponent: function () { + var me = this; + var vm = me.pveSelNode.data; + + var nodename = vm.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = vm.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var template = !!vm.template; + + var running = !!vm.uptime; + + var caps = Ext.state.Manager.get('GuiCap'); + + var base_url = '/nodes/' + nodename + '/lxc/' + vmid; + + me.statusStore = Ext.create('Proxmox.data.ObjectStore', { + url: '/api2/json' + base_url + '/status/current', + interval: 1000, + }); + + var vm_command = function (cmd, params) { + Proxmox.Utils.API2Request({ + params: params, + url: base_url + '/status/' + cmd, + waitMsgTarget: me, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + }); + }; + + var startBtn = Ext.create('Ext.Button', { + text: gettext('Start'), + disabled: !caps.vms['VM.PowerMgmt'] || running, + hidden: template, + handler: function () { + vm_command('start'); + }, + iconCls: 'fa fa-play', + }); + + var shutdownBtn = Ext.create('PVE.button.Split', { + text: gettext('Shutdown'), + disabled: !caps.vms['VM.PowerMgmt'] || !running, + hidden: template, + confirmMsg: PVE.Utils.formatGuestTaskConfirmation('vzshutdown', vmid, vm.name), + handler: function () { + vm_command('shutdown'); + }, + menu: { + items: [ + { + text: gettext('Reboot'), + disabled: !caps.vms['VM.PowerMgmt'], + confirmMsg: PVE.Utils.formatGuestTaskConfirmation( + 'vzreboot', + vmid, + vm.name, + ), + tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'), + handler: function () { + vm_command('reboot'); + }, + iconCls: 'fa fa-refresh', + }, + { + text: gettext('Stop'), + disabled: !caps.vms['VM.PowerMgmt'], + tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'), + handler: function () { + Ext.create('PVE.GuestStop', { + nodename: nodename, + vm: vm, + autoShow: true, + }); + }, + iconCls: 'fa fa-stop', + }, + ], + }, + iconCls: 'fa fa-power-off', + }); + + var migrateBtn = Ext.create('Ext.Button', { + text: gettext('Migrate'), + disabled: !caps.vms['VM.Migrate'], + hidden: PVE.Utils.isStandaloneNode(), + handler: function () { + var win = Ext.create('PVE.window.Migrate', { + vmtype: 'lxc', + nodename: nodename, + vmid: vmid, + vmname: vm.name, + }); + win.show(); + }, + iconCls: 'fa fa-send-o', + }); + + var moreBtn = Ext.create('Proxmox.button.Button', { + text: gettext('More'), + menu: { + items: [ + { + text: gettext('Clone'), + iconCls: 'fa fa-fw fa-clone', + hidden: !caps.vms['VM.Clone'], + handler: function () { + PVE.window.Clone.wrap(nodename, vmid, vm.name, template, 'lxc'); + }, + }, + { + text: gettext('Convert to template'), + disabled: template, + xtype: 'pveMenuItem', + iconCls: 'fa fa-fw fa-file-o', + hidden: !caps.vms['VM.Allocate'], + confirmMsg: PVE.Utils.formatGuestTaskConfirmation( + 'vztemplate', + vmid, + vm.name, + ), + handler: function () { + Proxmox.Utils.API2Request({ + url: base_url + '/template', + waitMsgTarget: me, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + }); + }, + }, + { + iconCls: 'fa fa-heartbeat ', + hidden: !caps.nodes['Sys.Console'], + text: gettext('Manage HA'), + handler: function () { + var ha = vm.hastate; + Ext.create('PVE.ha.VMResourceEdit', { + vmid: vmid, + guestType: 'ct', + isCreate: !ha || ha === 'unmanaged', + }).show(); + }, + }, + { + text: gettext('Remove'), + disabled: !caps.vms['VM.Allocate'], + itemId: 'removeBtn', + handler: function () { + Ext.create('PVE.window.SafeDestroyGuest', { + url: base_url, + item: { + type: 'CT', + id: vmid, + formattedIdentifier: PVE.Utils.getFormattedGuestIdentifier( + vmid, + vm.name, + ), + }, + taskName: 'vzdestroy', + }).show(); + }, + iconCls: 'fa fa-trash-o', + }, + ], + }, + }); + + var consoleBtn = Ext.create('PVE.button.ConsoleButton', { + disabled: !caps.vms['VM.Console'], + consoleType: 'lxc', + consoleName: vm.name, + hidden: template, + nodename: nodename, + vmid: vmid, + }); + + var statusTxt = Ext.create('Ext.toolbar.TextItem', { + data: { + lock: undefined, + }, + tpl: ['', ' ({lock})', ''], + }); + + let tagsContainer = Ext.create('PVE.panel.TagEditContainer', { + tags: vm.tags, + canEdit: !!caps.vms['VM.Config.Options'], + listeners: { + change: function (tags) { + Proxmox.Utils.API2Request({ + url: base_url + '/config', + method: 'PUT', + params: { + tags, + }, + success: function () { + me.statusStore.load(); + }, + failure: function (response) { + Ext.Msg.alert('Error', response.htmlStatus); + me.statusStore.load(); + }, + }); + }, + }, + }); + + let vm_text = `${vm.vmid} (${vm.name})`; + + Ext.apply(me, { + title: Ext.String.format(gettext("Container {0} on node '{1}'"), vm_text, nodename), + hstateid: 'lxctab', + tbarSpacing: false, + tbar: [ + statusTxt, + tagsContainer, + '->', + startBtn, + shutdownBtn, + migrateBtn, + consoleBtn, + moreBtn, + ], + defaults: { statusStore: me.statusStore }, + items: [ + { + title: gettext('Summary'), + xtype: 'pveGuestSummary', + iconCls: 'fa fa-book', + itemId: 'summary', + }, + ], + }); + + if (caps.vms['VM.Console'] && !template) { + me.items.push({ + title: gettext('Console'), + itemId: 'consolejs', + iconCls: 'fa fa-terminal', + xtype: 'pveNoVncConsole', + vmid: vmid, + consoleType: 'lxc', + xtermjs: true, + nodename: nodename, + }); + } + + me.items.push( + { + title: gettext('Resources'), + itemId: 'resources', + expandedOnInit: true, + iconCls: 'fa fa-cube', + xtype: 'pveLxcRessourceView', + }, + { + title: gettext('Network'), + iconCls: 'fa fa-exchange', + itemId: 'network', + xtype: 'pveLxcNetworkView', + }, + { + title: gettext('DNS'), + iconCls: 'fa fa-globe', + itemId: 'dns', + xtype: 'pveLxcDNS', + }, + { + title: gettext('Options'), + itemId: 'options', + iconCls: 'fa fa-gear', + xtype: 'pveLxcOptions', + }, + { + title: gettext('Task History'), + itemId: 'tasks', + iconCls: 'fa fa-list-alt', + xtype: 'proxmoxNodeTasks', + nodename: nodename, + preFilter: { + vmid, + }, + }, + ); + + if (caps.vms['VM.Backup']) { + me.items.push( + { + title: gettext('Backup'), + iconCls: 'fa fa-floppy-o', + xtype: 'pveBackupView', + itemId: 'backup', + }, + { + title: gettext('Replication'), + iconCls: 'fa fa-retweet', + xtype: 'pveReplicaView', + itemId: 'replication', + }, + ); + } + + if ( + (caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] || caps.vms['VM.Audit']) && + !template + ) { + me.items.push({ + title: gettext('Snapshots'), + iconCls: 'fa fa-history', + xtype: 'pveGuestSnapshotTree', + type: 'lxc', + itemId: 'snapshot', + }); + } + + if (caps.vms['VM.Audit']) { + me.items.push( + { + xtype: 'pveFirewallRules', + title: gettext('Firewall'), + iconCls: 'fa fa-shield', + allow_iface: true, + base_url: base_url + '/firewall/rules', + list_refs_url: base_url + '/firewall/refs', + itemId: 'firewall', + firewall_type: 'vm', + }, + { + xtype: 'pveFirewallOptions', + groups: ['firewall'], + iconCls: 'fa fa-gear', + onlineHelp: 'pve_firewall_vm_container_configuration', + title: gettext('Options'), + base_url: base_url + '/firewall/options', + fwtype: 'vm', + itemId: 'firewall-options', + }, + { + xtype: 'pveFirewallAliases', + title: gettext('Alias'), + groups: ['firewall'], + iconCls: 'fa fa-external-link', + base_url: base_url + '/firewall/aliases', + itemId: 'firewall-aliases', + }, + { + xtype: 'pveIPSet', + title: gettext('IPSet'), + groups: ['firewall'], + iconCls: 'fa fa-list-ol', + base_url: base_url + '/firewall/ipset', + list_refs_url: base_url + '/firewall/refs', + itemId: 'firewall-ipset', + }, + ); + } + + if (caps.vms['VM.Console']) { + me.items.push({ + title: gettext('Log'), + groups: ['firewall'], + iconCls: 'fa fa-list', + onlineHelp: 'chapter_pve_firewall', + itemId: 'firewall-fwlog', + xtype: 'proxmoxLogView', + url: '/api2/extjs' + base_url + '/firewall/log', + log_select_timespan: true, + submitFormat: 'U', + }); + } + + if (caps.vms['Permissions.Modify']) { + me.items.push({ + xtype: 'pveACLView', + title: gettext('Permissions'), + itemId: 'permissions', + iconCls: 'fa fa-unlock', + path: '/vms/' + vmid, + }); + } + + me.callParent(); + + var prevStatus = 'unknown'; + me.mon(me.statusStore, 'load', function (s, records, success) { + var status; + var lock; + var rec; + + if (!success) { + status = 'unknown'; + } else { + rec = s.data.get('status'); + status = rec ? rec.data.value : 'unknown'; + rec = s.data.get('template'); + template = rec ? rec.data.value : false; + rec = s.data.get('lock'); + lock = rec ? rec.data.value : undefined; + } + + statusTxt.update({ lock: lock }); + + rec = s.data.get('tags'); + tagsContainer.loadTags(rec?.data?.value); + + startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status === 'running' || template); + shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running'); + me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped'); + consoleBtn.setDisabled(template); + + if (prevStatus === 'stopped' && status === 'running') { + let con = me.down('#consolejs'); + if (con) { + con.reload(); + } + } + + prevStatus = status; + }); + + me.on('afterrender', function () { + me.statusStore.startUpdate(); + }); + + me.on('destroy', function () { + me.statusStore.stopUpdate(); + }); + }, +}); +Ext.define('PVE.lxc.CreateWizard', { + extend: 'PVE.window.Wizard', + mixins: ['Proxmox.Mixin.CBind'], + + viewModel: { + data: { + nodename: '', + storage: '', + unprivileged: true, + }, + formulas: { + cgroupMode: function (get) { + const nodeInfo = PVE.data.ResourceStore.getNodes().find( + (node) => node.node === get('nodename'), + ); + return nodeInfo ? nodeInfo['cgroup-mode'] : 2; + }, + }, + }, + + cbindData: { + nodename: undefined, + }, + + subject: gettext('LXC Container'), + + items: [ + { + xtype: 'inputpanel', + title: gettext('General'), + onlineHelp: 'pct_general', + column1: [ + { + xtype: 'pveNodeSelector', + name: 'nodename', + cbind: { + selectCurNode: '{!nodename}', + preferredValue: '{nodename}', + }, + bind: { + value: '{nodename}', + }, + fieldLabel: gettext('Node'), + allowBlank: false, + onlineValidator: true, + }, + { + xtype: 'pveGuestIDSelector', + name: 'vmid', // backend only knows vmid + guestType: 'lxc', + value: '', + loadNextFreeID: true, + validateExists: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'hostname', + vtype: 'DnsName', + value: '', + fieldLabel: gettext('Hostname'), + skipEmptyText: true, + allowBlank: true, + }, + { + xtype: 'proxmoxcheckbox', + name: 'unprivileged', + value: true, + uncheckedValue: 0, + bind: { + value: '{unprivileged}', + }, + fieldLabel: gettext('Unprivileged container'), + }, + { + xtype: 'proxmoxcheckbox', + name: 'features', + inputValue: 'nesting=1', + value: true, + clearOnDisable: true, + bind: { + disabled: '{!unprivileged}', + }, + fieldLabel: gettext('Nesting'), + }, + { + xtype: 'proxmoxcheckbox', + name: 'ha-managed', + // only submit value of checkbox if checked + uncheckedValue: undefined, + fieldLabel: gettext('Add to HA'), + }, + ], + column2: [ + { + xtype: 'pvePoolSelector', + fieldLabel: gettext('Resource Pool'), + name: 'pool', + value: '', + allowBlank: true, + }, + { + xtype: 'textfield', + inputType: 'password', + name: 'password', + value: '', + fieldLabel: gettext('Password'), + allowBlank: false, + minLength: 5, + change: function (f, value) { + if (f.rendered) { + f.up().down('field[name=confirmpw]').validate(); + } + }, + }, + { + xtype: 'textfield', + inputType: 'password', + name: 'confirmpw', + value: '', + fieldLabel: gettext('Confirm password'), + allowBlank: true, + submitValue: false, + validator: function (value) { + var pw = this.up().down('field[name=password]').getValue(); + if (pw !== value) { + return 'Passwords do not match!'; + } + return true; + }, + }, + { + xtype: 'textarea', + name: 'ssh-public-keys', + value: '', + fieldLabel: gettext('SSH public key(s)'), + allowBlank: true, + validator: function (value) { + let pwfield = this.up().down('field[name=password]'); + if (value.length) { + let keys = value.indexOf('\n') !== -1 ? value.split('\n') : [value]; + if (keys.some((key) => key !== '' && !PVE.Parser.parseSSHKey(key))) { + return 'Failed to recognize ssh key'; + } + pwfield.allowBlank = true; + } else { + pwfield.allowBlank = false; + } + pwfield.validate(); + return true; + }, + afterRender: function () { + if (!window.FileReader) { + return; // No FileReader support in this browser + } + let cancelEvent = (ev) => { + ev = ev.event; + if (ev.preventDefault) { + ev.preventDefault(); + } + }; + this.inputEl.on('dragover', cancelEvent); + this.inputEl.on('dragenter', cancelEvent); + this.inputEl.on('drop', (ev) => { + cancelEvent(ev); + let files = ev.event.dataTransfer.files; + PVE.Utils.loadSSHKeyFromFile(files[0], (v) => this.setValue(v)); + }); + }, + }, + { + xtype: 'pveMultiFileButton', + name: 'file', + hidden: !window.FileReader, + text: gettext('Load SSH Key File'), + listeners: { + change: function (btn, e, value) { + e = e.event; + let field = this.up().down('textarea[name=ssh-public-keys]'); + for (const file of e?.target?.files ?? []) { + PVE.Utils.loadSSHKeyFromFile(file, (v) => { + let oldValue = field.getValue(); + field.setValue( + oldValue ? `${oldValue}\n${v.trim()}` : v.trim(), + ); + }); + } + btn.reset(); + }, + }, + }, + ], + advancedColumnB: [ + { + xtype: 'pveTagFieldSet', + name: 'tags', + maxHeight: 150, + }, + ], + }, + { + xtype: 'inputpanel', + title: gettext('Template'), + onlineHelp: 'pct_container_images', + column1: [ + { + xtype: 'pveStorageSelector', + name: 'tmplstorage', + fieldLabel: gettext('Storage'), + storageContent: 'vztmpl', + autoSelect: true, + allowBlank: false, + bind: { + value: '{storage}', + nodename: '{nodename}', + }, + }, + { + xtype: 'pveFileSelector', + name: 'ostemplate', + storageContent: 'vztmpl', + fieldLabel: gettext('Template'), + bind: { + storage: '{storage}', + nodename: '{nodename}', + }, + allowBlank: false, + }, + ], + }, + { + xtype: 'pveMultiMPPanel', + title: gettext('Disks'), + insideWizard: true, + isCreate: true, + unused: false, + confid: 'rootfs', + }, + { + xtype: 'pveLxcCPUInputPanel', + title: gettext('CPU'), + insideWizard: true, + }, + { + xtype: 'pveLxcMemoryInputPanel', + title: gettext('Memory'), + insideWizard: true, + }, + { + xtype: 'pveLxcNetworkInputPanel', + title: gettext('Network'), + insideWizard: true, + bind: { + nodename: '{nodename}', + }, + isCreate: true, + }, + { + xtype: 'pveLxcDNSInputPanel', + title: gettext('DNS'), + insideWizard: true, + }, + { + title: gettext('Confirm'), + layout: 'fit', + items: [ + { + xtype: 'grid', + store: { + model: 'KeyValue', + sorters: [ + { + property: 'key', + direction: 'ASC', + }, + ], + }, + columns: [ + { header: 'Key', width: 150, dataIndex: 'key' }, + { header: 'Value', flex: 1, dataIndex: 'value', renderer: Ext.htmlEncode }, + ], + }, + ], + dockedItems: [ + { + xtype: 'proxmoxcheckbox', + name: 'start', + dock: 'bottom', + margin: '5 0 0 0', + boxLabel: gettext('Start after created'), + }, + ], + listeners: { + show: function (panel) { + let wizard = this.up('window'); + let kv = wizard.getValues(); + let data = []; + Ext.Object.each(kv, function (key, value) { + if (key === 'delete' || key === 'tmplstorage') { + // ignore + return; + } + if (key === 'password') { + // don't show pw + return; + } + data.push({ key: key, value: value }); + }); + + let summaryStore = panel.down('grid').getStore(); + summaryStore.suspendEvents(); + summaryStore.removeAll(); + summaryStore.add(data); + summaryStore.sort(); + summaryStore.resumeEvents(); + summaryStore.fireEvent('refresh'); + }, + }, + onSubmit: function () { + let wizard = this.up('window'); + let kv = wizard.getValues(); + delete kv.delete; + + let nodename = kv.nodename; + delete kv.nodename; + delete kv.tmplstorage; + + if (!kv.pool.length) { + delete kv.pool; + } + if (!kv.password.length && kv['ssh-public-keys']) { + delete kv.password; + } + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/lxc`, + waitMsgTarget: wizard, + method: 'POST', + params: kv, + success: function (response, opts) { + Ext.create('Proxmox.window.TaskViewer', { + autoShow: true, + upid: response.result.data, + }); + wizard.close(); + }, + failure: (response, opts) => + Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, + }, + ], +}); +Ext.define('PVE.lxc.DeviceInputPanel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + autoComplete: false, + + controller: { + xclass: 'Ext.app.ViewController', + }, + + setVMConfig: function (vmconfig) { + let me = this; + me.vmconfig = vmconfig; + + if (me.isCreate) { + PVE.Utils.forEachLxcDev((i, name) => { + if (!Ext.isDefined(vmconfig[name])) { + me.confid = name; + me.down('field[name=devid]').setValue(i); + return false; + } + return undefined; + }); + } + }, + + onGetValues: function (values) { + let me = this; + let confid = me.isCreate ? 'dev' + values.devid : me.confid; + delete values.devid; + let val = PVE.Parser.printPropertyString(values, 'path'); + let ret = {}; + ret[confid] = val; + return ret; + }, + + items: [ + { + xtype: 'proxmoxintegerfield', + name: 'devid', + minValue: 0, + maxValue: PVE.Utils.lxc_dev_count - 1, + hidden: true, + allowBlank: false, + disabled: true, + cbind: { + disabled: '{!isCreate}', + }, + }, + { + xtype: 'textfield', + name: 'path', + fieldLabel: gettext('Device Path'), + labelWidth: 120, + editable: true, + allowBlank: false, + emptyText: '/dev/xyz', + validator: (v) => + v.startsWith('/dev/') ? true : gettext('Path has to start with /dev/'), + }, + ], + + advancedColumn1: [ + { + xtype: 'proxmoxintegerfield', + name: 'uid', + editable: true, + fieldLabel: Ext.String.format(gettext('{0} in CT'), 'UID'), + labelWidth: 120, + emptyText: '0', + minValue: 0, + }, + { + xtype: 'proxmoxintegerfield', + name: 'gid', + editable: true, + fieldLabel: Ext.String.format(gettext('{0} in CT'), 'GID'), + labelWidth: 120, + emptyText: '0', + minValue: 0, + }, + ], + + advancedColumn2: [ + { + xtype: 'textfield', + name: 'mode', + editable: true, + fieldLabel: Ext.String.format(gettext('Access Mode in CT')), + labelWidth: 120, + emptyText: '0660', + validator: function (value) { + if (/^0[0-7]{3}$|^$/i.test(value)) { + return true; + } + return gettext('Access mode has to be an octal number'); + }, + }, + { + xtype: 'checkbox', + name: 'deny-write', + fieldLabel: gettext('Read only'), + labelWidth: 120, + checked: false, + }, + ], +}); + +Ext.define('PVE.lxc.DeviceEdit', { + extend: 'Proxmox.window.Edit', + + vmconfig: undefined, + + isAdd: true, + width: 450, + + initComponent: function () { + let me = this; + + me.isCreate = !me.confid; + + let ipanel = Ext.create('PVE.lxc.DeviceInputPanel', { + confid: me.confid, + isCreate: me.isCreate, + pveSelNode: me.pveSelNode, + }); + + let subject; + if (me.isCreate) { + subject = gettext('Device'); + } else { + subject = gettext('Device') + ' (' + me.confid + ')'; + } + + Ext.apply(me, { + subject: subject, + items: [ipanel], + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + ipanel.setVMConfig(response.result.data); + if (me.isCreate) { + return; + } + + let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'path'); + + let values = { + path: data.path, + mode: data.mode, + uid: data.uid, + gid: data.gid, + 'deny-write': data['deny-write'], + }; + + ipanel.setValues(values); + }, + }); + }, +}); +Ext.define('PVE.lxc.DNSInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveLxcDNSInputPanel', + + insideWizard: false, + + onGetValues: function (values) { + var me = this; + + var deletes = []; + if (!values.searchdomain && !me.insideWizard) { + deletes.push('searchdomain'); + } + + if (values.nameserver) { + let list = values.nameserver.split(/[ ,;]+/); + values.nameserver = list.join(' '); + } else if (!me.insideWizard) { + deletes.push('nameserver'); + } + + if (deletes.length) { + values.delete = deletes.join(','); + } + + return values; + }, + + initComponent: function () { + var me = this; + + var items = [ + { + xtype: 'proxmoxtextfield', + name: 'searchdomain', + skipEmptyText: true, + fieldLabel: gettext('DNS domain'), + emptyText: gettext('use host settings'), + allowBlank: true, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('DNS servers'), + vtype: 'IP64AddressWithSuffixList', + allowBlank: true, + emptyText: gettext('use host settings'), + name: 'nameserver', + itemId: 'nameserver', + }, + ]; + + if (me.insideWizard) { + me.column1 = items; + } else { + me.items = items; + } + + me.callParent(); + }, +}); + +Ext.define('PVE.lxc.DNSEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + var ipanel = Ext.create('PVE.lxc.DNSInputPanel'); + + Ext.apply(me, { + subject: gettext('Resources'), + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + + if (values.nameserver) { + values.nameserver.replace(/[,;]/, ' '); + values.nameserver.replace(/^\s+/, ''); + } + + ipanel.setValues(values); + }, + }); + } + }, +}); + +Ext.define('PVE.lxc.DNS', { + extend: 'Proxmox.grid.PendingObjectGrid', + alias: ['widget.pveLxcDNS'], + + onlineHelp: 'pct_container_network', + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var caps = Ext.state.Manager.get('GuiCap'); + + var rows = { + hostname: { + required: true, + defaultValue: me.pveSelNode.data.name, + header: gettext('Hostname'), + editor: caps.vms['VM.Config.Network'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Hostname'), + items: { + xtype: 'inputpanel', + items: { + fieldLabel: gettext('Hostname'), + xtype: 'textfield', + name: 'hostname', + vtype: 'DnsName', + allowBlank: true, + emptyText: 'CT' + vmid.toString(), + }, + onGetValues: function (values) { + var params = values; + if ( + values.hostname === undefined || + values.hostname === null || + values.hostname === '' + ) { + params = { hostname: 'CT' + vmid.toString() }; + } + return params; + }, + }, + } + : undefined, + }, + searchdomain: { + header: gettext('DNS domain'), + defaultValue: '', + editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined, + renderer: function (value) { + return value || gettext('use host settings'); + }, + }, + nameserver: { + header: gettext('DNS server'), + defaultValue: '', + editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined, + renderer: function (value) { + return value || gettext('use host settings'); + }, + }, + }; + + var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config'; + + var reload = function () { + me.rstore.load(); + }; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var run_editor = function () { + var rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + var rowdef = rows[rec.data.key]; + if (!rowdef.editor) { + return; + } + + var win; + if (Ext.isString(rowdef.editor)) { + win = Ext.create(rowdef.editor, { + pveSelNode: me.pveSelNode, + confid: rec.data.key, + url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config', + }); + } else { + let config = Ext.apply( + { + pveSelNode: me.pveSelNode, + confid: rec.data.key, + url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config', + }, + rowdef.editor, + ); + win = Ext.createWidget(rowdef.editor.xtype, config); + win.load(); + } + //win.load(); + win.show(); + win.on('destroy', reload); + }; + + var edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + enableFn: function (rec) { + var rowdef = rows[rec.data.key]; + return !!rowdef.editor; + }, + handler: run_editor, + }); + + var revert_btn = new PVE.button.PendingRevert(); + + var set_button_status = function () { + let button_sm = me.getSelectionModel(); + let rec = button_sm.getSelection()[0]; + + if (!rec) { + edit_btn.disable(); + return; + } + let key = rec.data.key; + + let rowdef = rows[key]; + edit_btn.setDisabled(!rowdef.editor); + + let pending = rec.data.delete || me.hasPendingChanges(key); + revert_btn.setDisabled(!pending); + }; + + Ext.apply(me, { + url: '/api2/json/nodes/' + nodename + '/lxc/' + vmid + '/pending', + selModel: sm, + cwidth1: 150, + interval: 5000, + run_editor: run_editor, + tbar: [edit_btn, revert_btn], + rows: rows, + editorConfig: { + url: '/api2/extjs/' + baseurl, + }, + listeners: { + itemdblclick: run_editor, + selectionchange: set_button_status, + activate: reload, + }, + }); + + me.callParent(); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + me.on('deactivate', me.rstore.stopUpdate); + + me.mon(me.getStore(), 'datachanged', function () { + set_button_status(); + }); + }, +}); +Ext.define('PVE.lxc.FeaturesInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveLxcFeaturesInputPanel', + onlineHelp: 'pct_options', + + // used to save the mounts fstypes until sending + mounts: [], + + fstypes: ['nfs', 'cifs'], + + viewModel: { + parent: null, + data: { + unprivileged: false, + }, + formulas: { + privilegedOnly: function (get) { + return get('unprivileged') ? gettext('privileged only') : ''; + }, + unprivilegedOnly: function (get) { + return !get('unprivileged') ? gettext('unprivileged only') : ''; + }, + }, + }, + + items: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('keyctl'), + name: 'keyctl', + bind: { + disabled: '{!unprivileged}', + boxLabel: '{unprivilegedOnly}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Nesting'), + name: 'nesting', + }, + { + xtype: 'proxmoxcheckbox', + name: 'nfs', + fieldLabel: 'NFS', + bind: { + disabled: '{unprivileged}', + boxLabel: '{privilegedOnly}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'cifs', + fieldLabel: 'SMB/CIFS', + bind: { + disabled: '{unprivileged}', + boxLabel: '{privilegedOnly}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'fuse', + fieldLabel: 'FUSE', + }, + { + xtype: 'proxmoxcheckbox', + name: 'mknod', + fieldLabel: gettext('Create Device Nodes'), + boxLabel: gettext('Experimental'), + }, + ], + + onGetValues: function (values) { + var me = this; + var mounts = me.mounts; + me.fstypes.forEach(function (fs) { + if (values[fs]) { + mounts.push(fs); + } + delete values[fs]; + }); + + if (mounts.length) { + values.mount = mounts.join(';'); + } + + var featuresstring = PVE.Parser.printPropertyString(values, undefined); + if (featuresstring === '') { + return { delete: 'features' }; + } + return { features: featuresstring }; + }, + + setValues: function (values) { + var me = this; + + me.viewModel.set('unprivileged', values.unprivileged); + + if (values.features) { + let res = PVE.Parser.parsePropertyString(values.features); + me.mounts = []; + if (res.mount) { + res.mount.split(/[; ]/).forEach(function (item) { + if (me.fstypes.indexOf(item) === -1) { + me.mounts.push(item); + } else { + res[item] = 1; + } + }); + } + this.callParent([res]); + } + }, + + initComponent: function () { + let me = this; + me.mounts = []; // reset state + me.callParent(); + }, +}); + +Ext.define('PVE.lxc.FeaturesEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveLxcFeaturesEdit', + + subject: gettext('Features'), + autoLoad: true, + width: 350, + + items: [ + { + xtype: 'pveLxcFeaturesInputPanel', + }, + ], +}); +Ext.define('PVE.lxc.EnvVariableField', { + extend: 'Ext.form.FieldContainer', + mixins: { + field: 'Ext.form.field.Field', + }, + xtype: 'pveLxcEnvVariableField', + + name: 'variable', + + layout: { + type: 'hbox', + align: 'stretch', + }, + + config: { + value: null, + }, + + // called when X icon-button is clicked, with this field as argument. + onRemove: Ext.emptyFn, + + setValue: function (nameValue) { + let me = this; + let viewModel = me.getViewModel(); + + me.value = nameValue; + let [name, value] = nameValue?.split('=') ?? ['', '']; + + viewModel.set('value', value); + viewModel.set('name', name); + + // TODO: sub-fields might not be available when this is called, so we cannot just set the + // value on field directly or call resetOriginalValue for correct reset orig. val. behavior + me.resetOriginalValue(); + }, + + getValue: function () { + let viewModel = this.getViewModel(); + let name = viewModel.get('name'); + let value = viewModel.get('value') ?? ''; + + return name?.length ? `${name}=${value}` : ''; + }, + + viewModel: { + parent: null, + data: { + name: '', + value: '', + }, + formulas: { + valueEmpty: (get) => !get('value')?.length, + }, + }, + + defaultType: 'textfield', + items: [ + { + xtype: 'proxmoxtextfield', + emptyText: gettext('Name'), + bind: { + allowBlank: '{valueEmpty}', + value: '{name}', + }, + submitValue: false, + flex: 2, + }, + { + xtype: 'box', + html: '=', + padding: '0 5', + }, + { + xtype: 'proxmoxtextfield', + emptyText: gettext('Value'), + bind: { + value: '{value}', + }, + submitValue: false, + flex: 3, + }, + { + xtype: 'button', + cls: 'x-btn-default-toolbar-small proxmox-inline-button', + iconCls: 'x-btn-icon-el-default-toolbar-small fa fa-trash-o', + handler: function (button, event) { + let field = button.up('pveLxcEnvVariableField'); + field.onRemove.call(field, field); + }, + }, + ], +}); + +Ext.define('PVE.lxc.EnvInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveLxcEnvInputPanel', + onlineHelp: 'pct_options', + + onGetValues: function (formValues) { + let variables = formValues?.variable; + if (typeof variables === 'string') { + variables = [variables]; + } + variables = variables?.filter((v) => typeof v === 'string' && v.length); + + let submitValues = {}; + if (variables?.length) { + submitValues.env = variables.join('\0'); + } else { + submitValues.delete = 'env'; + } + + return submitValues; + }, + + items: [ + { + xtype: 'fieldcontainer', + layout: { + type: 'hbox', + align: 'stretch', + }, + defaults: { + padding: '0 4', + }, + items: [ + { + xtype: 'displayfield', + flex: 2, + value: gettext('Name'), + }, + { + xtype: 'box', + html: ' ', + }, + { + xtype: 'displayfield', + flex: 3, + value: gettext('Value'), + }, + ], + }, + { + xtype: 'container', + name: 'variableContainer', + layout: 'anchor', + items: [], + }, + { + xtype: 'fieldcontainer', + layout: { + type: 'hbox', + align: 'start', + }, + items: { + xtype: 'button', + text: gettext('Add Variable'), + handler: function (button, event) { + let variableContainer = button + .up('pveLxcEnvInputPanel') + .down('container[name=variableContainer]'); + + variableContainer.add( + Ext.create({ + xtype: 'pveLxcEnvVariableField', + onRemove: (field) => variableContainer.remove(field), + }), + ); + }, + }, + }, + ], + + setValues: function (values) { + let me = this; + + me.env = values; // TODO: needed? + + let variableContainer = me.down('container[name=variableContainer]'); + + values.env?.split(/\0+/).forEach((value) => { + variableContainer.add( + Ext.create({ + xtype: 'pveLxcEnvVariableField', + onRemove: (field) => variableContainer.remove(field), + value, + }), + ); + }); + }, + + initComponent: function () { + let me = this; + me.mounts = []; // reset state + + me.callParent(); + }, +}); + +Ext.define('PVE.lxc.EnvEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveLxcEnvEdit', + + subject: gettext('Environment'), + autoLoad: true, + width: 720, + + showReset: false, // TODO: fix reset handling for EnvVar inputpanel/fields. + + items: [ + { + xtype: 'pveLxcEnvInputPanel', + }, + ], +}); +Ext.define('PVE.lxc.MountPointInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveLxcMountPointInputPanel', + + onlineHelp: 'pct_container_storage', + + insideWizard: false, + + unused: false, // add unused disk imaged + unprivileged: false, + + vmconfig: {}, // used to select unused disks + + setUnprivileged: function (unprivileged) { + var me = this; + var vm = me.getViewModel(); + me.unprivileged = unprivileged; + vm.set('unpriv', unprivileged); + }, + + onGetValues: function (values) { + var me = this; + + var confid = me.confid || 'mp' + values.mpid; + me.mp.file = me.down('field[name=file]').getValue(); + + if (me.unused) { + confid = 'mp' + values.mpid; + } else if (me.isCreate) { + me.mp.file = values.hdstorage + ':' + values.disksize; + } + + // delete unnecessary fields + delete values.mpid; + delete values.hdstorage; + delete values.disksize; + delete values.diskformat; + + let setMPOpt = (k, src, v) => PVE.Utils.propertyStringSet(me.mp, src, k, v); + + setMPOpt('mp', values.mp); + let mountOpts = (values.mountoptions || []).join(';'); + setMPOpt('mountoptions', values.mountoptions, mountOpts); + setMPOpt('mp', values.mp); + setMPOpt('backup', values.backup); + setMPOpt('quota', values.quota); + setMPOpt('ro', values.ro); + setMPOpt('acl', values.acl); + setMPOpt('replicate', values.replicate); + + let res = {}; + res[confid] = PVE.Parser.printLxcMountPoint(me.mp); + return res; + }, + + setMountPoint: function (mp) { + let me = this; + let vm = me.getViewModel(); + vm.set('mptype', mp.type); + if (mp.mountoptions) { + mp.mountoptions = mp.mountoptions.split(';'); + } + me.mp = mp; + me.filterMountOptions(); + me.setValues(mp); + }, + + filterMountOptions: function () { + let me = this; + if (me.confid === 'rootfs') { + let field = me.down('field[name=mountoptions]'); + let exclude = ['nodev', 'noexec']; + let filtered = field.comboItems.filter((v) => !exclude.includes(v[0])); + field.setComboItems(filtered); + } + }, + + updateVMConfig: function (vmconfig) { + let me = this; + let vm = me.getViewModel(); + me.vmconfig = vmconfig; + vm.set('unpriv', vmconfig.unprivileged); + me.down('field[name=mpid]').validate(); + }, + + setVMConfig: function (vmconfig) { + let me = this; + + me.updateVMConfig(vmconfig); + PVE.Utils.forEachLxcMP((bus, i, name) => { + if (!Ext.isDefined(vmconfig[name])) { + me.down('field[name=mpid]').setValue(i); + return false; + } + return undefined; + }); + }, + + setNodename: function (nodename) { + let me = this; + let vm = me.getViewModel(); + vm.set('node', nodename); + me.down('#diskstorage').setNodename(nodename); + }, + + controller: { + xclass: 'Ext.app.ViewController', + + control: { + 'field[name=mpid]': { + change: function (field, value) { + let _me = this; + let view = this.getView(); + if (view.confid !== 'rootfs') { + view.fireEvent('diskidchange', view, `mp${value}`); + } + field.validate(); + }, + }, + '#hdstorage': { + change: function (field, newValue) { + let me = this; + if (!newValue) { + return; + } + + let rec = field.store.getById(newValue); + if (!rec) { + return; + } + me.getViewModel().set('type', rec.data.type); + }, + }, + }, + init: function (view) { + let _me = this; + let vm = this.getViewModel(); + view.mp = {}; + vm.set('confid', view.confid); + vm.set('unused', view.unused); + vm.set('node', view.nodename); + vm.set('unpriv', view.unprivileged); + vm.set('hideStorSelector', view.unused || !view.isCreate); + + if (view.isCreate) { + // can be array if created from unused disk + vm.set('isIncludedInBackup', true); + if (view.insideWizard) { + view.filterMountOptions(); + } + } + if (view.selectFree) { + view.setVMConfig(view.vmconfig); + } + }, + }, + + viewModel: { + data: { + unpriv: false, + unused: false, + showStorageSelector: false, + mptype: '', + type: '', + confid: '', + node: '', + }, + + formulas: { + quota: function (get) { + return !( + get('type') === 'zfs' || + get('type') === 'zfspool' || + get('unpriv') || + get('isBind') + ); + }, + hasMP: function (get) { + return !!get('confid') && !get('unused'); + }, + isRoot: function (get) { + return get('confid') === 'rootfs'; + }, + isBind: function (get) { + return get('mptype') === 'bind'; + }, + isBindOrRoot: function (get) { + return get('isBind') || get('isRoot'); + }, + }, + }, + + column1: [ + { + xtype: 'proxmoxintegerfield', + name: 'mpid', + fieldLabel: gettext('Mount Point ID'), + minValue: 0, + maxValue: PVE.Utils.lxc_mp_counts.mp - 1, + hidden: true, + allowBlank: false, + disabled: true, + bind: { + hidden: '{hasMP}', + disabled: '{hasMP}', + }, + validator: function (value) { + let view = this.up('inputpanel'); + if (!view.rendered) { + return undefined; + } + if (Ext.isDefined(view.vmconfig['mp' + value])) { + return 'Mount point is already in use.'; + } + return true; + }, + }, + { + xtype: 'pveDiskStorageSelector', + itemId: 'diskstorage', + storageContent: 'rootdir', + hidden: true, + autoSelect: true, + selectformat: false, + defaultSize: 8, + bind: { + hidden: '{hideStorSelector}', + disabled: '{hideStorSelector}', + nodename: '{node}', + }, + }, + { + xtype: 'textfield', + disabled: true, + submitValue: false, + fieldLabel: gettext('Disk image'), + name: 'file', + bind: { + hidden: '{!hideStorSelector}', + }, + }, + ], + + column2: [ + { + xtype: 'textfield', + name: 'mp', + value: '', + emptyText: gettext('/some/path'), + allowBlank: false, + disabled: true, + fieldLabel: gettext('Path'), + bind: { + hidden: '{isRoot}', + disabled: '{isRoot}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'backup', + fieldLabel: gettext('Backup'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Include volume in backup job'), + }, + bind: { + hidden: '{isRoot}', + disabled: '{isBindOrRoot}', + value: '{isIncludedInBackup}', + }, + }, + ], + + advancedColumn1: [ + { + xtype: 'proxmoxcheckbox', + name: 'quota', + defaultValue: 0, + bind: { + disabled: '{!quota}', + }, + fieldLabel: gettext('Enable quota'), + listeners: { + disable: function () { + this.reset(); + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'ro', + defaultValue: 0, + bind: { + hidden: '{isRoot}', + disabled: '{isRoot}', + }, + fieldLabel: gettext('Read-only'), + }, + { + xtype: 'proxmoxKVComboBox', + name: 'mountoptions', + fieldLabel: gettext('Mount options'), + deleteEmpty: false, + comboItems: [ + ['discard', 'discard'], + ['lazytime', 'lazytime'], + ['noatime', 'noatime'], + ['nodev', 'nodev'], + ['noexec', 'noexec'], + ['nosuid', 'nosuid'], + ], + multiSelect: true, + value: [], + allowBlank: true, + }, + ], + + advancedColumn2: [ + { + xtype: 'proxmoxKVComboBox', + name: 'acl', + fieldLabel: 'ACLs', + deleteEmpty: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['1', Proxmox.Utils.enabledText], + ['0', Proxmox.Utils.disabledText], + ], + value: '__default__', + bind: { + disabled: '{isBind}', + }, + allowBlank: true, + }, + { + xtype: 'proxmoxcheckbox', + inputValue: '0', // reverses the logic + name: 'replicate', + fieldLabel: gettext('Skip replication'), + }, + ], +}); + +Ext.define('PVE.lxc.MountPointEdit', { + extend: 'Proxmox.window.Edit', + + unprivileged: false, + + initComponent: function () { + let me = this; + + let nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + let unused = me.confid && me.confid.match(/^unused\d+$/); + + me.isCreate = me.confid ? unused : true; + + let ipanel = Ext.create('PVE.lxc.MountPointInputPanel', { + confid: me.confid, + nodename: nodename, + unused: unused, + unprivileged: me.unprivileged, + isCreate: me.isCreate, + }); + + let subject; + if (unused) { + subject = gettext('Unused Disk'); + } else if (me.isCreate) { + subject = gettext('Mount Point'); + } else { + subject = gettext('Mount Point') + ' (' + me.confid + ')'; + } + + Ext.apply(me, { + subject: subject, + defaultFocus: me.confid !== 'rootfs' ? 'textfield[name=mp]' : 'tool', + items: ipanel, + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + ipanel.setVMConfig(response.result.data); + + if (me.confid) { + let value = response.result.data[me.confid]; + let mp = PVE.Parser.parseLxcMountPoint(value); + if (!mp) { + Ext.Msg.alert(gettext('Error'), 'Unable to parse mount point options'); + me.close(); + return; + } + ipanel.setMountPoint(mp); + me.isValid(); // trigger validation + } + }, + }); + }, +}); +Ext.define('PVE.window.MPResize', { + extend: 'Ext.window.Window', + + resizable: false, + + resize_disk: function (disk, size) { + var me = this; + var params = { disk: disk, size: '+' + size + 'G' }; + + Proxmox.Utils.API2Request({ + params: params, + url: '/nodes/' + me.nodename + '/lxc/' + me.vmid + '/resize', + waitMsgTarget: me, + method: 'PUT', + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, opts) { + var upid = response.result.data; + var win = Ext.create('Proxmox.window.TaskViewer', { upid: upid }); + win.show(); + me.close(); + }, + }); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + var items = [ + { + xtype: 'displayfield', + name: 'disk', + value: me.disk, + fieldLabel: gettext('Disk'), + vtype: 'StorageId', + allowBlank: false, + }, + ]; + + me.hdsizesel = Ext.createWidget('numberfield', { + name: 'size', + minValue: 0, + maxValue: 128 * 1024, + decimalPrecision: 3, + value: '0', + fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`, + allowBlank: false, + }); + + items.push(me.hdsizesel); + + me.formPanel = Ext.create('Ext.form.Panel', { + bodyPadding: 10, + border: false, + fieldDefaults: { + labelWidth: 120, + anchor: '100%', + }, + items: items, + }); + + var form = me.formPanel.getForm(); + + var submitBtn; + + me.title = gettext('Resize disk'); + submitBtn = Ext.create('Ext.Button', { + text: gettext('Resize disk'), + handler: function () { + if (form.isValid()) { + let values = form.getValues(); + me.resize_disk(me.disk, values.size); + } + }, + }); + + Ext.apply(me, { + modal: true, + border: false, + layout: 'fit', + buttons: [submitBtn], + items: [me.formPanel], + }); + + me.callParent(); + }, +}); +Ext.define('PVE.lxc.NetworkInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveLxcNetworkInputPanel', + + insideWizard: false, + + onlineHelp: 'pct_container_network', + + setNodename: function (nodename) { + let me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + me.nodename = nodename; + + let bridgeSelector = me.query('[isFormField][name=bridge]')[0]; + bridgeSelector.setNodename(nodename); + }, + + onGetValues: function (values) { + let me = this; + + let id; + if (me.isCreate) { + id = values.id; + delete values.id; + } else { + id = me.ifname; + } + let newdata = {}; + if (id) { + if (values.ipv6mode !== 'static') { + values.ip6 = values.ipv6mode; + } + if (values.ipv4mode !== 'static') { + values.ip = values.ipv4mode; + } + newdata[id] = PVE.Parser.printLxcNetwork(values); + } + return newdata; + }, + + initComponent: function () { + let me = this; + + let cdata = {}; + if (me.insideWizard) { + me.ifname = 'net0'; + cdata.name = 'eth0'; + me.dataCache = {}; + } + cdata.firewall = me.insideWizard || me.isCreate; + + if (!me.dataCache) { + throw 'no dataCache specified'; + } + + if (!me.isCreate) { + if (!me.ifname) { + throw 'no interface name specified'; + } + if (!me.dataCache[me.ifname]) { + throw "no such interface '" + me.ifname + "'"; + } + cdata = PVE.Parser.parseLxcNetwork(me.dataCache[me.ifname]); + } + + for (let i = 0; i < 32; i++) { + let ifname = 'net' + i.toString(); + if (me.isCreate && !me.dataCache[ifname]) { + me.ifname = ifname; + break; + } + } + + me.column1 = [ + { + xtype: 'hidden', + name: 'id', + value: me.ifname, + }, + { + xtype: 'textfield', + name: 'name', + fieldLabel: gettext('Name'), + emptyText: '(e.g., eth0)', + allowBlank: false, + value: cdata.name, + validator: function (value) { + for (const [key, netRaw] of Object.entries(me.dataCache)) { + if (!key.match(/^net\d+/) || key === me.ifname) { + continue; + } + let net = PVE.Parser.parseLxcNetwork(netRaw); + if (net.name === value) { + return 'interface name already in use'; + } + } + return true; + }, + }, + { + xtype: 'textfield', + name: 'hwaddr', + fieldLabel: gettext('MAC address'), + vtype: 'MacAddress', + value: cdata.hwaddr, + allowBlank: true, + emptyText: 'auto', + }, + { + xtype: 'PVE.form.BridgeSelector', + name: 'bridge', + nodename: me.nodename, + fieldLabel: gettext('Bridge'), + value: cdata.bridge, + allowBlank: false, + }, + { + xtype: 'pveVlanField', + name: 'tag', + value: cdata.tag, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Firewall'), + name: 'firewall', + value: cdata.firewall, + }, + ]; + + let dhcp4 = cdata.ip === 'dhcp'; + if (dhcp4) { + cdata.ip = ''; + cdata.gw = ''; + } + + let auto6 = cdata.ip6 === 'auto'; + let dhcp6 = cdata.ip6 === 'dhcp'; + if (auto6 || dhcp6) { + cdata.ip6 = ''; + cdata.gw6 = ''; + } + + me.column2 = [ + { + layout: { + type: 'hbox', + align: 'middle', + }, + border: false, + margin: '0 0 5 0', + items: [ + { + xtype: 'label', + text: 'IPv4:', // do not localize + }, + { + xtype: 'radiofield', + boxLabel: gettext('Static'), + name: 'ipv4mode', + inputValue: 'static', + checked: !dhcp4, + margin: '0 0 0 10', + listeners: { + change: function (cb, value) { + me.down('field[name=ip]').setEmptyText( + value ? Proxmox.Utils.NoneText : '', + ); + me.down('field[name=ip]').setDisabled(!value); + me.down('field[name=gw]').setDisabled(!value); + }, + }, + }, + { + xtype: 'radiofield', + boxLabel: 'DHCP', // do not localize + name: 'ipv4mode', + inputValue: 'dhcp', + checked: dhcp4, + margin: '0 0 0 10', + }, + ], + }, + { + xtype: 'textfield', + name: 'ip', + vtype: 'IPCIDRAddress', + value: cdata.ip, + emptyText: dhcp4 ? '' : Proxmox.Utils.NoneText, + disabled: dhcp4, + fieldLabel: 'IPv4/CIDR', // do not localize + }, + { + xtype: 'textfield', + name: 'gw', + value: cdata.gw, + vtype: 'IPAddress', + disabled: dhcp4, + fieldLabel: gettext('Gateway') + ' (IPv4)', + margin: '0 0 3 0', // override bottom margin to account for the menuseparator + }, + { + xtype: 'menuseparator', + height: '3', + margin: '0', + }, + { + layout: { + type: 'hbox', + align: 'middle', + }, + border: false, + margin: '0 0 5 0', + items: [ + { + xtype: 'label', + text: 'IPv6:', // do not localize + }, + { + xtype: 'radiofield', + boxLabel: gettext('Static'), + name: 'ipv6mode', + inputValue: 'static', + checked: !(auto6 || dhcp6), + margin: '0 0 0 10', + listeners: { + change: function (cb, value) { + me.down('field[name=ip6]').setEmptyText( + value ? Proxmox.Utils.NoneText : '', + ); + me.down('field[name=ip6]').setDisabled(!value); + me.down('field[name=gw6]').setDisabled(!value); + }, + }, + }, + { + xtype: 'radiofield', + boxLabel: 'DHCP', // do not localize + name: 'ipv6mode', + inputValue: 'dhcp', + checked: dhcp6, + margin: '0 0 0 10', + }, + { + xtype: 'radiofield', + boxLabel: 'SLAAC', // do not localize + name: 'ipv6mode', + inputValue: 'auto', + checked: auto6, + margin: '0 0 0 10', + }, + ], + }, + { + xtype: 'textfield', + name: 'ip6', + value: cdata.ip6, + emptyText: dhcp6 || auto6 ? '' : Proxmox.Utils.NoneText, + vtype: 'IP6CIDRAddress', + disabled: dhcp6 || auto6, + fieldLabel: 'IPv6/CIDR', // do not localize + }, + { + xtype: 'textfield', + name: 'gw6', + vtype: 'IP6Address', + value: cdata.gw6, + disabled: dhcp6 || auto6, + fieldLabel: gettext('Gateway') + ' (IPv6)', + }, + ]; + + me.advancedColumn1 = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Disconnect'), + name: 'link_down', + value: cdata.link_down, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: 'MTU', + emptyText: gettext('Same as bridge'), + name: 'mtu', + value: cdata.mtu, + minValue: 576, + maxValue: 65535, + }, + ]; + + me.advancedColumn2 = [ + { + xtype: 'numberfield', + name: 'rate', + fieldLabel: gettext('Rate limit') + ' (MB/s)', + minValue: 0, + maxValue: 10 * 1024, + value: cdata.rate, + emptyText: 'unlimited', + allowBlank: true, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Host-Managed'), + name: 'host-managed', + value: cdata['host-managed'], + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.lxc.NetworkEdit', { + extend: 'Proxmox.window.Edit', + + isAdd: true, + + initComponent: function () { + let me = this; + + if (!me.dataCache) { + throw 'no dataCache specified'; + } + if (!me.nodename) { + throw 'no node name specified'; + } + + Ext.apply(me, { + subject: gettext('Network Device') + ' (veth)', + digest: me.dataCache.digest, + items: [ + { + xtype: 'pveLxcNetworkInputPanel', + ifname: me.ifname, + nodename: me.nodename, + dataCache: me.dataCache, + isCreate: me.isCreate, + }, + ], + }); + + me.callParent(); + }, +}); + +Ext.define( + 'PVE.lxc.NetworkView', + { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveLxcNetworkView', + + onlineHelp: 'pct_container_network', + + dataCache: {}, // used to store result of last load + + stateful: true, + stateId: 'grid-lxc-network', + + load: async function () { + let me = this; + + Proxmox.Utils.setErrorMask(me, true); + + let nodename = me.pveSelNode.data.node; + let vmid = me.pveSelNode.data.vmid; + + try { + let ifResponse = await Proxmox.Async.api2({ + url: `/nodes/${nodename}/lxc/${vmid}/interfaces`, + method: 'GET', + }); + let confResponse = await Proxmox.Async.api2({ + url: me.url, + }); + Proxmox.Utils.setErrorMask(me, false); + + let interfaces = []; + for (const [, iface] of Object.entries(ifResponse?.result?.data || {})) { + interfaces[iface['hardware-address']] = iface; + } + + let records = []; + me.dataCache = confResponse.result.data || {}; + for (const [key, value] of Object.entries(confResponse.result.data)) { + if (!key.match(/^net\d+/)) { + continue; + } + let config = PVE.Parser.parseLxcNetwork(value); + let net = structuredClone(config); + net.id = key; + + let iface = interfaces[config.hwaddr.toLowerCase()]; + if (iface) { + net.name = iface.name; + net.ip = []; + net.ip6 = []; + for (const i of iface['ip-addresses']) { + let ip_with_prefix = `${i['ip-address']}/${i.prefix}`; + if (i['ip-address-type'] === 'inet') { + if (config.ip === ip_with_prefix) { + net.ip.push(`${ip_with_prefix} (static)`); + } else { + // this could be dhcp, but also a static address set directly on the container + net.ip.push(`${ip_with_prefix} (dynamic)`); + } + } else if (i['ip-address-type'] === 'inet6') { + if (config.ip6 === ip_with_prefix) { + net.ip6.push(`${ip_with_prefix} (static)`); + } else { + // this could be dhcp, slaac, but also a static address set directly on the container + net.ip6.push(`${ip_with_prefix} (dynamic)`); + } + } + } + } + records.push(net); + } + + me.store.loadData(records); + me.down('button[name=addButton]').setDisabled(records.length >= 32); + } catch (error) { + Proxmox.Utils.setErrorMask(me, gettext('Error') + ': ' + error); + } + }, + + initComponent: function () { + let me = this; + + let nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + let vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + let caps = Ext.state.Manager.get('GuiCap'); + + me.url = `/nodes/${nodename}/lxc/${vmid}/config`; + + let store = new Ext.data.Store({ + model: 'pve-lxc-network', + sorters: [ + { + property: 'id', + direction: 'ASC', + }, + ], + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec || !caps.vms['VM.Config.Network']) { + return false; // disable default-propagation when triggered by grid dblclick + } + Ext.create('PVE.lxc.NetworkEdit', { + url: me.url, + nodename: nodename, + dataCache: me.dataCache, + ifname: rec.data.id, + listeners: { + destroy: () => me.load(), + }, + autoShow: true, + }); + return undefined; + }; + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + { + text: gettext('Add'), + name: 'addButton', + disabled: !caps.vms['VM.Config.Network'], + handler: function () { + Ext.create('PVE.lxc.NetworkEdit', { + url: me.url, + nodename: nodename, + isCreate: true, + dataCache: me.dataCache, + listeners: { + destroy: () => me.load(), + }, + autoShow: true, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Remove'), + disabled: true, + selModel: sm, + enableFn: function (rec) { + return !!caps.vms['VM.Config.Network']; + }, + confirmMsg: ({ data }) => + Ext.String.format( + gettext('Are you sure you want to remove entry {0}'), + `'${data.id}'`, + ), + handler: function (btn, e, rec) { + Proxmox.Utils.API2Request({ + url: me.url, + waitMsgTarget: me, + method: 'PUT', + params: { + delete: rec.data.id, + digest: me.dataCache.digest, + }, + callback: () => me.load(), + failure: (response, opts) => + Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + selModel: sm, + disabled: true, + enableFn: (rec) => !!caps.vms['VM.Config.Network'], + handler: run_editor, + }, + ], + columns: [ + { + header: 'ID', + width: 50, + dataIndex: 'id', + }, + { + header: gettext('Name'), + width: 80, + dataIndex: 'name', + }, + { + header: gettext('Bridge'), + width: 80, + dataIndex: 'bridge', + }, + { + header: gettext('Firewall'), + width: 80, + dataIndex: 'firewall', + renderer: Proxmox.Utils.format_boolean, + }, + { + header: gettext('VLAN Tag'), + width: 70, + dataIndex: 'tag', + }, + { + header: gettext('MAC address'), + width: 110, + dataIndex: 'hwaddr', + }, + { + header: gettext('IP address'), + width: 300, + dataIndex: 'ip', + renderer: function (_value, _metaData, rec) { + const formatIpValue = (value, prefix) => { + if (Array.isArray(value) && value.length > 0) { + // multiple addresses (usually from the api) + return value.join('
    ') + '
    '; + } else if (typeof value === 'string') { + if (value === 'dhcp') { + // ipv4 and ipv6 dhcp + return `${prefix}dhcp
    `; + } else if (value === 'auto') { + // ipv6 slaac + return `${prefix}auto
    `; + } else if (value.length > 0) { + // single address (usually from config) + return value + '
    '; + } + } + return ''; + }; + + return ( + formatIpValue(rec.data.ip, 'ip: ') + + formatIpValue(rec.data.ip6, 'ip6: ') + ); + }, + }, + { + header: gettext('Gateway'), + width: 150, + dataIndex: 'gw', + renderer: function (value, metaData, rec) { + if (rec.data.gw && rec.data.gw6) { + return rec.data.gw + '
    ' + rec.data.gw6; + } else if (rec.data.gw6) { + return rec.data.gw6; + } else { + return rec.data.gw; + } + }, + }, + { + header: gettext('MTU'), + width: 80, + dataIndex: 'mtu', + }, + { + header: gettext('Disconnected'), + width: 100, + dataIndex: 'link_down', + renderer: Proxmox.Utils.format_boolean, + }, + ], + listeners: { + activate: me.load, + itemdblclick: run_editor, + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-lxc-network', { + extend: 'Ext.data.Model', + proxy: { type: 'memory' }, + fields: [ + 'id', + 'name', + 'hwaddr', + 'bridge', + 'ip', + 'gw', + 'ip6', + 'gw6', + 'tag', + 'firewall', + 'mtu', + 'link_down', + ], + }); + }, +); +Ext.define('PVE.lxc.Options', { + extend: 'Proxmox.grid.PendingObjectGrid', + alias: ['widget.pveLxcOptions'], + + onlineHelp: 'pct_options', + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var caps = Ext.state.Manager.get('GuiCap'); + + var rows = { + onboot: { + header: gettext('Start at boot'), + defaultValue: '', + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Start at boot'), + items: { + xtype: 'proxmoxcheckbox', + name: 'onboot', + uncheckedValue: 0, + defaultValue: 0, + fieldLabel: gettext('Start at boot'), + }, + } + : undefined, + }, + startup: { + header: gettext('Start/Shutdown order'), + defaultValue: '', + renderer: PVE.Utils.render_kvm_startup, + editor: + caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify'] + ? { + xtype: 'pveWindowStartupEdit', + onlineHelp: 'pct_startup_and_shutdown', + } + : undefined, + }, + ostype: { + header: gettext('OS Type'), + defaultValue: Proxmox.Utils.unknownText, + }, + arch: { + header: gettext('Architecture'), + defaultValue: Proxmox.Utils.unknownText, + }, + console: { + header: '/dev/console', + defaultValue: 1, + renderer: Proxmox.Utils.format_enabled_toggle, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: '/dev/console', + items: { + xtype: 'proxmoxcheckbox', + name: 'console', + uncheckedValue: 0, + defaultValue: 1, + deleteDefaultValue: true, + checked: true, + fieldLabel: '/dev/console', + }, + } + : undefined, + }, + tty: { + header: gettext('TTY count'), + defaultValue: 2, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('TTY count'), + items: { + xtype: 'proxmoxintegerfield', + name: 'tty', + minValue: 0, + maxValue: 6, + fieldLabel: gettext('TTY count'), + emptyText: gettext('Default'), + deleteEmpty: true, + }, + } + : undefined, + }, + cmode: { + header: gettext('Console mode'), + defaultValue: 'tty', + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Console mode'), + items: { + xtype: 'proxmoxKVComboBox', + name: 'cmode', + deleteEmpty: true, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (tty)'], + ['tty', '/dev/tty[X]'], + ['console', '/dev/console'], + ['shell', 'shell'], + ], + fieldLabel: gettext('Console mode'), + }, + } + : undefined, + }, + protection: { + header: gettext('Protection'), + defaultValue: false, + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Protection'), + items: { + xtype: 'proxmoxcheckbox', + name: 'protection', + uncheckedValue: 0, + defaultValue: 0, + deleteDefaultValue: true, + fieldLabel: gettext('Enabled'), + }, + } + : undefined, + }, + unprivileged: { + header: gettext('Unprivileged container'), + renderer: Proxmox.Utils.format_boolean, + defaultValue: 0, + }, + features: { + header: gettext('Features'), + defaultValue: Proxmox.Utils.noneText, + editor: 'PVE.lxc.FeaturesEdit', + }, + hookscript: { + header: gettext('Hookscript'), + renderer: Ext.htmlEncode, + }, + entrypoint: { + header: gettext('Entrypoint'), + defaultValue: '/sbin/init', + renderer: Ext.htmlEncode, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Entrypoint Init Command'), + defaultFocus: undefined, + items: [ + { + xtype: 'proxmoxtextfield', + name: 'entrypoint', + deleteEmpty: true, + emptyText: '/sbin/init', + }, + + { + xtype: 'displayfield', + reference: 'emptyWarning', + userCls: 'pmx-hint', + value: gettext( + 'Changing the entrypoint command can lead to start failure!', + ), + }, + ], + } + : undefined, + }, + env: { + header: gettext('Environment'), + renderer: (v) => (v ? Ext.htmlEncode(v.replaceAll(/\0+/g, ' ')) : null), + defaultValue: Proxmox.Utils.noneText, + editor: 'PVE.lxc.EnvEdit', + }, + }; + + var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config'; + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + enableFn: function (rec) { + var rowdef = rows[rec.data.key]; + return !!rowdef.editor; + }, + handler: function () { + me.run_editor(); + }, + }); + + var revert_btn = new PVE.button.PendingRevert(); + + var set_button_status = function () { + let button_sm = me.getSelectionModel(); + let rec = button_sm.getSelection()[0]; + + if (!rec) { + edit_btn.disable(); + return; + } + + var key = rec.data.key; + var pending = rec.data.delete || me.hasPendingChanges(key); + var rowdef = rows[key]; + + if (key === 'features') { + let unprivileged = me.getStore().getById('unprivileged').data.value; + let root = Proxmox.UserName === 'root@pam'; + let vmalloc = caps.vms['VM.Allocate']; + edit_btn.setDisabled(!(root || (vmalloc && unprivileged))); + } else { + edit_btn.setDisabled(!rowdef.editor); + } + + revert_btn.setDisabled(!pending); + }; + + Ext.apply(me, { + url: '/api2/json/nodes/' + nodename + '/lxc/' + vmid + '/pending', + selModel: sm, + interval: 5000, + tbar: [edit_btn, revert_btn], + rows: rows, + editorConfig: { + url: '/api2/extjs/' + baseurl, + }, + listeners: { + itemdblclick: me.run_editor, + selectionchange: set_button_status, + }, + }); + + me.callParent(); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + me.on('deactivate', me.rstore.stopUpdate); + + me.mon(me.getStore(), 'datachanged', function () { + set_button_status(); + }); + }, +}); +var labelWidth = 120; + +Ext.define('PVE.lxc.MemoryEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + Ext.apply(me, { + subject: gettext('Memory'), + items: Ext.create('PVE.lxc.MemoryInputPanel'), + }); + + me.callParent(); + + me.load(); + }, +}); + +Ext.define('PVE.lxc.CPUEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveLxcCPUEdit', + + viewModel: { + data: { + cgroupMode: 2, + }, + }, + + initComponent: function () { + let me = this; + me.getViewModel().set('cgroupMode', me.cgroupMode); + + Ext.apply(me, { + subject: gettext('CPU'), + items: Ext.create('PVE.lxc.CPUInputPanel'), + }); + + me.callParent(); + + me.load(); + }, +}); + +// The view model of the parent should contain a 'cgroupMode' variable (or params for v2 are used). +Ext.define('PVE.lxc.CPUInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveLxcCPUInputPanel', + + onlineHelp: 'pct_cpu', + + insideWizard: false, + + viewModel: { + formulas: { + cpuunitsDefault: (get) => (get('cgroupMode') === 1 ? 1024 : 100), + cpuunitsMax: (get) => (get('cgroupMode') === 1 ? 500000 : 10000), + }, + }, + + onGetValues: function (values) { + let me = this; + let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault'); + + PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard); + PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard); + + return values; + }, + + advancedColumn1: [ + { + xtype: 'numberfield', + name: 'cpulimit', + minValue: 0, + value: '', + step: 1, + fieldLabel: gettext('CPU limit'), + allowBlank: true, + emptyText: gettext('unlimited'), + }, + ], + + advancedColumn2: [ + { + xtype: 'proxmoxintegerfield', + name: 'cpuunits', + fieldLabel: gettext('CPU units'), + value: '', + minValue: 8, + maxValue: '10000', + emptyText: '100', + bind: { + emptyText: '{cpuunitsDefault}', + maxValue: '{cpuunitsMax}', + }, + labelWidth: labelWidth, + deleteEmpty: true, + allowBlank: true, + }, + ], + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: 'proxmoxintegerfield', + name: 'cores', + minValue: 1, + maxValue: 8192, + value: me.insideWizard ? 1 : '', + fieldLabel: gettext('Cores'), + allowBlank: true, + deleteEmpty: true, + emptyText: gettext('unlimited'), + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.lxc.MemoryInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveLxcMemoryInputPanel', + + onlineHelp: 'pct_memory', + + insideWizard: false, + + initComponent: function () { + var me = this; + + var items = [ + { + xtype: 'proxmoxintegerfield', + name: 'memory', + minValue: 16, + value: '512', + step: 32, + fieldLabel: gettext('Memory') + ' (MiB)', + labelWidth: labelWidth, + allowBlank: false, + }, + { + xtype: 'proxmoxintegerfield', + name: 'swap', + minValue: 0, + value: '512', + step: 32, + fieldLabel: gettext('Swap') + ' (MiB)', + labelWidth: labelWidth, + allowBlank: false, + }, + ]; + + if (me.insideWizard) { + me.column1 = items; + } else { + me.items = items; + } + + me.callParent(); + }, +}); +Ext.define('PVE.lxc.RessourceView', { + extend: 'Proxmox.grid.PendingObjectGrid', + alias: ['widget.pveLxcRessourceView'], + + onlineHelp: 'pct_configuration', + + renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { + let me = this; + let rowdef = me.rows[key] || {}; + + let txt = rowdef.header || key; + let icon = ''; + + metaData.tdAttr = 'valign=middle'; + if (rowdef.tdCls) { + metaData.tdCls = rowdef.tdCls; + } else if (rowdef.iconCls) { + icon = ``; + metaData.tdCls += ' pve-itype-fa'; + } + // only return icons in grid but not remove dialog + if (rowIndex !== undefined) { + return icon + txt; + } else { + return txt; + } + }, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var caps = Ext.state.Manager.get('GuiCap'); + var diskCap = caps.vms['VM.Config.Disk']; + + var mpeditor = caps.vms['VM.Config.Disk'] ? 'PVE.lxc.MountPointEdit' : undefined; + + const nodeInfo = PVE.data.ResourceStore.getNodes().find((node) => node.node === nodename); + let cpuEditor = { + xtype: 'pveLxcCPUEdit', + cgroupMode: nodeInfo['cgroup-mode'], + }; + + var rows = { + memory: { + header: gettext('Memory'), + editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined, + defaultValue: 512, + tdCls: 'pmx-itype-icon-memory', + group: 1, + renderer: function (value) { + return Proxmox.Utils.format_size(value * 1024 * 1024); + }, + }, + swap: { + header: gettext('Swap'), + editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined, + defaultValue: 512, + iconCls: 'refresh', + group: 2, + renderer: function (value) { + return Proxmox.Utils.format_size(value * 1024 * 1024); + }, + }, + cores: { + header: gettext('Cores'), + editor: caps.vms['VM.Config.CPU'] ? cpuEditor : undefined, + defaultValue: '', + tdCls: 'pmx-itype-icon-processor', + group: 3, + renderer: function (value) { + var cpulimit = me.getObjectValue('cpulimit'); + var cpuunits = me.getObjectValue('cpuunits'); + var res; + if (value) { + res = value; + } else { + res = gettext('unlimited'); + } + + if (cpulimit) { + res += ' [cpulimit=' + cpulimit + ']'; + } + + if (cpuunits) { + res += ' [cpuunits=' + cpuunits + ']'; + } + return res; + }, + }, + rootfs: { + header: gettext('Root Disk'), + defaultValue: Proxmox.Utils.noneText, + editor: mpeditor, + iconCls: 'hdd-o', + group: 4, + renderer: Ext.htmlEncode, + }, + cpulimit: { + visible: false, + }, + cpuunits: { + visible: false, + }, + unprivileged: { + visible: false, + }, + }; + + PVE.Utils.forEachLxcMP(function (bus, i, confid) { + var group = 5; + var header; + if (bus === 'mp') { + header = gettext('Mount Point') + ' (' + confid + ')'; + } else { + header = gettext('Unused Disk') + ' ' + i; + group += 1; + } + rows[confid] = { + group: group, + order: i, + iconCls: 'hdd-o', + editor: mpeditor, + header: header, + renderer: Ext.htmlEncode, + }; + }, true); + + let deveditor = Proxmox.UserName === 'root@pam' ? 'PVE.lxc.DeviceEdit' : undefined; + + PVE.Utils.forEachLxcDev(function (i, confid) { + rows[confid] = { + group: 7, + order: i, + tdCls: 'pve-itype-icon-pci', + editor: deveditor, + header: gettext('Device') + ' (' + confid + ')', + renderer: Ext.htmlEncode, + }; + }); + + var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config'; + + me.selModel = Ext.create('Ext.selection.RowModel', {}); + + var run_resize = function () { + var rec = me.selModel.getSelection()[0]; + if (!rec) { + return; + } + + var win = Ext.create('PVE.window.MPResize', { + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + }); + + win.show(); + }; + + var run_remove = function (b, e, rec) { + Proxmox.Utils.API2Request({ + url: '/api2/extjs/' + baseurl, + waitMsgTarget: me, + method: 'PUT', + params: { + delete: rec.data.key, + }, + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + }); + }; + + let run_move = function () { + let rec = me.selModel.getSelection()[0]; + if (!rec) { + return; + } + + var win = Ext.create('PVE.window.HDMove', { + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + type: 'lxc', + }); + + win.show(); + + win.on('destroy', me.reload, me); + }; + + let run_reassign = function () { + let rec = me.selModel.getSelection()[0]; + if (!rec) { + return; + } + + Ext.create('PVE.window.GuestDiskReassign', { + disk: rec.data.key, + nodename: nodename, + autoShow: true, + vmid: vmid, + type: 'lxc', + listeners: { + destroy: () => me.reload(), + }, + }); + }; + + var edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + selModel: me.selModel, + disabled: true, + enableFn: function (rec) { + if (!rec) { + return false; + } + var rowdef = rows[rec.data.key]; + return !!rowdef.editor; + }, + handler: function () { + me.run_editor(); + }, + }); + + var remove_btn = new Proxmox.button.Button({ + text: gettext('Remove'), + defaultText: gettext('Remove'), + altText: gettext('Detach'), + selModel: me.selModel, + disabled: true, + dangerous: true, + confirmMsg: function (rec) { + let warn = Ext.String.format(gettext('Are you sure you want to remove entry {0}')); + if (this.text === this.altText) { + warn = gettext('Are you sure you want to detach entry {0}'); + } + let rendered = me.renderKey(rec.data.key, {}, rec); + let msg = Ext.String.format(warn, `'${rendered}'`); + + if (rec.data.key.match(/^unused\d+$/)) { + msg += ' ' + gettext('This will permanently erase all data.'); + } + return msg; + }, + handler: run_remove, + listeners: { + render: function (btn) { + // hack: calculate the max button width on first display to prevent the whole + // toolbar to move when we switch between the "Remove" and "Detach" labels + let def = btn.getSize().width; + + btn.setText(btn.altText); + let alt = btn.getSize().width; + + btn.setText(btn.defaultText); + + let optimal = alt > def ? alt : def; + btn.setSize({ width: optimal }); + }, + }, + }); + + let move_menuitem = new Ext.menu.Item({ + text: gettext('Move Storage'), + tooltip: gettext('Move volume to another storage'), + iconCls: 'fa fa-database', + selModel: me.selModel, + handler: run_move, + }); + + let reassign_menuitem = new Ext.menu.Item({ + text: gettext('Reassign Owner'), + tooltip: gettext('Reassign volume to another CT'), + iconCls: 'fa fa-cube', + handler: run_reassign, + reference: 'reassing_item', + }); + + let resize_menuitem = new Ext.menu.Item({ + text: gettext('Resize'), + iconCls: 'fa fa-plus', + selModel: me.selModel, + handler: run_resize, + }); + + let volumeaction_btn = new Proxmox.button.Button({ + text: gettext('Volume Action'), + disabled: true, + menu: { + items: [move_menuitem, reassign_menuitem, resize_menuitem], + }, + }); + + let revert_btn = new PVE.button.PendingRevert(); + + let set_button_status = function () { + let rec = me.selModel.getSelection()[0]; + + if (!rec) { + edit_btn.disable(); + remove_btn.disable(); + volumeaction_btn.disable(); + revert_btn.disable(); + return; + } + let { key, value, delete: isDelete } = rec.data; + let rowdef = rows[key]; + + let pending = isDelete || me.hasPendingChanges(key); + let isRootFS = key === 'rootfs'; + let isDisk = isRootFS || key.match(/^(mp|unused)\d+/); + let isUnusedDisk = key.match(/^unused\d+/); + let isUsedDisk = isDisk && !isUnusedDisk; + let isDevice = key.match(/^dev\d+/); + + let noedit = isDelete || !rowdef.editor; + if (!noedit && Proxmox.UserName !== 'root@pam' && key.match(/^mp\d+$/)) { + let mp = PVE.Parser.parseLxcMountPoint(value); + if (mp.type !== 'volume') { + noedit = true; + } + } + edit_btn.setDisabled(noedit); + + volumeaction_btn.setDisabled(!isDisk || !diskCap); + move_menuitem.setDisabled(isUnusedDisk); + reassign_menuitem.setDisabled(isRootFS); + resize_menuitem.setDisabled(isUnusedDisk); + + remove_btn.setDisabled(!(isDisk || isDevice) || isRootFS || !diskCap || pending); + revert_btn.setDisabled(!pending); + + remove_btn.setText(isUsedDisk ? remove_btn.altText : remove_btn.defaultText); + }; + + let sorterFn = function (rec1, rec2) { + let v1 = rec1.data.key, + v2 = rec2.data.key; + + let g1 = rows[v1].group || 0, + g2 = rows[v2].group || 0; + if (g1 - g2 !== 0) { + return g1 - g2; + } + + let order1 = rows[v1].order || 0, + order2 = rows[v2].order || 0; + if (order1 - order2 !== 0) { + return order1 - order2; + } + + if (v1 > v2) { + return 1; + } else if (v1 < v2) { + return -1; + } else { + return 0; + } + }; + + Ext.apply(me, { + url: `/api2/json/nodes/${nodename}/lxc/${vmid}/pending`, + selModel: me.selModel, + interval: 2000, + cwidth1: 170, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + items: [ + { + text: gettext('Mount Point'), + iconCls: 'fa fa-fw fa-hdd-o black', + disabled: !caps.vms['VM.Config.Disk'], + handler: function () { + Ext.create('PVE.lxc.MountPointEdit', { + autoShow: true, + url: `/api2/extjs/${baseurl}`, + unprivileged: me.getObjectValue('unprivileged'), + pveSelNode: me.pveSelNode, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }, + { + text: gettext('Device Passthrough'), + iconCls: 'pve-itype-icon-pci', + disabled: Proxmox.UserName !== 'root@pam', + handler: function () { + Ext.create('PVE.lxc.DeviceEdit', { + autoShow: true, + url: `/api2/extjs/${baseurl}`, + pveSelNode: me.pveSelNode, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }, + ], + }), + }, + edit_btn, + remove_btn, + volumeaction_btn, + revert_btn, + ], + rows: rows, + sorterFn: sorterFn, + editorConfig: { + pveSelNode: me.pveSelNode, + url: '/api2/extjs/' + baseurl, + }, + listeners: { + itemdblclick: me.run_editor, + selectionchange: set_button_status, + }, + }); + + me.callParent(); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + me.on('deactivate', me.rstore.stopUpdate); + + me.mon(me.getStore(), 'datachanged', function () { + set_button_status(); + }); + + Ext.apply(me.editorConfig, { unprivileged: me.getObjectValue('unprivileged') }); + }, +}); +Ext.define('PVE.lxc.MultiMPPanel', { + extend: 'PVE.panel.MultiDiskPanel', + alias: 'widget.pveMultiMPPanel', + + onlineHelp: 'pct_container_storage', + + controller: { + xclass: 'Ext.app.ViewController', + + // count of mps + rootfs + maxCount: PVE.Utils.lxc_mp_counts.mp + 1, + + getNextFreeDisk: function (vmconfig) { + let nextFreeDisk; + if (!vmconfig.rootfs) { + return { + confid: 'rootfs', + }; + } else { + for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) { + let confid = `mp${i}`; + if (!vmconfig[confid]) { + nextFreeDisk = { + confid, + }; + break; + } + } + } + return nextFreeDisk; + }, + + addPanel: function (itemId, vmconfig, nextFreeDisk) { + let me = this; + return me.getView().add({ + vmconfig, + border: false, + showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'), + xtype: 'pveLxcMountPointInputPanel', + confid: nextFreeDisk.confid === 'rootfs' ? 'rootfs' : null, + bind: { + nodename: '{nodename}', + unprivileged: '{unprivileged}', + }, + padding: '0 5 0 10', + itemId, + selectFree: true, + isCreate: true, + insideWizard: true, + }); + }, + + getBaseVMConfig: function () { + let me = this; + + return { + unprivileged: me.getViewModel().get('unprivileged'), + }; + }, + + diskSorter: { + sorterFn: function (rec1, rec2) { + if (rec1.data.name === 'rootfs') { + return -1; + } else if (rec2.data.name === 'rootfs') { + return 1; + } + + let mp_match = /^mp(\d+)$/; + let [, id1] = mp_match.exec(rec1.data.name); + let [, id2] = mp_match.exec(rec2.data.name); + + return parseInt(id1, 10) - parseInt(id2, 10); + }, + }, + + deleteDisabled: (view, rI, cI, item, rec) => rec.data.name === 'rootfs', + }, +}); +Ext.define('PVE.menu.Item', { + extend: 'Ext.menu.Item', + alias: 'widget.pveMenuItem', + + // set to wrap the handler callback in a confirm dialog showing this text + confirmMsg: false, + + // set to focus 'No' instead of 'Yes' button and show a warning symbol + dangerous: false, + + initComponent: function () { + let me = this; + if (me.handler) { + me.setHandler(me.handler, me.scope); + } + me.callParent(); + }, + + setHandler: function (fn, scope) { + let me = this; + me.scope = scope; + me.handler = function (button, e) { + if (me.confirmMsg) { + Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1; + Ext.Msg.show({ + title: gettext('Confirm'), + icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION, + msg: me.confirmMsg, + buttons: Ext.Msg.YESNO, + defaultFocus: me.dangerous ? 'no' : 'yes', + callback: function (btn) { + if (btn === 'yes') { + Ext.callback(fn, me.scope, [me, e], 0, me); + } + }, + }); + } else { + Ext.callback(fn, me.scope, [me, e], 0, me); + } + }; + }, +}); +Ext.define('PVE.menu.TemplateMenu', { + extend: 'Ext.menu.Menu', + + initComponent: function () { + let me = this; + + let info = me.pveSelNode.data; + if (!info.node) { + throw 'no node name specified'; + } + if (!info.vmid) { + throw 'no VM ID specified'; + } + + let guestType = me.pveSelNode.data.type; + if (guestType !== 'qemu' && guestType !== 'lxc') { + throw `invalid guest type ${guestType}`; + } + + let template = me.pveSelNode.data.template; + + me.title = (guestType === 'qemu' ? 'VM ' : 'CT ') + info.vmid; + + let caps = Ext.state.Manager.get('GuiCap'); + let standaloneNode = PVE.Utils.isStandaloneNode(); + + me.items = [ + { + text: gettext('Migrate'), + iconCls: 'fa fa-fw fa-send-o', + hidden: standaloneNode || !caps.vms['VM.Migrate'], + handler: function () { + Ext.create('PVE.window.Migrate', { + vmtype: guestType, + nodename: info.node, + vmid: info.vmid, + autoShow: true, + }); + }, + }, + { + text: gettext('Clone'), + iconCls: 'fa fa-fw fa-clone', + hidden: !caps.vms['VM.Clone'], + handler: function () { + Ext.create('PVE.window.Clone', { + nodename: info.node, + guestType: guestType, + vmid: info.vmid, + isTemplate: template, + autoShow: true, + }); + }, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.ceph.CephInstallWizardInfo', { + extend: 'Ext.panel.Panel', + xtype: 'pveCephInstallWizardInfo', + + html: `

    Ceph?

    +

    "Ceph is a unified, + distributed storage system, designed for excellent performance, reliability, + and scalability."

    +

    + Ceph is currently not installed on this node. This wizard + will guide you through the installation. Click on the next button below + to begin. After the initial installation, the wizard will offer to create + an initial configuration. This configuration step is only + needed once per cluster and will be skipped if a config is already present. +

    +

    + Before starting the installation, please take a look at our documentation, + by clicking the help button below. If you want to gain deeper knowledge about + Ceph, visit ceph.com. +

    `, +}); + +Ext.define('PVE.ceph.CephVersionSelector', { + extend: 'Ext.form.field.ComboBox', + xtype: 'pveCephVersionSelector', + + fieldLabel: gettext('Ceph version to install'), + + displayField: 'display', + valueField: 'release', + + queryMode: 'local', + editable: false, + forceSelection: true, + + store: { + fields: [ + 'release', + 'version', + { + name: 'display', + calculate: (d) => `${d.release} (${d.version})`, + }, + ], + proxy: { + type: 'memory', + reader: { + type: 'json', + }, + }, + data: [{ release: 'squid', version: '19.2' }], + }, +}); + +Ext.define('PVE.ceph.CephHighestVersionDisplay', { + extend: 'Ext.form.field.Display', + xtype: 'pveCephHighestVersionDisplay', + + fieldLabel: gettext('Ceph in the cluster'), + + value: 'unknown', + + // called on success with (release, versionTxt, versionParts) + gotNewestVersion: Ext.emptyFn, + + initComponent: function () { + let me = this; + + me.callParent(arguments); + + Proxmox.Utils.API2Request({ + method: 'GET', + url: '/cluster/ceph/metadata', + params: { + scope: 'versions', + }, + waitMsgTarget: me, + success: (response) => { + let res = response.result; + if (!res || !res.data || !res.data.node) { + me.setValue(gettext('Could not detect a ceph installation in the cluster')); + return; + } + let nodes = res.data.node; + if (me.nodename) { + // can happen on ceph purge, we do not yet cleanup old version data + delete nodes[me.nodename]; + } + + let maxversion = []; + let maxversiontext = ''; + for (const [_nodename, data] of Object.entries(nodes)) { + let version = data.version.parts; + if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) { + maxversion = version; + maxversiontext = data.version.str; + } + } + // FIXME: get from version selector store + const major2release = { + 13: 'luminous', + 14: 'nautilus', + 15: 'octopus', + 16: 'pacific', + 17: 'quincy', + 18: 'reef', + 19: 'squid', + 20: 'tentacle', + }; + let release = major2release[maxversion[0]] || 'unknown'; + let newestVersionTxt = `${Ext.String.capitalize(release)} (${maxversiontext})`; + + if (release === 'unknown') { + me.setValue(gettext('Could not detect a ceph installation in the cluster')); + } else { + me.setValue( + Ext.String.format( + gettext('Newest ceph version in cluster is {0}'), + newestVersionTxt, + ), + ); + } + me.gotNewestVersion(release, maxversiontext, maxversion); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, +}); + +Ext.define('PVE.ceph.CephInstallWizard', { + extend: 'PVE.window.Wizard', + alias: 'widget.pveCephInstallWizard', + mixins: ['Proxmox.Mixin.CBind'], + + resizable: false, + nodename: undefined, + + width: 760, // 4:3 + height: 570, + + viewModel: { + data: { + nodename: '', + cephRelease: 'squid', // default + cephRepo: 'enterprise', + configuration: true, + isInstalled: false, + nodeHasSubscription: true, // avoid warning hint until fully loaded + allHaveSubscription: true, // avoid warning hint until fully loaded + selectedReleaseIsTechPreview: false, // avoid warning hint until fully loaded + }, + formulas: { + repoHintHidden: (get) => get('allHaveSubscription') && get('cephRepo') === 'enterprise', + repoHint: function (get) { + let repo = get('cephRepo'); + let nodeSub = get('nodeHasSubscription'), + allSub = get('allHaveSubscription'); + + if (repo === 'enterprise') { + if (!nodeSub) { + return gettext( + 'The enterprise repository is enabled, but there is no active subscription!', + ); + } else if (!allSub) { + return gettext( + 'Not all nodes have an active subscription, which is required for cluster-wide enterprise repo access', + ); + } + return ''; // should be hidden + } else if (repo === 'no-subscription') { + return allSub + ? gettext( + 'Cluster has active subscriptions and would be eligible for using the enterprise repository.', + ) + : gettext( + 'The no-subscription repository is not the best choice for production setups.', + ); + } else if (repo === 'manual') { + return gettext( + 'The manual repository option expects that the repository is already configured. For example, in combination with the Proxmox Offline Mirror.', + ); + } else { + return gettext( + 'The test repository should only be used for test setups or after consulting the official Proxmox support!', + ); + } + }, + }, + }, + cbindData: { + nodename: undefined, + }, + + title: gettext('Setup'), + navigateNext: function () { + var tp = this.down('#wizcontent'); + var atab = tp.getActiveTab(); + + var next = tp.items.indexOf(atab) + 1; + var ntab = tp.items.getAt(next); + if (ntab) { + ntab.enable(); + tp.setActiveTab(ntab); + } + }, + setInitialTab: function (index) { + var tp = this.down('#wizcontent'); + var initialTab = tp.items.getAt(index); + initialTab.enable(); + tp.setActiveTab(initialTab); + }, + onShow: function () { + this.callParent(arguments); + let viewModel = this.getViewModel(); + var isInstalled = this.getViewModel().get('isInstalled'); + if (isInstalled) { + viewModel.set('configuration', false); + this.setInitialTab(2); + } + + PVE.Utils.getClusterSubscriptionLevel().then((subcriptionMap) => { + viewModel.set('nodeHasSubscription', !!subcriptionMap[this.nodename]); + + let allHaveSubscription = Object.values(subcriptionMap).every((level) => !!level); + viewModel.set('allHaveSubscription', allHaveSubscription); + }); + }, + items: [ + { + xtype: 'panel', + title: gettext('Info'), + viewModel: {}, // needed to inherit parent viewModel data + border: false, + bodyBorder: false, + onlineHelp: 'chapter_pveceph', + layout: { + type: 'vbox', + align: 'stretch', + }, + defaults: { + border: false, + bodyBorder: false, + }, + items: [ + { + xtype: 'pveCephInstallWizardInfo', + }, + { + flex: 1, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Hint'), + labelClsExtra: 'pmx-hint', + submitValue: false, + labelWidth: 50, + bind: { + value: '{repoHint}', + hidden: '{repoHintHidden}', + }, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Note'), + labelClsExtra: 'pmx-hint', + submitValue: false, + labelWidth: 50, + value: gettext( + 'The selected release is currently considered a Technology Preview. Although we are not aware of any major issues, there may be some bugs and the Enterprise Repository is not yet available.', + ), + bind: { + hidden: '{!selectedReleaseIsTechPreview}', + }, + }, + { + xtype: 'pveCephHighestVersionDisplay', + labelWidth: 150, + cbind: { + nodename: '{nodename}', + }, + gotNewestVersion: function (release, maxversiontext, maxversion) { + if (release === 'unknown') { + return; + } + let wizard = this.up('pveCephInstallWizard'); + wizard.getViewModel().set('cephRelease', release); + }, + }, + { + xtype: 'container', + layout: 'hbox', + defaults: { + border: false, + layout: 'anchor', + flex: 1, + }, + items: [ + { + xtype: 'pveCephVersionSelector', + labelWidth: 150, + padding: '0 10 0 0', + submitValue: false, + bind: { + value: '{cephRelease}', + }, + listeners: { + change: function (field, release) { + let me = this; + let wizard = this.up('pveCephInstallWizard'); + wizard + .down('#next') + .setText( + Ext.String.format( + gettext('Start {0} installation'), + release, + ), + ); + + let record = me.store.findRecord( + 'release', + release, + 0, + false, + true, + true, + ); + let releaseIsTechPreview = !!record.data.preview; + wizard + .getViewModel() + .set('selectedReleaseIsTechPreview', releaseIsTechPreview); + + let repoSelector = wizard.down('#repoSelector'); + if (releaseIsTechPreview) { + repoSelector.store.filterBy( + (entry) => entry.get('key') !== 'enterprise', + ); + } else { + repoSelector.store.clearFilter(); + } + }, + }, + }, + { + xtype: 'proxmoxKVComboBox', + id: 'repoSelector', // TODO: use name or reference (how to lookup that here?) + fieldLabel: gettext('Repository'), + padding: '0 0 0 10', + comboItems: [ + ['enterprise', gettext('Enterprise (recommended)')], + ['no-subscription', gettext('No-Subscription')], + ['test', gettext('Test')], + ['manual', gettext('Manual')], + ], + labelWidth: 150, + submitValue: false, + value: 'enterprise', + bind: { + value: '{cephRepo}', + }, + }, + ], + }, + ], + listeners: { + activate: function () { + // notify owning container that it should display a help button + if (this.onlineHelp) { + Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp); + } + let wizard = this.up('pveCephInstallWizard'); + let release = wizard.getViewModel().get('cephRelease'); + wizard.down('#back').hide(true); + wizard + .down('#next') + .setText(Ext.String.format(gettext('Start {0} installation'), release)); + }, + deactivate: function () { + if (this.onlineHelp) { + Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp); + } + this.up('pveCephInstallWizard').down('#next').setText(gettext('Next')); + }, + }, + }, + { + title: gettext('Installation'), + xtype: 'panel', + layout: 'fit', + cbind: { + nodename: '{nodename}', + }, + viewModel: {}, // needed to inherit parent viewModel data + listeners: { + afterrender: function () { + var me = this; + if (this.getViewModel().get('isInstalled')) { + this.mask( + 'Ceph is already installed, click next to create your configuration.', + ['pve-static-mask'], + ); + } else { + me.down('pveNoVncConsole').fireEvent('activate'); + } + }, + activate: function () { + let me = this; + const nodename = me.nodename; + me.updateStore = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'ceph-status-' + nodename, + interval: 1000, + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/' + nodename + '/ceph/status', + }, + listeners: { + load: function (rec, response, success, operation) { + if (success) { + me.updateStore.stopUpdate(); + me.down('textfield').setValue('success'); + } else if ( + operation.error.statusText.match('not initialized', 'i') + ) { + me.updateStore.stopUpdate(); + me.up('pveCephInstallWizard') + .getViewModel() + .set('configuration', false); + me.down('textfield').setValue('success'); + } else if ( + operation.error.statusText.match('rados_connect failed', 'i') + ) { + me.updateStore.stopUpdate(); + me.up('pveCephInstallWizard') + .getViewModel() + .set('configuration', true); + me.down('textfield').setValue('success'); + } else if ( + !operation.error.statusText.match('not installed', 'i') + ) { + let msg = Ext.htmlEncode(operation.error.statusText); + Proxmox.Utils.setErrorMask(me, msg); + } + }, + }, + }); + me.updateStore.startUpdate(); + }, + destroy: function () { + var me = this; + if (me.updateStore) { + me.updateStore.stopUpdate(); + } + }, + }, + items: [ + { + xtype: 'pveNoVncConsole', + itemId: 'jsconsole', + consoleType: 'cmd', + xtermjs: true, + cbind: { + nodename: '{nodename}', + }, + beforeLoad: function () { + let me = this; + let wizard = me.up('pveCephInstallWizard'); + let release = wizard.getViewModel().get('cephRelease'); + let repo = wizard.getViewModel().get('cephRepo'); + me.cmdOpts = `--version\0${release}\0--repository\0${repo}`; + }, + cmd: 'ceph_install', + }, + { + xtype: 'textfield', + name: 'installSuccess', + value: '', + allowBlank: false, + submitValue: false, + hidden: true, + }, + ], + }, + { + xtype: 'inputpanel', + title: gettext('Configuration'), + onlineHelp: 'chapter_pveceph', + height: 300, + cbind: { + nodename: '{nodename}', + }, + viewModel: { + data: { + replicas: undefined, + minreplicas: undefined, + }, + }, + listeners: { + activate: function () { + this.up('pveCephInstallWizard').down('#submit').setText(gettext('Next')); + }, + afterrender: function () { + if (this.up('pveCephInstallWizard').getViewModel().get('configuration')) { + this.mask('Configuration already initialized', ['pve-static-mask']); + } else { + this.unmask(); + } + }, + deactivate: function () { + this.up('pveCephInstallWizard').down('#submit').setText(gettext('Finish')); + }, + }, + column1: [ + { + xtype: 'displayfield', + value: gettext('Ceph cluster configuration') + ':', + }, + { + xtype: 'proxmoxNetworkSelector', + name: 'network', + value: '', + fieldLabel: 'Public Network IP/CIDR', + autoSelect: false, + type: 'include_sdn', + bind: { + allowBlank: '{configuration}', + }, + cbind: { + nodename: '{nodename}', + }, + }, + { + xtype: 'proxmoxNetworkSelector', + name: 'cluster-network', + fieldLabel: 'Cluster Network IP/CIDR', + allowBlank: true, + autoSelect: false, + type: 'include_sdn', + emptyText: gettext('Same as Public Network'), + cbind: { + nodename: '{nodename}', + }, + }, + // FIXME: add hint about cluster network and/or reference user to docs?? + ], + column2: [ + { + xtype: 'displayfield', + value: gettext('First Ceph monitor') + ':', + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Monitor node'), + cbind: { + value: '{nodename}', + }, + }, + { + xtype: 'displayfield', + value: gettext( + 'Additional monitors are recommended. They can be created at any time in the Monitor tab.', + ), + userCls: 'pmx-hint', + }, + ], + advancedColumn1: [ + { + xtype: 'numberfield', + name: 'size', + fieldLabel: 'Number of replicas', + bind: { + value: '{replicas}', + }, + maxValue: 7, + minValue: 2, + emptyText: '3', + }, + { + xtype: 'numberfield', + name: 'min_size', + fieldLabel: 'Minimum replicas', + bind: { + maxValue: '{replicas}', + value: '{minreplicas}', + }, + minValue: 2, + maxValue: 3, + setMaxValue: function (value) { + this.maxValue = Ext.Number.from(value, 2); + // allow enough to avoid split brains with max 'size', but more makes simply no sense + if (this.maxValue > 4) { + this.maxValue = 4; + } + this.toggleSpinners(); + this.validate(); + }, + emptyText: '2', + }, + ], + onGetValues: function (values) { + ['cluster-network', 'size', 'min_size'].forEach(function (field) { + if (!values[field]) { + delete values[field]; + } + }); + return values; + }, + onSubmit: function () { + var me = this; + if (!this.up('pveCephInstallWizard').getViewModel().get('configuration')) { + let wizard = me.up('window'); + let kv = wizard.getValues(); + delete kv.delete; + let nodename = me.nodename; + delete kv.nodename; + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/ceph/init`, + waitMsgTarget: wizard, + method: 'POST', + params: kv, + success: function () { + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/ceph/mon/${nodename}`, + waitMsgTarget: wizard, + method: 'POST', + success: function () { + me.up('pveCephInstallWizard').navigateNext(); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + } else { + me.up('pveCephInstallWizard').navigateNext(); + } + }, + }, + { + title: gettext('Success'), + xtype: 'panel', + border: false, + bodyBorder: false, + onlineHelp: 'pve_ceph_install', + html: + '

    Installation successful!

    ' + + '

    The basic installation and configuration is complete. Depending on your setup, some of the following steps are required to start using Ceph:

    ' + + '
    1. Install Ceph on other nodes
    2. ' + + '
    3. Create additional Ceph Monitors
    4. ' + + '
    5. Create Ceph OSDs
    6. ' + + '
    7. Create Ceph Pools
    ' + + '

    To learn more, click on the help button below.

    ', + listeners: { + activate: function () { + // notify owning container that it should display a help button + if (this.onlineHelp) { + Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp); + } + + var tp = this.up('#wizcontent'); + var idx = tp.items.indexOf(this) - 1; + for (; idx >= 0; idx--) { + let nc = tp.items.getAt(idx); + if (nc) { + nc.disable(); + } + } + }, + deactivate: function () { + if (this.onlineHelp) { + Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp); + } + }, + }, + onSubmit: function () { + var wizard = this.up('pveCephInstallWizard'); + wizard.close(); + }, + }, + ], +}); +Ext.define('PVE.node.CephConfigDb', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveNodeCephConfigDb', + + border: false, + store: { + proxy: { + type: 'proxmox', + }, + }, + + columns: [ + { + dataIndex: 'section', + text: 'WHO', + width: 100, + renderer: Ext.htmlEncode, + }, + { + dataIndex: 'mask', + text: 'MASK', + hidden: true, + width: 80, + renderer: Ext.htmlEncode, + }, + { + dataIndex: 'level', + hidden: true, + text: 'LEVEL', + renderer: Ext.htmlEncode, + }, + { + dataIndex: 'name', + flex: 1, + text: 'OPTION', + renderer: Ext.htmlEncode, + }, + { + dataIndex: 'value', + flex: 1, + text: 'VALUE', + renderer: Ext.htmlEncode, + }, + { + dataIndex: 'can_update_at_runtime', + text: 'Runtime Updatable', + hidden: true, + width: 80, + renderer: Proxmox.Utils.format_boolean, + }, + ], + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.store.proxy.url = '/api2/json/nodes/' + nodename + '/ceph/cfg/db'; + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore()); + me.getStore().load(); + }, +}); +Ext.define('PVE.node.CephConfig', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeCephConfig', + + bodyStyle: 'white-space:pre', + bodyPadding: 5, + border: false, + scrollable: true, + load: function () { + var me = this; + + Proxmox.Utils.API2Request({ + url: me.url, + waitMsgTarget: me, + failure: function (response, opts) { + me.update(gettext('Error') + ' ' + response.htmlStatus); + var msg = response.htmlStatus; + PVE.Utils.showCephInstallOrMask( + me.ownerCt, + msg, + me.pveSelNode.data.node, + function (win) { + me.mon(win, 'cephInstallWindowClosed', function () { + me.load(); + }); + }, + ); + }, + success: function (response, opts) { + var data = response.result.data; + me.update(Ext.htmlEncode(data)); + }, + }); + }, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + Ext.apply(me, { + url: '/nodes/' + nodename + '/ceph/cfg/raw', + listeners: { + activate: function () { + me.load(); + }, + }, + }); + + me.callParent(); + + me.load(); + }, +}); + +Ext.define('PVE.node.CephConfigCrush', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeCephConfigCrush', + + onlineHelp: 'chapter_pveceph', + + layout: 'border', + items: [ + { + title: gettext('Configuration'), + xtype: 'pveNodeCephConfig', + region: 'center', + }, + { + title: 'Crush Map', // do not localize + xtype: 'pveNodeCephCrushMap', + region: 'east', + split: true, + width: '50%', + }, + { + title: gettext('Configuration Database'), + xtype: 'pveNodeCephConfigDb', + region: 'south', + split: true, + weight: -30, + height: '50%', + }, + ], + + initComponent: function () { + var me = this; + me.defaults = { + pveSelNode: me.pveSelNode, + }; + me.callParent(); + }, +}); +Ext.define('PVE.node.CephCrushMap', { + extend: 'Ext.panel.Panel', + alias: ['widget.pveNodeCephCrushMap'], + bodyStyle: 'white-space:pre', + bodyPadding: 5, + border: false, + stateful: true, + stateId: 'layout-ceph-crush', + scrollable: true, + load: function () { + var me = this; + + Proxmox.Utils.API2Request({ + url: me.url, + waitMsgTarget: me, + failure: function (response, opts) { + me.update(gettext('Error') + ' ' + response.htmlStatus); + var msg = response.htmlStatus; + PVE.Utils.showCephInstallOrMask(me.ownerCt, msg, me.pveSelNode.data.node, (win) => + me.mon(win, 'cephInstallWindowClosed', () => me.load()), + ); + }, + success: function (response, opts) { + var data = response.result.data; + me.update(Ext.htmlEncode(data)); + }, + }); + }, + + initComponent: function () { + let me = this; + + let nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + Ext.apply(me, { + url: `/nodes/${nodename}/ceph/crush`, + listeners: { + activate: () => me.load(), + }, + }); + + me.callParent(); + + me.load(); + }, +}); +Ext.define('PVE.CephCreateFS', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveCephCreateFS', + + showTaskViewer: true, + onlineHelp: 'pveceph_fs_create', + + subject: 'Ceph FS', + isCreate: true, + method: 'POST', + + setFSName: function (fsName) { + var me = this; + + if (fsName === '' || fsName === undefined) { + fsName = 'cephfs'; + } + + me.url = '/nodes/' + me.nodename + '/ceph/fs/' + fsName; + }, + + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Name'), + name: 'name', + value: 'cephfs', + listeners: { + change: function (f, value) { + this.up('pveCephCreateFS').setFSName(value); + }, + }, + submitValue: false, // already encoded in apicall URL + emptyText: 'cephfs', + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: 'Placement Groups', + name: 'pg_num', + value: 128, + emptyText: 128, + minValue: 8, + maxValue: 32768, + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Add as Storage'), + value: true, + name: 'add-storage', + autoEl: { + tag: 'div', + 'data-qtip': gettext('Add the new CephFS to the cluster storage configuration.'), + }, + }, + ], + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + me.setFSName(); + + me.callParent(); + }, +}); + +Ext.define( + 'PVE.NodeCephFSPanel', + { + extend: 'Ext.panel.Panel', + xtype: 'pveNodeCephFSPanel', + mixins: ['Proxmox.Mixin.CBind'], + + title: gettext('CephFS'), + onlineHelp: 'pveceph_fs', + + border: false, + defaults: { + border: false, + cbind: { + nodename: '{nodename}', + }, + }, + + viewModel: { + parent: null, + data: { + mdsCount: 0, + }, + formulas: { + canCreateFS: function (get) { + return get('mdsCount') > 0; + }, + }, + }, + + items: [ + { + xtype: 'grid', + emptyText: Ext.String.format(gettext('No {0} configured.'), 'CephFS'), + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + view.rstore = Ext.create('Proxmox.data.UpdateStore', { + autoLoad: true, + xtype: 'update', + interval: 5 * 1000, + autoStart: true, + storeid: 'pve-ceph-fs', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${view.nodename}/ceph/fs`, + }, + model: 'pve-ceph-fs', + }); + view.setStore( + Ext.create('Proxmox.data.DiffStore', { + rstore: view.rstore, + sorters: { + property: 'name', + direction: 'ASC', + }, + }), + ); + // manages the "install ceph?" overlay + PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true); + view.on('destroy', () => view.rstore.stopUpdate()); + }, + + onCreate: function () { + let view = this.getView(); + view.rstore.stopUpdate(); + Ext.create('PVE.CephCreateFS', { + autoShow: true, + nodename: view.nodename, + listeners: { + destroy: () => view.rstore.startUpdate(), + }, + }); + }, + }, + tbar: [ + { + text: gettext('Create CephFS'), + reference: 'createButton', + handler: 'onCreate', + bind: { + disabled: '{!canCreateFS}', + }, + }, + ], + columns: [ + { + header: gettext('Name'), + flex: 1, + dataIndex: 'name', + renderer: Ext.htmlEncode, + }, + { + header: gettext('Data Pool'), + flex: 1, + dataIndex: 'data_pool', + renderer: Ext.htmlEncode, + }, + { + header: gettext('Metadata Pool'), + flex: 1, + dataIndex: 'metadata_pool', + renderer: Ext.htmlEncode, + }, + ], + cbind: { + nodename: '{nodename}', + }, + }, + { + xtype: 'pveNodeCephMDSList', + title: gettext('Metadata Servers'), + stateId: 'grid-ceph-mds', + type: 'mds', + storeLoadCallback: function (store, records, success) { + var vm = this.getViewModel(); + if (!success || !records) { + vm.set('mdsCount', 0); + return; + } + let count = 0; + for (const mds of records) { + if (mds.data.state === 'up:standby') { + count++; + } + } + vm.set('mdsCount', count); + }, + cbind: { + nodename: '{nodename}', + }, + }, + ], + }, + function () { + Ext.define('pve-ceph-fs', { + extend: 'Ext.data.Model', + fields: ['name', 'data_pool', 'metadata_pool'], + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/localhost/ceph/fs', + }, + idProperty: 'name', + }); + }, +); +Ext.define('PVE.ceph.Log', { + extend: 'Proxmox.panel.LogView', + xtype: 'cephLogView', + + nodename: undefined, + + failCallback: function (response) { + var me = this; + var msg = response.htmlStatus; + var windowShow = PVE.Utils.showCephInstallOrMask(me, msg, me.nodename, function (win) { + me.mon(win, 'cephInstallWindowClosed', function () { + me.loadTask.delay(200); + }); + }); + if (!windowShow) { + Proxmox.Utils.setErrorMask(me, msg); + } + }, +}); +Ext.define('PVE.node.CephMonMgrList', { + extend: 'Ext.container.Container', + xtype: 'pveNodeCephMonMgr', + + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'chapter_pveceph', + + defaults: { + border: false, + onlineHelp: 'chapter_pveceph', + flex: 1, + }, + + layout: { + type: 'vbox', + align: 'stretch', + }, + + items: [ + { + xtype: 'pveNodeCephServiceList', + cbind: { pveSelNode: '{pveSelNode}' }, + type: 'mon', + additionalColumns: [ + { + header: gettext('Quorum'), + width: 70, + sortable: true, + renderer: Proxmox.Utils.format_boolean, + dataIndex: 'quorum', + }, + ], + stateId: 'grid-ceph-monitor', + showCephInstallMask: true, + title: gettext('Monitor'), + }, + { + xtype: 'pveNodeCephServiceList', + type: 'mgr', + stateId: 'grid-ceph-manager', + cbind: { pveSelNode: '{pveSelNode}' }, + title: gettext('Manager'), + }, + ], +}); +Ext.define('PVE.CephCreateOsd', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCephCreateOsd', + + subject: 'Ceph OSD', + + showProgress: true, + + onlineHelp: 'pve_ceph_osds', + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + me.isCreate = true; + + Proxmox.Utils.API2Request({ + url: `/nodes/${me.nodename}/ceph/crush`, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function ({ result: { data } }) { + let classes = [ + ...new Set( + Array.from( + data.matchAll(/^device\s[0-9]*\sosd\.[0-9]*\sclass\s(.*)$/gim), + (m) => m[1], + ).filter((v) => !['hdd', 'ssd', 'nvme'].includes(v)), + ), + ].map((v) => [v, v]); + + if (classes.length) { + let kvField = me.down('field[name=crush-device-class]'); + kvField.setComboItems([...kvField.comboItems, ...classes]); + } + }, + }); + + Ext.applyIf(me, { + url: '/nodes/' + me.nodename + '/ceph/osd', + method: 'POST', + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + Object.keys(values || {}).forEach(function (name) { + if (values[name] === '') { + delete values[name]; + } + }); + + return values; + }, + column1: [ + { + xtype: 'pmxDiskSelector', + name: 'dev', + nodename: me.nodename, + diskType: 'unused', + includePartitions: true, + fieldLabel: gettext('Disk'), + allowBlank: false, + }, + ], + column2: [ + { + xtype: 'pmxDiskSelector', + name: 'db_dev', + nodename: me.nodename, + diskType: 'journal_disks', + includePartitions: true, + fieldLabel: gettext('DB Disk'), + value: '', + autoSelect: false, + allowBlank: true, + emptyText: gettext('use OSD disk'), + listeners: { + change: function (field, val) { + me.down('field[name=db_dev_size]').setDisabled(!val); + }, + }, + }, + { + xtype: 'numberfield', + name: 'db_dev_size', + fieldLabel: `${gettext('DB size')} (${gettext('GiB')})`, + minValue: 1, + maxValue: 128 * 1024, + decimalPrecision: 2, + allowBlank: true, + disabled: true, + emptyText: gettext('Automatic'), + }, + ], + advancedColumn1: [ + { + xtype: 'proxmoxcheckbox', + name: 'encrypted', + fieldLabel: gettext('Encrypt OSD'), + }, + { + xtype: 'proxmoxKVComboBox', + comboItems: [ + ['hdd', 'HDD'], + ['ssd', 'SSD'], + ['nvme', 'NVMe'], + ], + name: 'crush-device-class', + nodename: me.nodename, + fieldLabel: gettext('Device Class'), + value: '', + autoSelect: false, + allowBlank: true, + editable: true, + emptyText: gettext('auto detect'), + deleteEmpty: !me.isCreate, + }, + ], + advancedColumn2: [ + { + xtype: 'pmxDiskSelector', + name: 'wal_dev', + nodename: me.nodename, + diskType: 'journal_disks', + includePartitions: true, + fieldLabel: gettext('WAL Disk'), + value: '', + autoSelect: false, + allowBlank: true, + emptyText: gettext('use OSD/DB disk'), + listeners: { + change: function (field, val) { + me.down('field[name=wal_dev_size]').setDisabled(!val); + }, + }, + }, + { + xtype: 'numberfield', + name: 'wal_dev_size', + fieldLabel: `${gettext('WAL size')} (${gettext('GiB')})`, + minValue: 0.5, + maxValue: 128 * 1024, + decimalPrecision: 2, + allowBlank: true, + disabled: true, + emptyText: gettext('Automatic'), + }, + ], + }, + { + xtype: 'displayfield', + padding: '5 0 0 0', + userCls: 'pmx-hint', + value: + 'Note: Ceph is not compatible with disks backed by a hardware ' + + 'RAID controller. For details see ' + + 'the reference documentation.', + }, + ], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.CephRemoveOsd', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveCephRemoveOsd'], + + isRemove: true, + + showProgress: true, + method: 'DELETE', + items: [ + { + xtype: 'proxmoxcheckbox', + name: 'cleanup', + checked: true, + labelWidth: 130, + fieldLabel: gettext('Cleanup Disks'), + }, + { + xtype: 'displayfield', + name: 'osd-flag-hint', + userCls: 'pmx-hint', + value: gettext('Global flags limiting the self healing of Ceph are enabled.'), + hidden: true, + }, + { + xtype: 'displayfield', + name: 'degraded-objects-hint', + userCls: 'pmx-hint', + value: gettext('Objects are degraded. Consider waiting until the cluster is healthy.'), + hidden: true, + }, + ], + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + if (me.osdid === undefined || me.osdid < 0) { + throw 'no osdid specified'; + } + + me.isCreate = true; + + me.title = gettext('Destroy') + ': Ceph OSD osd.' + me.osdid.toString(); + + Ext.applyIf(me, { + url: '/nodes/' + me.nodename + '/ceph/osd/' + me.osdid.toString(), + }); + + me.callParent(); + + if (me.warnings.flags) { + me.down('field[name=osd-flag-hint]').setHidden(false); + } + if (me.warnings.degraded) { + me.down('field[name=degraded-objects-hint]').setHidden(false); + } + }, +}); + +Ext.define('PVE.CephSetFlags', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCephSetFlags', + + showProgress: true, + + width: 720, + layout: 'fit', + + onlineHelp: 'pve_ceph_osds', + isCreate: true, + title: gettext('Manage Global OSD Flags'), + submitText: gettext('Apply'), + + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + let me = this; + let val = {}; + me.down('#flaggrid') + .getStore() + .each((rec) => { + val[rec.data.name] = rec.data.value ? 1 : 0; + }); + + return val; + }, + items: [ + { + xtype: 'grid', + itemId: 'flaggrid', + store: { + listeners: { + update: function () { + this.commitChanges(); + }, + }, + }, + + columns: [ + { + text: gettext('Enable'), + xtype: 'checkcolumn', + width: 75, + dataIndex: 'value', + }, + { + text: 'Name', + dataIndex: 'name', + }, + { + text: 'Description', + flex: 1, + dataIndex: 'description', + renderer: Ext.htmlEncode, + }, + ], + }, + ], + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + Ext.applyIf(me, { + url: '/cluster/ceph/flags', + method: 'PUT', + }); + + me.callParent(); + + let grid = me.down('#flaggrid'); + me.load({ + success: function (response, options) { + let data = response.result.data; + grid.getStore().setData(data); + // re-align after store load, else the window is not centered + me.alignTo(Ext.getBody(), 'c-c'); + }, + }); + }, +}); + +Ext.define('PVE.node.CephOsdTree', { + extend: 'Ext.tree.Panel', + alias: ['widget.pveNodeCephOsdTree'], + onlineHelp: 'chapter_pveceph', + + viewModel: { + data: { + nodename: '', + flags: [], + maxversion: '0', + mixedversions: false, + versions: {}, + isOsd: false, + downOsd: false, + upOsd: false, + inOsd: false, + outOsd: false, + osdid: '', + osdhost: '', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function () { + let me = this; + let view = me.getView(); + let vm = me.getViewModel(); + let nodename = vm.get('nodename'); + let sm = view.getSelectionModel(); + Proxmox.Utils.API2Request({ + url: '/nodes/' + nodename + '/ceph/osd', + waitMsgTarget: view, + method: 'GET', + failure: function (response, opts) { + let msg = response.htmlStatus; + PVE.Utils.showCephInstallOrMask(view, msg, nodename, (win) => + view.mon(win, 'cephInstallWindowClosed', () => { + me.reload(); + }), + ); + }, + success: function (response, opts) { + let data = response.result.data; + let selected = view.getSelection(); + let name; + if (selected.length) { + name = selected[0].data.name; + } + data.versions = data.versions || {}; + vm.set('versions', data.versions); + // extract max version + let maxversion = '0'; + let mixedversions = false; + let traverse; + traverse = function (node, fn) { + fn(node); + if (Array.isArray(node.children)) { + node.children.forEach((c) => { + traverse(c, fn); + }); + } + }; + traverse(data.root, (node) => { + // compatibility for old api call + if (node.type === 'host' && !node.version) { + node.version = data.versions[node.name]; + } + + if (node.version === undefined) { + return; + } + + if ( + PVE.Utils.compare_ceph_versions(node.version, maxversion) !== 0 && + maxversion !== '0' + ) { + mixedversions = true; + } + + if (PVE.Utils.compare_ceph_versions(node.version, maxversion) > 0) { + maxversion = node.version; + } + }); + vm.set('maxversion', maxversion); + vm.set('mixedversions', mixedversions); + sm.deselectAll(); + view.setRootNode(data.root); + view.expandAll(); + if (name) { + let node = view.getRootNode().findChild('name', name, true); + if (node) { + view.setSelection([node]); + } + } + + let flags = data.flags.split(','); + vm.set('flags', flags); + }, + }); + }, + + osd_cmd: function (comp) { + let me = this; + let vm = this.getViewModel(); + let cmd = comp.cmd; + let params = comp.params || {}; + let osdid = vm.get('osdid'); + + let doRequest = function () { + let targetnode = vm.get('osdhost'); + // cmds not node specific and need to work if the OSD node is down + if (['in', 'out'].includes(cmd)) { + targetnode = vm.get('nodename'); + } + Proxmox.Utils.API2Request({ + url: `/nodes/${targetnode}/ceph/osd/${osdid}/${cmd}`, + waitMsgTarget: me.getView(), + method: 'POST', + params: params, + success: () => { + me.reload(); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }; + + if (cmd === 'scrub') { + Ext.MessageBox.defaultButton = params.deep === 1 ? 2 : 1; + Ext.Msg.show({ + title: gettext('Confirm'), + icon: params.deep === 1 ? Ext.Msg.WARNING : Ext.Msg.QUESTION, + msg: + params.deep !== 1 + ? Ext.String.format(gettext('Scrub OSD.{0}'), osdid) + : Ext.String.format(gettext('Deep Scrub OSD.{0}'), osdid) + + '
    Caution: This can reduce performance while it is running.', + buttons: Ext.Msg.YESNO, + callback: function (btn) { + if (btn !== 'yes') { + return; + } + doRequest(); + }, + }); + } else { + doRequest(); + } + }, + + create_osd: function () { + let me = this; + let vm = this.getViewModel(); + Ext.create('PVE.CephCreateOsd', { + nodename: vm.get('nodename'), + taskDone: () => { + me.reload(); + }, + }).show(); + }, + + destroy_osd: async function () { + let me = this; + let vm = this.getViewModel(); + + let warnings = { + flags: false, + degraded: false, + }; + + let flagsPromise = Proxmox.Async.api2({ + url: `/cluster/ceph/flags`, + method: 'GET', + }); + + let statusPromise = Proxmox.Async.api2({ + url: `/cluster/ceph/status`, + method: 'GET', + }); + + me.getView().mask(gettext('Loading...')); + + try { + let result = await Promise.all([flagsPromise, statusPromise]); + + let flagsData = result[0].result.data; + let statusData = result[1].result.data; + + let flags = Array.from( + flagsData.filter((v) => v.value), + (v) => v.name, + ).filter((v) => ['norebalance', 'norecover', 'noout'].includes(v)); + + if (flags.length) { + warnings.flags = true; + } + if (Object.keys(statusData.pgmap).includes('degraded_objects')) { + warnings.degraded = true; + } + } catch (error) { + Ext.Msg.alert(gettext('Error'), error.htmlStatus); + me.getView().unmask(); + return; + } + + me.getView().unmask(); + Ext.create('PVE.CephRemoveOsd', { + nodename: vm.get('osdhost'), + osdid: vm.get('osdid'), + warnings: warnings, + taskDone: () => { + me.reload(); + }, + autoShow: true, + }); + }, + + set_flags: function () { + let me = this; + let vm = this.getViewModel(); + Ext.create('PVE.CephSetFlags', { + nodename: vm.get('nodename'), + taskDone: () => { + me.reload(); + }, + }).show(); + }, + + service_cmd: function (comp) { + let me = this; + let vm = this.getViewModel(); + let cmd = comp.cmd || comp; + + let doRequest = function () { + Proxmox.Utils.API2Request({ + url: `/nodes/${vm.get('osdhost')}/ceph/${cmd}`, + params: { service: 'osd.' + vm.get('osdid') }, + waitMsgTarget: me.getView(), + method: 'POST', + success: function (response, options) { + let upid = response.result.data; + let win = Ext.create('Proxmox.window.TaskProgress', { + upid: upid, + taskDone: () => { + me.reload(); + }, + }); + win.show(); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }; + + if (cmd === 'stop') { + Proxmox.Utils.API2Request({ + url: `/nodes/${vm.get('osdhost')}/ceph/cmd-safety`, + params: { + service: 'osd', + id: vm.get('osdid'), + action: 'stop', + }, + waitMsgTarget: me.getView(), + method: 'GET', + success: function ({ result: { data } }) { + if (!data.safe) { + Ext.Msg.show({ + title: ngettext('Warning', 'Warnings', 1), + message: data.status, + icon: Ext.Msg.WARNING, + buttons: Ext.Msg.OKCANCEL, + buttonText: { ok: gettext('Stop OSD') }, + fn: function (selection) { + if (selection === 'ok') { + doRequest(); + } + }, + }); + } else { + doRequest(); + } + }, + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + } else { + doRequest(); + } + }, + + run_details: function (view, rec) { + if (rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0) { + this.details(); + } + }, + + details: function () { + let vm = this.getViewModel(); + Ext.create('PVE.CephOsdDetails', { + nodename: vm.get('osdhost'), + osdid: vm.get('osdid'), + }).show(); + }, + + set_selection_status: function (tp, selection) { + if (selection.length < 1) { + return; + } + let rec = selection[0]; + let vm = this.getViewModel(); + + let isOsd = rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0; + + vm.set('isOsd', isOsd); + vm.set('downOsd', isOsd && rec.data.status === 'down'); + vm.set('upOsd', isOsd && rec.data.status !== 'down'); + vm.set('inOsd', isOsd && rec.data.in); + vm.set('outOsd', isOsd && !rec.data.in); + vm.set('osdid', isOsd ? rec.data.id : undefined); + vm.set('osdhost', isOsd ? rec.data.host : undefined); + }, + + render_status: function (value, metaData, rec) { + if (!value) { + return value; + } + let inout = rec.data.in ? 'in' : 'out'; + let updownicon = + value === 'up' ? 'good fa-arrow-circle-up' : 'critical fa-arrow-circle-down'; + + let inouticon = rec.data.in ? 'good fa-circle' : 'warning fa-circle-o'; + + let text = + value + + ' / ' + + inout + + ' '; + + return text; + }, + + render_wal: function (value, metaData, rec) { + if (!value && rec.data.osdtype === 'bluestore' && rec.data.type === 'osd') { + return 'N/A'; + } + return value; + }, + + render_version: function (value, metadata, rec) { + let vm = this.getViewModel(); + let versions = vm.get('versions'); + let icon = ''; + let version = value || ''; + let maxversion = vm.get('maxversion'); + if (value && PVE.Utils.compare_ceph_versions(value, maxversion) !== 0) { + let host_version = rec.parentNode?.data?.version || versions[rec.data.host] || ''; + if ( + rec.data.type === 'host' || + PVE.Utils.compare_ceph_versions(host_version, maxversion) !== 0 + ) { + icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE'); + } else { + icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD'); + } + } else if (value && vm.get('mixedversions')) { + icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK'); + } + + return icon + version; + }, + + render_osd_val: function (value, metaData, rec) { + return rec.data.type === 'osd' ? value : ''; + }, + render_osd_weight: function (value, metaData, rec) { + if (rec.data.type !== 'osd') { + return ''; + } + return Ext.util.Format.number(value, '0.00###'); + }, + + render_osd_latency: function (value, metaData, rec) { + if (rec.data.type !== 'osd') { + return ''; + } + let commit_ms = rec.data.commit_latency_ms, + apply_ms = rec.data.apply_latency_ms; + return apply_ms + ' / ' + commit_ms; + }, + + render_osd_size: function (value, metaData, rec) { + return this.render_osd_val(Proxmox.Utils.render_size(value), metaData, rec); + }, + + control: { + '#': { + selectionchange: 'set_selection_status', + }, + }, + + init: function (view) { + let me = this; + let vm = this.getViewModel(); + + if (!view.pveSelNode.data.node) { + throw 'no node name specified'; + } + + vm.set('nodename', view.pveSelNode.data.node); + + me.callParent(); + me.reload(); + }, + }, + + stateful: true, + stateId: 'grid-ceph-osd', + rootVisible: false, + useArrows: true, + listeners: { + itemdblclick: 'run_details', + }, + + columns: [ + { + xtype: 'treecolumn', + text: 'Name', + dataIndex: 'name', + width: 150, + }, + { + text: 'Type', + dataIndex: 'type', + hidden: true, + align: 'right', + width: 75, + }, + { + text: gettext('Class'), + dataIndex: 'device_class', + align: 'right', + width: 75, + renderer: Ext.htmlEncode, + }, + { + text: 'OSD Type', + dataIndex: 'osdtype', + align: 'right', + width: 100, + }, + { + text: 'Bluestore Device', + dataIndex: 'blfsdev', + align: 'right', + width: 75, + hidden: true, + renderer: Ext.htmlEncode, + }, + { + text: 'DB Device', + dataIndex: 'dbdev', + align: 'right', + width: 75, + hidden: true, + renderer: Ext.htmlEncode, + }, + { + text: 'WAL Device', + dataIndex: 'waldev', + align: 'right', + renderer: 'render_wal', + width: 75, + hidden: true, + }, + { + text: 'Status', + dataIndex: 'status', + align: 'right', + renderer: 'render_status', + width: 120, + }, + { + text: gettext('Version'), + dataIndex: 'version', + align: 'right', + renderer: 'render_version', + }, + { + text: 'weight', + dataIndex: 'crush_weight', + align: 'right', + renderer: 'render_osd_weight', + width: 90, + }, + { + text: 'reweight', + dataIndex: 'reweight', + align: 'right', + renderer: 'render_osd_weight', + width: 90, + }, + { + text: gettext('Used') + ' (%)', + dataIndex: 'percent_used', + align: 'right', + renderer: function (value, metaData, rec) { + if (rec.data.type !== 'osd') { + return ''; + } + return Ext.util.Format.number(value, '0.00'); + }, + width: 100, + }, + { + text: gettext('Total'), + dataIndex: 'total_space', + align: 'right', + renderer: 'render_osd_size', + width: 100, + }, + { + text: 'Apply/Commit
    Latency (ms)', + dataIndex: 'apply_latency_ms', + align: 'right', + renderer: 'render_osd_latency', + width: 120, + }, + { + text: 'PGs', + dataIndex: 'pgs', + align: 'right', + renderer: 'render_osd_val', + width: 90, + }, + ], + + tbar: { + items: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: 'reload', + }, + '-', + { + text: gettext('Create: OSD'), + handler: 'create_osd', + }, + { + text: gettext('Manage Global Flags'), + handler: 'set_flags', + }, + '->', + { + xtype: 'tbtext', + data: { + osd: undefined, + }, + bind: { + data: { + osd: '{osdid}', + }, + }, + tpl: [ + '', + 'osd.{osd}:', + '', + gettext('No OSD selected'), + '', + ], + }, + { + text: gettext('Details'), + iconCls: 'fa fa-info-circle', + disabled: true, + bind: { + disabled: '{!isOsd}', + }, + handler: 'details', + }, + { + text: gettext('Start'), + iconCls: 'fa fa-play', + disabled: true, + bind: { + disabled: '{!downOsd}', + }, + cmd: 'start', + handler: 'service_cmd', + }, + { + text: gettext('Stop'), + iconCls: 'fa fa-stop', + disabled: true, + bind: { + disabled: '{!upOsd}', + }, + cmd: 'stop', + handler: 'service_cmd', + }, + { + text: gettext('Restart'), + iconCls: 'fa fa-refresh', + disabled: true, + bind: { + disabled: '{!upOsd}', + }, + cmd: 'restart', + handler: 'service_cmd', + }, + '-', + { + text: 'Out', + iconCls: 'fa fa-circle-o', + disabled: true, + bind: { + disabled: '{!inOsd}', + }, + cmd: 'out', + handler: 'osd_cmd', + }, + { + text: 'In', + iconCls: 'fa fa-circle', + disabled: true, + bind: { + disabled: '{!outOsd}', + }, + cmd: 'in', + handler: 'osd_cmd', + }, + '-', + { + text: gettext('More'), + iconCls: 'fa fa-bars', + disabled: true, + bind: { + disabled: '{!isOsd}', + }, + menu: [ + { + text: gettext('Scrub'), + iconCls: 'fa fa-shower', + cmd: 'scrub', + handler: 'osd_cmd', + }, + { + text: gettext('Deep Scrub'), + iconCls: 'fa fa-bath', + cmd: 'scrub', + params: { + deep: 1, + }, + handler: 'osd_cmd', + }, + { + text: gettext('Destroy'), + itemId: 'remove', + iconCls: 'fa fa-fw fa-trash-o', + bind: { + disabled: '{!downOsd}', + }, + handler: 'destroy_osd', + }, + ], + }, + ], + }, + + fields: [ + 'name', + 'type', + 'status', + 'host', + 'in', + 'id', + { type: 'number', name: 'reweight' }, + { type: 'number', name: 'percent_used' }, + { type: 'integer', name: 'bytes_used' }, + { type: 'integer', name: 'total_space' }, + { type: 'integer', name: 'apply_latency_ms' }, + { type: 'integer', name: 'commit_latency_ms' }, + { type: 'string', name: 'device_class' }, + { type: 'string', name: 'osdtype' }, + { type: 'string', name: 'blfsdev' }, + { type: 'string', name: 'dbdev' }, + { type: 'string', name: 'waldev' }, + { + type: 'string', + name: 'version', + calculate: function (data) { + return PVE.Utils.parse_ceph_version(data); + }, + }, + { + type: 'string', + name: 'iconCls', + calculate: function (data) { + let iconMap = { + host: 'fa-building', + osd: 'fa-hdd-o', + root: 'fa-server', + }; + return `fa x-fa-tree ${iconMap[data.type] ?? 'fa-folder-o'}`; + }, + }, + { type: 'number', name: 'crush_weight' }, + ], +}); +Ext.define('pve-osd-details-devices', { + extend: 'Ext.data.Model', + fields: ['device', 'type', 'physical_device', 'size', 'support_discard', 'dev_node'], + idProperty: 'device', +}); + +Ext.define('PVE.CephOsdDetails', { + extend: 'Ext.window.Window', + alias: ['widget.pveCephOsdDetails'], + + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: function () { + let me = this; + me.baseUrl = `/nodes/${me.nodename}/ceph/osd/${me.osdid}`; + return { + title: `${gettext('Details')}: OSD ${me.osdid}`, + }; + }, + + viewModel: { + data: { + device: '', + }, + }, + + modal: true, + width: 650, + minHeight: 250, + resizable: true, + cbind: { + title: '{title}', + }, + + layout: { + type: 'vbox', + align: 'stretch', + }, + defaults: { + layout: 'fit', + border: false, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function () { + let view = this.getView(); + + Proxmox.Utils.API2Request({ + url: `${view.baseUrl}/metadata`, + waitMsgTarget: view.lookup('detailsTabs'), + method: 'GET', + failure: function (response, opts) { + Proxmox.Utils.setErrorMask(view.lookup('detailsTabs'), response.htmlStatus); + }, + success: function (response, opts) { + let d = response.result.data; + let osdData = Object.keys(d.osd) + .sort() + .map((x) => ({ key: x, value: d.osd[x] })); + view.osdStore.loadData(osdData); + let devices = view.lookup('devices'); + let deviceStore = devices.getStore(); + deviceStore.loadData(d.devices); + + view.lookup('osdGeneral').rstore.fireEvent( + 'load', + view.osdStore, + osdData, + true, + ); + view.lookup('osdNetwork').rstore.fireEvent( + 'load', + view.osdStore, + osdData, + true, + ); + + // select 'block' device automatically on first load + if (devices.getSelection().length === 0) { + devices.setSelection(deviceStore.findRecord('device', 'block')); + } + }, + }); + }, + + showDevInfo: function (grid, selected) { + let view = this.getView(); + if (selected[0]) { + let device = selected[0].data.device; + this.getViewModel().set('device', device); + + let detailStore = view.lookup('volumeDetails'); + detailStore.rstore.getProxy().setUrl(`api2/json${view.baseUrl}/lv-info`); + detailStore.rstore.getProxy().setExtraParams({ type: device }); + detailStore.setLoading(); + detailStore.rstore.load({ callback: () => detailStore.setLoading(false) }); + } + }, + + init: function () { + this.reload(); + }, + + control: { + 'grid[reference=devices]': { + selectionchange: 'showDevInfo', + }, + }, + }, + tbar: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: 'reload', + }, + ], + initComponent: function () { + let me = this; + + me.osdStore = Ext.create('Proxmox.data.ObjectStore'); + + Ext.applyIf(me, { + items: [ + { + xtype: 'tabpanel', + reference: 'detailsTabs', + items: [ + { + xtype: 'proxmoxObjectGrid', + reference: 'osdGeneral', + tooltip: gettext('Various information about the OSD'), + rstore: me.osdStore, + title: gettext('General'), + viewConfig: { + enableTextSelection: true, + }, + gridRows: [ + { + xtype: 'text', + name: 'version', + text: gettext('Version'), + }, + { + xtype: 'text', + name: 'hostname', + text: gettext('Hostname'), + }, + { + xtype: 'text', + name: 'osd_data', + text: gettext('OSD data path'), + }, + { + xtype: 'text', + name: 'osd_objectstore', + text: gettext('OSD object store'), + }, + { + xtype: 'text', + name: 'mem_usage', + text: gettext('Memory usage (PSS)'), + renderer: Proxmox.Utils.render_size, + }, + { + xtype: 'text', + name: 'pid', + text: `${gettext('Process ID')} (PID)`, + }, + ], + }, + { + xtype: 'proxmoxObjectGrid', + reference: 'osdNetwork', + tooltip: gettext('Addresses and ports used by the OSD service'), + rstore: me.osdStore, + title: gettext('Network'), + viewConfig: { + enableTextSelection: true, + }, + gridRows: [ + { + xtype: 'text', + name: 'front_addr', + text: `${gettext('Front Address')}
    (Client & Monitor)`, + renderer: PVE.Utils.render_ceph_osd_addr, + }, + { + xtype: 'text', + name: 'hb_front_addr', + text: gettext('Heartbeat Front Address'), + renderer: PVE.Utils.render_ceph_osd_addr, + }, + { + xtype: 'text', + name: 'back_addr', + text: `${gettext('Back Address')}
    (OSD)`, + renderer: PVE.Utils.render_ceph_osd_addr, + }, + { + xtype: 'text', + name: 'hb_back_addr', + text: gettext('Heartbeat Back Address'), + renderer: PVE.Utils.render_ceph_osd_addr, + }, + ], + }, + { + xtype: 'panel', + title: gettext('Devices'), + tooltip: gettext('Physical devices used by the OSD'), + items: [ + { + xtype: 'grid', + border: false, + reference: 'devices', + store: { + model: 'pve-osd-details-devices', + }, + columns: { + items: [ + { text: gettext('Device'), dataIndex: 'device' }, + { text: gettext('Type'), dataIndex: 'type' }, + { + text: gettext('Physical Device'), + dataIndex: 'physical_device', + }, + { + text: gettext('Size'), + dataIndex: 'size', + renderer: Proxmox.Utils.render_size, + }, + { + text: 'Discard', + dataIndex: 'support_discard', + hidden: true, + }, + { + text: gettext('Device node'), + dataIndex: 'dev_node', + hidden: true, + }, + ], + defaults: { + tdCls: 'pointer', + flex: 1, + }, + }, + }, + { + xtype: 'proxmoxObjectGrid', + reference: 'volumeDetails', + maskOnLoad: true, + viewConfig: { + enableTextSelection: true, + }, + bind: { + title: Ext.String.format( + gettext('Volume Details for {0}'), + '{device}', + ), + }, + rows: { + creation_time: { + header: gettext('Creation time'), + }, + lv_name: { + header: gettext('LV Name'), + }, + lv_path: { + header: gettext('LV Path'), + }, + lv_uuid: { + header: gettext('LV UUID'), + }, + vg_name: { + header: gettext('VG Name'), + }, + }, + url: 'nodes/', //placeholder will be set when device is selected + }, + ], + }, + ], + }, + ], + }); + + me.callParent(); + }, +}); +Ext.define('PVE.CephPoolInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveCephPoolInputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + showProgress: true, + onlineHelp: 'pve_ceph_pools', + + subject: 'Ceph Pool', + + defaultSize: undefined, + defaultMinSize: undefined, + + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + let vm = this.getViewModel(); + if (view.isCreate) { + vm.set('size', Number(view.defaultSize)); + vm.set('minSize', Number(view.defaultMinSize)); + } + }, + sizeChange: function (field, val) { + let vm = this.getViewModel(); + let minSize = Math.round(val / 2); + if (minSize > 1) { + vm.set('minSize', minSize); + } + vm.set('size', val); // bind does not work in a pmxDisplayEditField, update manually + }, + }, + + viewModel: { + data: { + minSize: null, + size: null, + }, + formulas: { + minSizeLabel: (get) => { + if (get('showMinSizeOneWarning') || get('showMinSizeHalfWarning')) { + return `${gettext('Min. Size')} `; + } + return gettext('Min. Size'); + }, + showMinSizeOneWarning: (get) => get('minSize') === 1, + showMinSizeHalfWarning: (get) => { + let minSize = get('minSize'); + let size = get('size'); + if (minSize === 1) { + return false; + } + return minSize < size / 2 && minSize !== size; + }, + }, + }, + + column1: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + cbind: { + editable: '{isCreate}', + value: '{pool_name}', + }, + name: 'name', + allowBlank: false, + }, + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{!isErasure}', + }, + fieldLabel: gettext('Size'), + name: 'size', + editConfig: { + xtype: 'proxmoxintegerfield', + cbind: { + value: (get) => get('defaultSize'), + }, + minValue: 2, + maxValue: 7, + allowBlank: false, + listeners: { + change: 'sizeChange', + }, + }, + }, + ], + column2: [ + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('PG Autoscaler Mode'), + name: 'pg_autoscale_mode', + comboItems: [ + ['warn', 'warn'], + ['on', 'on'], + ['off', 'off'], + ], + value: 'on', // FIXME: check ceph version and only default to on on octopus and newer + allowBlank: false, + autoSelect: false, + labelWidth: 140, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Add as Storage'), + cbind: { + value: '{isCreate}', + hidden: '{!isCreate}', + }, + name: 'add_storages', + labelWidth: 140, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Add the new pool to the cluster storage configuration.'), + }, + }, + ], + advancedColumn1: [ + { + xtype: 'proxmoxintegerfield', + bind: { + fieldLabel: '{minSizeLabel}', + value: '{minSize}', + }, + name: 'min_size', + cbind: { + value: (get) => get('defaultMinSize'), + minValue: (get) => { + if (Number(get('defaultMinSize')) === 1) { + return 1; + } else { + return get('isCreate') ? 2 : 1; + } + }, + }, + maxValue: 7, + allowBlank: false, + }, + { + xtype: 'displayfield', + bind: { + hidden: '{!showMinSizeHalfWarning}', + }, + hidden: true, + userCls: 'pmx-hint', + value: gettext( + 'min_size < size/2 can lead to data loss, incomplete PGs or unfound objects.', + ), + }, + { + xtype: 'displayfield', + bind: { + hidden: '{!showMinSizeOneWarning}', + }, + hidden: true, + userCls: 'pmx-hint', + value: gettext('a min_size of 1 is not recommended and can lead to data loss'), + }, + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{!isErasure}', + nodename: '{nodename}', + isCreate: '{isCreate}', + }, + fieldLabel: 'Crush Rule', // do not localize + name: 'crush_rule', + editConfig: { + xtype: 'pveCephRuleSelector', + allowBlank: false, + }, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: '# of PGs', + name: 'pg_num', + value: 128, + minValue: 1, + maxValue: 32768, + allowBlank: false, + emptyText: 128, + }, + ], + advancedColumn2: [ + { + xtype: 'numberfield', + fieldLabel: gettext('Target Ratio'), + name: 'target_size_ratio', + minValue: 0, + decimalPrecision: 3, + allowBlank: true, + emptyText: '0.0', + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'The ratio of storage amount this pool will consume compared to other pools with ratios. Used for auto-scaling.', + ), + }, + }, + { + xtype: 'pveSizeField', + name: 'target_size', + fieldLabel: gettext('Target Size'), + unit: 'GiB', + minValue: 0, + allowBlank: true, + allowZero: true, + emptyText: '0', + emptyValue: 0, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'The amount of data eventually stored in this pool. Used for auto-scaling.', + ), + }, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: Ext.String.format(gettext('{0} takes precedence.'), gettext('Target Ratio')), // FIXME: tooltip? + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: 'Min. # of PGs', + name: 'pg_num_min', + minValue: 0, + allowBlank: true, + emptyText: '0', + }, + ], + + onGetValues: function (values) { + Object.keys(values || {}).forEach(function (name) { + if (values[name] === '') { + delete values[name]; + } + }); + + return values; + }, +}); + +Ext.define('PVE.Ceph.PoolEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveCephPoolEdit', + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: { + pool_name: '', + isCreate: (cfg) => !cfg.pool_name, + defaultSize: undefined, + defaultMinSize: undefined, + }, + + cbind: { + autoLoad: (get) => !get('isCreate'), + url: (get) => + get('isCreate') + ? `/nodes/${get('nodename')}/ceph/pool` + : `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}`, + loadUrl: (get) => `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}/status`, + method: (get) => (get('isCreate') ? 'POST' : 'PUT'), + }, + + showProgress: true, + + subject: gettext('Ceph Pool'), + + items: [ + { + xtype: 'pveCephPoolInputPanel', + cbind: { + nodename: '{nodename}', + pool_name: '{pool_name}', + isErasure: '{isErasure}', + isCreate: '{isCreate}', + defaultSize: '{defaultSize}', + defaultMinSize: '{defaultMinSize}', + }, + }, + ], +}); + +Ext.define( + 'PVE.node.Ceph.PoolList', + { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveNodeCephPoolList', + + onlineHelp: 'chapter_pveceph', + + stateful: true, + stateId: 'grid-ceph-pools', + bufferedRenderer: false, + + features: [{ ftype: 'summary' }], + + columns: [ + { + text: gettext('Pool #'), + minWidth: 70, + flex: 1, + align: 'right', + sortable: true, + dataIndex: 'pool', + }, + { + text: gettext('Name'), + minWidth: 120, + flex: 2, + sortable: true, + dataIndex: 'pool_name', + renderer: Ext.htmlEncode, + }, + { + text: gettext('Type'), + minWidth: 100, + flex: 1, + dataIndex: 'type', + hidden: true, + }, + { + text: gettext('Application'), + minWidth: 100, + flex: 1, + dataIndex: 'application_metadata', + hidden: true, + renderer: (v, _meta, _rec) => Ext.htmlEncode(Object.keys(v).toString()), + }, + { + text: gettext('Size') + '/min', + minWidth: 100, + flex: 1, + align: 'right', + renderer: (v, meta, rec) => `${v}/${rec.data.min_size}`, + dataIndex: 'size', + }, + { + text: '# of Placement Groups', + flex: 1, + minWidth: 100, + align: 'right', + dataIndex: 'pg_num', + }, + { + text: gettext('Optimal # of PGs'), + flex: 1, + minWidth: 100, + align: 'right', + dataIndex: 'pg_num_final', + renderer: function (value, metaData) { + if (!value) { + value = ' n/a'; + metaData.tdAttr = 'data-qtip="Needs pg_autoscaler module enabled."'; + } + return value; + }, + }, + { + text: gettext('Min. # of PGs'), + flex: 1, + minWidth: 100, + align: 'right', + dataIndex: 'pg_num_min', + hidden: true, + }, + { + text: gettext('Target Ratio'), + flex: 1, + minWidth: 100, + align: 'right', + dataIndex: 'target_size_ratio', + renderer: Ext.util.Format.numberRenderer('0.0000'), + hidden: true, + }, + { + text: gettext('Target Size'), + flex: 1, + minWidth: 100, + align: 'right', + dataIndex: 'target_size', + hidden: true, + renderer: function (v, metaData, rec) { + let value = Proxmox.Utils.render_size(v); + if (rec.data.target_size_ratio > 0) { + value = ' ' + value; + metaData.tdAttr = + 'data-qtip="Target Size Ratio takes precedence over Target Size."'; + } + return value; + }, + }, + { + text: gettext('Autoscaler Mode'), + flex: 1, + minWidth: 100, + align: 'right', + dataIndex: 'pg_autoscale_mode', + }, + { + text: 'CRUSH Rule (ID)', + flex: 1, + align: 'right', + minWidth: 150, + renderer: (v, meta, rec) => Ext.htmlEncode(`${v} (${rec.data.crush_rule})`), + dataIndex: 'crush_rule_name', + }, + { + text: gettext('Used') + ' (%)', + flex: 1, + minWidth: 150, + sortable: true, + align: 'right', + dataIndex: 'bytes_used', + summaryType: 'sum', + summaryRenderer: Proxmox.Utils.render_size, + renderer: function (v, meta, rec) { + let percentage = Ext.util.Format.percent(rec.data.percent_used, '0.00'); + let used = Proxmox.Utils.render_size(v); + return `${used} (${percentage})`; + }, + }, + ], + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var rstore = Ext.create('Proxmox.data.UpdateStore', { + interval: 3000, + storeid: 'ceph-pool-list' + nodename, + model: 'ceph-pool-list', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${nodename}/ceph/pool`, + }, + }); + let store = Ext.create('Proxmox.data.DiffStore', { rstore: rstore }); + + // manages the "install ceph?" overlay + PVE.Utils.monitor_ceph_installed(me, rstore, nodename); + + var run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec || !rec.data.pool_name) { + return; + } + Ext.create('PVE.Ceph.PoolEdit', { + title: gettext('Edit') + ': Ceph Pool', + nodename: nodename, + pool_name: rec.data.pool_name, + isErasure: rec.data.type === 'erasure', + autoShow: true, + listeners: { + destroy: () => rstore.load(), + }, + }); + }; + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + { + text: gettext('Create'), + handler: function () { + let keys = [ + 'global:osd-pool-default-min-size', + 'global:osd-pool-default-size', + ]; + let params = { + 'config-keys': keys.join(';'), + }; + + Proxmox.Utils.API2Request({ + url: '/nodes/localhost/ceph/cfg/value', + method: 'GET', + params, + waitMsgTarget: me.getView(), + failure: (response) => + Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function ({ result: { data } }) { + let global = data.global; + let defaultSize = global?.['osd-pool-default-size'] ?? 3; + let defaultMinSize = global?.['osd-pool-default-min-size'] ?? 2; + + Ext.create('PVE.Ceph.PoolEdit', { + title: gettext('Create') + ': Ceph Pool', + isCreate: true, + isErasure: false, + defaultSize, + defaultMinSize, + nodename: nodename, + autoShow: true, + listeners: { + destroy: () => rstore.load(), + }, + }); + }, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + selModel: sm, + disabled: true, + handler: run_editor, + }, + { + xtype: 'proxmoxButton', + text: gettext('Destroy'), + selModel: sm, + disabled: true, + handler: function () { + let rec = sm.getSelection()[0]; + if (!rec || !rec.data.pool_name) { + return; + } + let poolName = rec.data.pool_name; + Ext.create('Proxmox.window.SafeDestroy', { + showProgress: true, + url: `/nodes/${nodename}/ceph/pool/${poolName}`, + params: { + remove_storages: 1, + }, + item: { + type: 'CephPool', + id: poolName, + }, + taskName: 'cephdestroypool', + autoShow: true, + listeners: { + destroy: () => rstore.load(), + }, + }); + }, + }, + ], + listeners: { + activate: () => rstore.startUpdate(), + destroy: () => rstore.stopUpdate(), + itemdblclick: run_editor, + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('ceph-pool-list', { + extend: 'Ext.data.Model', + fields: [ + 'pool_name', + { name: 'pool', type: 'integer' }, + { name: 'size', type: 'integer' }, + { name: 'min_size', type: 'integer' }, + { name: 'pg_num', type: 'integer' }, + { name: 'pg_num_min', type: 'integer' }, + { name: 'bytes_used', type: 'integer' }, + { name: 'percent_used', type: 'number' }, + { name: 'crush_rule', type: 'integer' }, + { name: 'crush_rule_name', type: 'string' }, + { name: 'pg_autoscale_mode', type: 'string' }, + { name: 'pg_num_final', type: 'integer' }, + { name: 'target_size_ratio', type: 'number' }, + { name: 'target_size', type: 'integer' }, + ], + idProperty: 'pool_name', + }); + }, +); + +Ext.define('PVE.form.CephRuleSelector', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveCephRuleSelector', + + allowBlank: false, + valueField: 'name', + displayField: 'name', + editable: false, + queryMode: 'local', + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + me.originalAllowBlank = me.allowBlank; + me.allowBlank = true; + + Ext.apply(me, { + store: { + fields: ['name'], + sorters: 'name', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/ceph/rules`, + }, + autoLoad: { + callback: (records, op, success) => { + if (me.isCreate && success && records.length > 0) { + me.select(records[0]); + } + + me.allowBlank = me.originalAllowBlank; + delete me.originalAllowBlank; + me.validate(); + }, + }, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.CephCreateService', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + xtype: 'pveCephCreateService', + + method: 'POST', + isCreate: true, + showProgress: true, + width: 450, + + setNode: function (node) { + let me = this; + me.nodename = node; + me.updateUrl(); + }, + setServiceID: function (value) { + let me = this; + me.serviceID = value; + me.updateUrl(); + }, + updateUrl: function () { + let me = this; + let node = me.nodename; + let serviceID = me.serviceID ?? me.nodename; + + me.url = `/nodes/${node}/ceph/${me.type}/${serviceID}`; + }, + + defaults: { + labelWidth: 75, + }, + items: [ + { + xtype: 'pveNodeSelector', + fieldLabel: gettext('Host'), + selectCurNode: true, + allowBlank: false, + submitValue: false, + listeners: { + change: function (f, value) { + let view = this.up('pveCephCreateService'); + view.lookup('mds-id').setValue(value); + view.setNode(value); + }, + }, + }, + { + xtype: 'textfield', + reference: 'mds-id', + fieldLabel: gettext('MDS ID'), + regex: /^([a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?)$/, + regexText: gettext( + 'ID may consist of alphanumeric characters and hyphen. It cannot start with a number or end in a hyphen.', + ), + submitValue: false, + allowBlank: false, + cbind: { + disabled: (get) => get('type') !== 'mds', + hidden: (get) => get('type') !== 'mds', + }, + listeners: { + change: function (f, value) { + let view = this.up('pveCephCreateService'); + view.setServiceID(value); + }, + }, + }, + { + xtype: 'component', + border: false, + padding: '5 2', + style: { + fontSize: '12px', + }, + userCls: 'pmx-hint', + cbind: { + hidden: (get) => get('type') !== 'mds', + }, + html: gettext( + 'By using different IDs, you can have multiple MDS per node, which increases redundancy with more than one CephFS.', + ), + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + if (!me.type) { + throw 'no type specified'; + } + me.setNode(me.nodename); + + me.callParent(); + }, +}); + +Ext.define('PVE.node.CephServiceController', { + extend: 'Ext.app.ViewController', + alias: 'controller.CephServiceList', + + render_status: (value, metadata, rec) => Ext.htmlEncode(value), + + render_version: function (value, metadata, rec) { + if (value === undefined) { + return ''; + } + let view = this.getView(); + let host = rec.data.host, + nodev = [0]; + if (view.nodeversions[host] !== undefined) { + nodev = view.nodeversions[host].version.parts; + } + + let icon = ''; + if (PVE.Utils.compare_ceph_versions(view.maxversion, nodev) > 0) { + icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE'); + } else if (PVE.Utils.compare_ceph_versions(nodev, value) > 0) { + icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD'); + } else if (view.mixedversions) { + icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK'); + } + return icon + value; + }, + + getMaxVersions: function (store, records, success) { + if (!success || records.length < 1) { + return; + } + let me = this; + let view = me.getView(); + + view.nodeversions = records[0].data.node; + view.maxversion = []; + view.mixedversions = false; + for (const [_nodename, data] of Object.entries(view.nodeversions)) { + let res = PVE.Utils.compare_ceph_versions(data.version.parts, view.maxversion); + if (res !== 0 && view.maxversion.length > 0) { + view.mixedversions = true; + } + if (res > 0) { + view.maxversion = data.version.parts; + } + } + }, + + init: function (view) { + if (view.pveSelNode) { + view.nodename = view.pveSelNode.data.node; + } + if (!view.nodename) { + throw 'no node name specified'; + } + + if (!view.type) { + throw 'no type specified'; + } + + view.versionsstore = Ext.create('Proxmox.data.UpdateStore', { + autoStart: true, + interval: 10000, + storeid: `ceph-versions-${view.type}-list${view.nodename}`, + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/ceph/metadata?scope=versions', + }, + }); + view.versionsstore.on('load', this.getMaxVersions, this); + view.on('destroy', view.versionsstore.stopUpdate); + + view.rstore = Ext.create('Proxmox.data.UpdateStore', { + autoStart: true, + interval: 3000, + storeid: `ceph-${view.type}-list${view.nodename}`, + model: 'ceph-service-list', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${view.nodename}/ceph/${view.type}`, + }, + }); + + view.setStore( + Ext.create('Proxmox.data.DiffStore', { + rstore: view.rstore, + sorters: [{ property: 'name' }], + }), + ); + + if (view.storeLoadCallback) { + view.rstore.on('load', view.storeLoadCallback, this); + } + view.on('destroy', view.rstore.stopUpdate); + + if (view.showCephInstallMask) { + PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true); + } + }, + + service_cmd: function (rec, cmd) { + let view = this.getView(); + if (!rec.data.host) { + Ext.Msg.alert(gettext('Error'), 'entry has no host'); + return; + } + let doRequest = function () { + Proxmox.Utils.API2Request({ + url: `/nodes/${rec.data.host}/ceph/${cmd}`, + method: 'POST', + params: { service: view.type + '.' + rec.data.name }, + success: function (response, options) { + Ext.create('Proxmox.window.TaskProgress', { + autoShow: true, + upid: response.result.data, + taskDone: () => view.rstore.load(), + }); + }, + failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }; + if (cmd === 'stop' && ['mon', 'mds'].includes(view.type)) { + Proxmox.Utils.API2Request({ + url: `/nodes/${rec.data.host}/ceph/cmd-safety`, + params: { + service: view.type, + id: rec.data.name, + action: 'stop', + }, + method: 'GET', + success: function ({ result: { data } }) { + let stopText = { + mon: gettext('Stop MON'), + mds: gettext('Stop MDS'), + }; + if (!data.safe) { + Ext.Msg.show({ + title: ngettext('Warning', 'Warnings', 1), + message: data.status, + icon: Ext.Msg.WARNING, + buttons: Ext.Msg.OKCANCEL, + buttonText: { ok: stopText[view.type] }, + fn: function (selection) { + if (selection === 'ok') { + doRequest(); + } + }, + }); + } else { + doRequest(); + } + }, + failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + } else { + doRequest(); + } + }, + onChangeService: function (button) { + let me = this; + let record = me.getView().getSelection()[0]; + me.service_cmd(record, button.action); + }, + + showSyslog: function () { + let view = this.getView(); + let rec = view.getSelection()[0]; + let service = `ceph-${view.type}@${rec.data.name}`; + Ext.create('Ext.window.Window', { + title: `${gettext('Syslog')}: ${service}`, + autoShow: true, + modal: true, + width: 800, + height: 400, + layout: 'fit', + items: [ + { + xtype: 'proxmoxLogView', + url: `/api2/extjs/nodes/${rec.data.host}/syslog?service=${encodeURIComponent(service)}`, + log_select_timespan: 1, + }, + ], + }); + }, + + onCreate: function () { + let view = this.getView(); + Ext.create('PVE.CephCreateService', { + autoShow: true, + nodename: view.nodename, + subject: view.getTitle(), + type: view.type, + taskDone: () => view.rstore.load(), + }); + }, +}); + +Ext.define( + 'PVE.node.CephServiceList', + { + extend: 'Ext.grid.GridPanel', + xtype: 'pveNodeCephServiceList', + + onlineHelp: 'chapter_pveceph', + emptyText: gettext('No such service configured.'), + + stateful: true, + + // will be called when the store loads + storeLoadCallback: Ext.emptyFn, + + // if set to true, does shows the ceph install mask if needed + showCephInstallMask: false, + + controller: 'CephServiceList', + + tbar: [ + { + xtype: 'proxmoxButton', + text: gettext('Start'), + iconCls: 'fa fa-play', + action: 'start', + disabled: true, + enableFn: (rec) => rec.data.state === 'stopped' || rec.data.state === 'unknown', + handler: 'onChangeService', + }, + { + xtype: 'proxmoxButton', + text: gettext('Stop'), + iconCls: 'fa fa-stop', + action: 'stop', + enableFn: (rec) => rec.data.state !== 'stopped', + disabled: true, + handler: 'onChangeService', + }, + { + xtype: 'proxmoxButton', + text: gettext('Restart'), + iconCls: 'fa fa-refresh', + action: 'restart', + disabled: true, + enableFn: (rec) => rec.data.state !== 'stopped', + handler: 'onChangeService', + }, + '-', + { + text: gettext('Create'), + reference: 'createButton', + handler: 'onCreate', + }, + { + text: gettext('Destroy'), + xtype: 'proxmoxStdRemoveButton', + getUrl: function (rec) { + let view = this.up('grid'); + if (!rec.data.host) { + Ext.Msg.alert(gettext('Error'), 'entry has no host, cannot build API url'); + return ''; + } + return `/nodes/${rec.data.host}/ceph/${view.type}/${rec.data.name}`; + }, + callback: function (options, success, response) { + let view = this.up('grid'); + if (!success) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + return; + } + Ext.create('Proxmox.window.TaskProgress', { + autoShow: true, + upid: response.result.data, + taskDone: () => view.rstore.load(), + }); + }, + handler: function (btn, event, rec) { + let me = this; + let view = me.up('grid'); + let doRequest = function () { + Proxmox.button.StdRemoveButton.prototype.handler.call(me, btn, event, rec); + }; + if (view.type === 'mon') { + Proxmox.Utils.API2Request({ + url: `/nodes/${rec.data.host}/ceph/cmd-safety`, + params: { + service: view.type, + id: rec.data.name, + action: 'destroy', + }, + method: 'GET', + success: function ({ result: { data } }) { + if (!data.safe) { + Ext.Msg.show({ + title: ngettext('Warning', 'Warnings', 1), + message: data.status, + icon: Ext.Msg.WARNING, + buttons: Ext.Msg.OKCANCEL, + buttonText: { ok: gettext('Destroy MON') }, + fn: function (selection) { + if (selection === 'ok') { + doRequest(); + } + }, + }); + } else { + doRequest(); + } + }, + failure: (response, _opts) => + Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + } else { + doRequest(); + } + }, + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Syslog'), + disabled: true, + handler: 'showSyslog', + }, + ], + + columns: [ + { + header: gettext('Name'), + flex: 1, + sortable: true, + renderer: function (v) { + return this.type + '.' + v; + }, + dataIndex: 'name', + }, + { + header: gettext('Host'), + flex: 1, + sortable: true, + renderer: function (v) { + return v || Proxmox.Utils.unknownText; + }, + dataIndex: 'host', + }, + { + header: gettext('Status'), + flex: 1, + sortable: false, + renderer: 'render_status', + dataIndex: 'state', + }, + { + header: gettext('Address'), + flex: 3, + sortable: true, + renderer: function (v) { + return v || Proxmox.Utils.unknownText; + }, + dataIndex: 'addr', + }, + { + header: gettext('Version'), + flex: 3, + sortable: true, + dataIndex: 'version', + renderer: 'render_version', + }, + ], + + initComponent: function () { + let me = this; + + if (me.additionalColumns) { + me.columns = me.columns.concat(me.additionalColumns); + } + + me.callParent(); + }, + }, + function () { + Ext.define('ceph-service-list', { + extend: 'Ext.data.Model', + fields: [ + 'addr', + 'name', + 'fs_name', + 'rank', + 'host', + 'quorum', + 'state', + 'ceph_version', + 'ceph_version_short', + { + type: 'string', + name: 'version', + calculate: (data) => PVE.Utils.parse_ceph_version(data), + }, + ], + idProperty: 'name', + }); + }, +); + +Ext.define('PVE.node.CephMDSServiceController', { + extend: 'PVE.node.CephServiceController', + alias: 'controller.CephServiceMDSList', + + render_status: (value, mD, rec) => + Ext.htmlEncode(rec.data.fs_name ? `${value} (${rec.data.fs_name})` : value), +}); + +Ext.define('PVE.node.CephMDSList', { + extend: 'PVE.node.CephServiceList', + xtype: 'pveNodeCephMDSList', + + controller: { + type: 'CephServiceMDSList', + }, +}); +Ext.define('PVE.ceph.Services', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveCephServices', + + layout: { + type: 'hbox', + align: 'stretch', + }, + + bodyPadding: '0 5 20', + defaults: { + xtype: 'box', + style: { + 'text-align': 'center', + }, + }, + + items: [ + { + flex: 1, + xtype: 'pveCephServiceList', + itemId: 'mons', + title: gettext('Monitors'), + }, + { + flex: 1, + xtype: 'pveCephServiceList', + itemId: 'mgrs', + title: gettext('Managers'), + }, + { + flex: 1, + xtype: 'pveCephServiceList', + itemId: 'mdss', + title: gettext('Metadata Servers'), + }, + ], + + updateAll: function (metadata, status) { + var me = this; + + const healthstates = { + HEALTH_UNKNOWN: 0, + HEALTH_ERR: 1, + HEALTH_WARN: 2, + HEALTH_UPGRADE: 3, + HEALTH_OLD: 4, + HEALTH_OK: 5, + }; + // order guarantee since es2020, but browsers did so before. Note, integers would break it. + const healthmap = Object.keys(healthstates); + let maxversion = '00.0.00'; + Object.values(metadata.node || {}).forEach(function (node) { + if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) { + maxversion = node?.version?.parts; + } + }); + var quorummap = status && status.quorum_names ? status.quorum_names : []; + let monmessages = {}, + mgrmessages = {}, + mdsmessages = {}; + if (status) { + if (status.health) { + Ext.Object.each(status.health.checks, function (key, value, _obj) { + if (!Ext.String.startsWith(key, 'MON_')) { + return; + } + for (let i = 0; i < value.detail.length; i++) { + let match = value.detail[i].message.match(/mon.([a-zA-Z0-9\-.]+)/); + if (!match) { + continue; + } + let monid = match[1]; + if (!monmessages[monid]) { + monmessages[monid] = { + worstSeverity: healthstates.HEALTH_OK, + messages: [], + }; + } + + let severityIcon = PVE.Utils.get_ceph_icon_html(value.severity, true); + let details = value.detail.reduce((acc, v) => `${acc}\n${v.message}`, ''); + monmessages[monid].messages.push(severityIcon + details); + + if (healthstates[value.severity] < monmessages[monid].worstSeverity) { + monmessages[monid].worstSeverity = healthstates[value.severity]; + } + } + }); + } + + if (status.mgrmap) { + mgrmessages[status.mgrmap.active_name] = 'active'; + status.mgrmap.standbys.forEach(function (mgr) { + mgrmessages[mgr.name] = 'standby'; + }); + } + + if (status.fsmap) { + status.fsmap.by_rank.forEach(function (mds) { + mdsmessages[mds.name] = 'rank: ' + mds.rank + '; ' + mds.status; + }); + } + } + + let checks = { + mon: function (mon) { + if (quorummap.indexOf(mon.name) !== -1) { + mon.health = healthstates.HEALTH_OK; + } else { + mon.health = healthstates.HEALTH_ERR; + } + if (monmessages[mon.name]) { + if (monmessages[mon.name].worstSeverity < mon.health) { + mon.health = monmessages[mon.name].worstSeverity; + } + Array.prototype.push.apply(mon.messages, monmessages[mon.name].messages); + } + return mon; + }, + mgr: function (mgr) { + if (mgrmessages[mgr.name] === 'active') { + mgr.title = '' + mgr.title + ''; + mgr.statuses.push(gettext('Status') + ': active'); + } else if (mgrmessages[mgr.name] === 'standby') { + mgr.statuses.push(gettext('Status') + ': standby'); + } else if (mgr.health > healthstates.HEALTH_WARN) { + mgr.health = healthstates.HEALTH_WARN; + } + + return mgr; + }, + mds: function (mds) { + if (mdsmessages[mds.name]) { + mds.title = '' + mds.title + ''; + mds.statuses.push(gettext('Status') + ': ' + mdsmessages[mds.name] + ''); + } else if (mds.addr !== Proxmox.Utils.unknownText) { + mds.statuses.push(gettext('Status') + ': standby'); + } + + return mds; + }, + }; + + for (let type of ['mon', 'mgr', 'mds']) { + let ids = Object.keys(metadata[type] || {}); + me[type] = {}; + + for (let id of ids) { + const [name, host] = id.split('@'); + let result = { + id: id, + health: healthstates.HEALTH_OK, + statuses: [], + messages: [], + name: name, + title: metadata[type][id].name || name, + host: host, + version: PVE.Utils.parse_ceph_version(metadata[type][id]), + service: metadata[type][id].service, + addr: + metadata[type][id].addr || + metadata[type][id].addrs || + Proxmox.Utils.unknownText, + }; + + result.statuses = [ + gettext('Host') + ': ' + host, + gettext('Address') + ': ' + result.addr, + ]; + + if (checks[type]) { + result = checks[type](result); + } + + if (result.service && !result.version) { + result.messages.push( + PVE.Utils.get_ceph_icon_html('HEALTH_UNKNOWN', true) + gettext('Stopped'), + ); + result.health = healthstates.HEALTH_UNKNOWN; + } + + if (!result.version && result.addr === Proxmox.Utils.unknownText) { + result.health = healthstates.HEALTH_UNKNOWN; + } + + if (result.version) { + result.statuses.push(gettext('Version') + ': ' + result.version); + + if (PVE.Utils.compare_ceph_versions(result.version, maxversion) !== 0) { + let host_version = + metadata.node[host]?.version?.parts || metadata.version?.[host] || ''; + if (PVE.Utils.compare_ceph_versions(host_version, maxversion) === 0) { + if (result.health > healthstates.HEALTH_OLD) { + result.health = healthstates.HEALTH_OLD; + } + result.messages.push( + PVE.Utils.get_ceph_icon_html('HEALTH_OLD', true) + + gettext( + 'A newer version was installed but old version still running, please restart', + ), + ); + } else { + if (result.health > healthstates.HEALTH_UPGRADE) { + result.health = healthstates.HEALTH_UPGRADE; + } + result.messages.push( + PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE', true) + + gettext( + 'Other cluster members use a newer version of this service, please upgrade and restart', + ), + ); + } + } + } + + result.statuses.push(''); // empty line + result.text = result.statuses.concat(result.messages).join('
    '); + + result.health = healthmap[result.health]; + + me[type][id] = result; + } + } + + me.getComponent('mons').updateAll(Object.values(me.mon)); + me.getComponent('mgrs').updateAll(Object.values(me.mgr)); + me.getComponent('mdss').updateAll(Object.values(me.mds)); + }, +}); + +Ext.define('PVE.ceph.ServiceList', { + extend: 'Ext.container.Container', + xtype: 'pveCephServiceList', + + style: { + 'text-align': 'center', + }, + defaults: { + xtype: 'box', + style: { + 'text-align': 'center', + }, + }, + + items: [ + { + itemId: 'title', + data: { + title: '', + }, + tpl: '

    {title}

    ', + }, + ], + + updateAll: function (list) { + var me = this; + me.suspendLayout = true; + + list.sort((a, b) => (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)); + if (!me.ids) { + me.ids = []; + } + let pendingRemoval = {}; + me.ids.forEach((id) => { + pendingRemoval[id] = true; + }); // mark all as to-remove first here + + for (let i = 0; i < list.length; i++) { + let service = me.getComponent(list[i].id); + if (!service) { + // services and list are sorted, so just insert at i + 1 (first el. is the title) + service = me.insert(i + 1, { + xtype: 'pveCephServiceWidget', + itemId: list[i].id, + }); + me.ids.push(list[i].id); + } else { + delete pendingRemoval[list[i].id]; // drop existing from for-removal + } + service.updateService(list[i].title, list[i].text, list[i].health); + } + Object.keys(pendingRemoval).forEach((id) => me.remove(id)); // GC + + me.suspendLayout = false; + me.updateLayout(); + }, + + initComponent: function () { + var me = this; + me.callParent(); + me.getComponent('title').update({ + title: me.title, + }); + }, +}); + +Ext.define('PVE.ceph.ServiceWidget', { + extend: 'Ext.Component', + alias: 'widget.pveCephServiceWidget', + + userCls: 'monitor inline-block', + data: { + title: '0', + health: 'HEALTH_ERR', + text: '', + iconCls: PVE.Utils.get_health_icon(), + }, + + tpl: ['{title}: ', ''], + + updateService: function (title, text, health) { + var me = this; + + me.update( + Ext.apply(me.data, { + health: health, + text: text, + title: title, + iconCls: PVE.Utils.get_health_icon(PVE.Utils.map_ceph_health[health]), + }), + ); + + if (me.tooltip) { + me.tooltip.setHtml(text); + } + }, + + listeners: { + destroy: function () { + let me = this; + if (me.tooltip) { + me.tooltip.destroy(); + delete me.tooltip; + } + }, + mouseenter: { + element: 'el', + fn: function (events, element) { + let view = this.component; + if (!view) { + return; + } + if (!view.tooltip || view.data.text !== view.tooltip.html) { + view.tooltip = Ext.create('Ext.tip.ToolTip', { + target: view.el, + trackMouse: true, + dismissDelay: 0, + renderTo: Ext.getBody(), + html: view.data.text, + }); + } + view.tooltip.show(); + }, + }, + mouseleave: { + element: 'el', + fn: function (events, element) { + let view = this.component; + if (view.tooltip) { + view.tooltip.destroy(); + delete view.tooltip; + } + }, + }, + }, +}); +Ext.define('pve-ceph-warnings', { + extend: 'Ext.data.Model', + fields: ['id', 'summary', 'detail', 'severity'], + idProperty: 'id', +}); + +Ext.define('PVE.node.CephStatus', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeCephStatus', + + onlineHelp: 'chapter_pveceph', + + scrollable: true, + bodyPadding: 5, + layout: { + type: 'column', + }, + + defaults: { + padding: 5, + }, + + items: [ + { + xtype: 'panel', + title: gettext('Health'), + bodyPadding: 10, + plugins: 'responsive', + responsiveConfig: { + 'width < 1600': { + minHeight: 230, + columnWidth: 1, + }, + 'width >= 1600': { + minHeight: 500, + columnWidth: 0.5, + }, + }, + layout: { + type: 'hbox', + align: 'stretch', + }, + items: [ + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + flex: 1, + items: [ + { + xtype: 'pveHealthWidget', + itemId: 'overallhealth', + flex: 1, + title: gettext('Status'), + }, + { + xtype: 'displayfield', + itemId: 'versioninfo', + fieldLabel: gettext('Ceph Version'), + value: '', + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'The newest version installed in the Cluster.', + ), + }, + padding: '10 0 0 0', + style: { + 'text-align': 'center', + }, + }, + ], + }, + { + xtype: 'grid', + itemId: 'warnings', + flex: 2, + maxHeight: 430, + stateful: true, + stateId: 'ceph-status-warnings', + viewConfig: { + enableTextSelection: true, + listeners: { + collapsebody: function (rowNode, record) { + record.set('expanded', false); + record.commit(); + }, + expandbody: function (rowNode, record) { + record.set('expanded', true); + record.commit(); + }, + }, + }, + // we load the store manually, to show an emptyText specify an empty intermediate store + store: { + type: 'diff', + trackRemoved: false, + data: [], + rstore: { + storeid: 'pve-ceph-warnings', + type: 'update', + model: 'pve-ceph-warnings', + }, + }, + updateHealth: function (health) { + let checks = health.checks || {}; + + let checkRecords = Object.keys(checks) + .sort() + .map((key) => { + let check = checks[key]; + let data = { + id: key, + summary: check.summary.message, + detail: check.detail + .reduce((acc, v) => `${acc}\n${v.message}`, '') + .trimStart(), + severity: check.severity, + }; + data.noDetails = data.detail.length === 0; + data.detailsCls = data.detail.length === 0 ? 'pmx-opacity-75' : ''; + if (data.detail.length === 0) { + data.detail = 'no additional data'; + } + return data; + }); + + let rstore = this.getStore().rstore; + rstore.loadData(checkRecords, false); + rstore.fireEvent('load', rstore, checkRecords, true); + }, + emptyText: gettext('No Warnings/Errors'), + columns: [ + { + dataIndex: 'severity', + tooltip: gettext('Severity'), + align: 'center', + width: 38, + renderer: function (value) { + let health = PVE.Utils.map_ceph_health[value]; + let icon = PVE.Utils.get_health_icon(health); + return ``; + }, + sorter: { + sorterFn: function (a, b) { + let health = ['HEALTH_ERR', 'HEALTH_WARN', 'HEALTH_OK']; + return ( + health.indexOf(b.data.severity) - + health.indexOf(a.data.severity) + ); + }, + }, + }, + { + dataIndex: 'summary', + header: gettext('Summary'), + renderer: function (value, metaData, record, rI, cI, store, view) { + if (record.get('expanded')) { + metaData.tdCls = 'pmx-column-wrapped'; + } + return Ext.htmlEncode(value); + }, + flex: 1, + }, + { + xtype: 'actioncolumn', + width: 50, + align: 'center', + tooltip: gettext('Actions'), + items: [ + { + iconCls: 'x-fa fa-clipboard', + tooltip: gettext('Copy to Clipboard'), + handler: function ( + grid, + rowindex, + colindex, + item, + e, + { data }, + ) { + let detail = data.noDetails ? '' : `\n${data.detail}`; + navigator.clipboard + .writeText(`${data.severity}: ${data.summary}${detail}`) + .catch((err) => Ext.Msg.alert(gettext('Error'), err)); + }, + }, + ], + }, + ], + listeners: { + itemdblclick: function (view, record, row, rowIdx, e) { + // inspired by Ext.grid.plugin.RowExpander, but for double click + let rowNode = view.getNode(rowIdx); + let normalRow = Ext.fly(rowNode); + + let collapsedCls = view.rowBodyFeature.rowCollapsedCls; + + if (normalRow.hasCls(collapsedCls)) { + view.rowBodyFeature.rowExpander.toggleRow(rowIdx, record); + } + }, + }, + plugins: [ + { + ptype: 'rowexpander', + expandOnDblClick: false, + scrollIntoViewOnExpand: false, + rowBodyTpl: [ + '
    ',
    +                                '{detail:htmlEncode}',
    +                                '
    ', + ], + }, + ], + }, + ], + }, + { + xtype: 'pveCephStatusDetail', + itemId: 'statusdetail', + plugins: 'responsive', + responsiveConfig: { + 'width < 1600': { + columnWidth: 1, + minHeight: 250, + }, + 'width >= 1600': { + columnWidth: 0.5, + minHeight: 300, + }, + }, + title: gettext('Status'), + }, + { + xtype: 'pveCephServices', + title: gettext('Services'), + itemId: 'services', + plugins: 'responsive', + layout: { + type: 'hbox', + align: 'stretch', + }, + responsiveConfig: { + 'width < 1600': { + columnWidth: 1, + minHeight: 200, + }, + 'width >= 1600': { + columnWidth: 0.5, + minHeight: 200, + }, + }, + }, + { + xtype: 'panel', + title: gettext('Performance'), + columnWidth: 1, + bodyPadding: 5, + layout: { + type: 'hbox', + align: 'center', + }, + items: [ + { + xtype: 'container', + flex: 1, + items: [ + { + xtype: 'proxmoxGauge', + itemId: 'space', + title: gettext('Usage'), + }, + { + flex: 1, + border: false, + }, + { + xtype: 'container', + itemId: 'recovery', + hidden: true, + padding: 25, + items: [ + { + xtype: 'pveRunningChart', + itemId: 'recoverychart', + title: gettext('Recovery') + '/ ' + gettext('Rebalance'), + renderer: PVE.Utils.render_bandwidth, + height: 100, + }, + { + xtype: 'progressbar', + itemId: 'recoveryprogress', + }, + ], + }, + ], + }, + { + xtype: 'container', + flex: 2, + defaults: { + padding: 0, + height: 100, + }, + items: [ + { + xtype: 'pveRunningChart', + itemId: 'reads', + title: gettext('Reads'), + renderer: PVE.Utils.render_bandwidth, + }, + { + xtype: 'pveRunningChart', + itemId: 'writes', + title: gettext('Writes'), + renderer: PVE.Utils.render_bandwidth, + }, + { + xtype: 'pveRunningChart', + itemId: 'readiops', + title: 'IOPS: ' + gettext('Reads'), + renderer: Ext.util.Format.numberRenderer('0,000'), + }, + { + xtype: 'pveRunningChart', + itemId: 'writeiops', + title: 'IOPS: ' + gettext('Writes'), + renderer: Ext.util.Format.numberRenderer('0,000'), + }, + ], + }, + ], + }, + ], + + updateAll: function (store, records, success) { + if (!success || records.length === 0) { + return; + } + + var me = this; + var rec = records[0]; + me.status = rec.data; + + // add health panel + me.down('#overallhealth').updateHealth(PVE.Utils.render_ceph_health(rec.data.health || {})); + me.down('#warnings').updateHealth(rec.data.health || {}); // add errors to gridstore + + me.getComponent('services').updateAll(me.metadata || {}, rec.data); + + me.getComponent('statusdetail').updateAll(me.metadata || {}, rec.data); + + // add performance data + let pgmap = rec.data.pgmap; + let used = pgmap.bytes_used; + let total = pgmap.bytes_total; + + var text = Ext.String.format( + gettext('{0} of {1}'), + Proxmox.Utils.render_size(used), + Proxmox.Utils.render_size(total), + ); + + // update the usage widget + const usage = total > 0 ? used / total : 0; + me.down('#space').updateValue(usage, text); + + let readiops = pgmap.read_op_per_sec; + let writeiops = pgmap.write_op_per_sec; + let reads = pgmap.read_bytes_sec || 0; + let writes = pgmap.write_bytes_sec || 0; + + // update the graphs + me.reads.addDataPoint(reads); + me.writes.addDataPoint(writes); + me.readiops.addDataPoint(readiops); + me.writeiops.addDataPoint(writeiops); + + let degraded = pgmap.degraded_objects || 0; + let misplaced = pgmap.misplaced_objects || 0; + let unfound = pgmap.unfound_objects || 0; + let unhealthy = degraded + unfound + misplaced; + // update recovery + if (pgmap.recovering_objects_per_sec !== undefined || unhealthy > 0) { + let toRecoverObjects = + pgmap.misplaced_total || pgmap.unfound_total || pgmap.degraded_total || 0; + if (toRecoverObjects === 0) { + return; // FIXME: unexpected return and leaves things possible visible when it shouldn't? + } + let recovered = toRecoverObjects - unhealthy || 0; + let speed = pgmap.recovering_bytes_per_sec || 0; + + let recoveryRatio = recovered / toRecoverObjects; + let txt = `${(recoveryRatio * 100).toFixed(2)}%`; + if (speed > 0) { + let obj_per_sec = speed / (4 * 1024 * 1024); // 4 MiB per Object + let duration = Proxmox.Utils.format_duration_human(unhealthy / obj_per_sec); + let speedTxt = PVE.Utils.render_bandwidth(speed); + txt += ` (${speedTxt} - ${duration} left)`; + } + + me.down('#recovery').setVisible(true); + me.down('#recoveryprogress').updateValue(recoveryRatio); + me.down('#recoveryprogress').updateText(txt); + me.down('#recoverychart').addDataPoint(speed); + } else { + me.down('#recovery').setVisible(false); + me.down('#recoverychart').addDataPoint(0); + } + }, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + + me.callParent(); + var baseurl = '/api2/json' + (nodename ? '/nodes/' + nodename : '/cluster') + '/ceph'; + me.store = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'ceph-status-' + (nodename || 'cluster'), + interval: 5000, + proxy: { + type: 'proxmox', + url: baseurl + '/status', + }, + }); + + me.metadatastore = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'ceph-metadata-' + (nodename || 'cluster'), + interval: 15 * 1000, + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/ceph/metadata', + }, + }); + + // save references for the updatefunction + me.iops = me.down('#iops'); + me.readiops = me.down('#readiops'); + me.writeiops = me.down('#writeiops'); + me.reads = me.down('#reads'); + me.writes = me.down('#writes'); + + // manages the "install ceph?" overlay + PVE.Utils.monitor_ceph_installed(me, me.store, nodename); + + me.mon(me.store, 'load', me.updateAll, me); + me.mon( + me.metadatastore, + 'load', + function (store, records, success) { + if (!success || records.length < 1) { + return; + } + me.metadata = records[0].data; + + // update services + me.getComponent('services').updateAll(me.metadata, me.status || {}); + + // update detailstatus panel + me.getComponent('statusdetail').updateAll(me.metadata, me.status || {}); + + let maxversion = []; + let maxversiontext = ''; + for (const [_nodename, data] of Object.entries(me.metadata.node)) { + let version = data.version.parts; + if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) { + maxversion = version; + maxversiontext = data.version.str; + } + } + me.down('#versioninfo').setValue(maxversiontext); + }, + me, + ); + + me.on('destroy', me.store.stopUpdate); + me.on('destroy', me.metadatastore.stopUpdate); + me.store.startUpdate(); + me.metadatastore.startUpdate(); + }, +}); +Ext.define('PVE.ceph.StatusDetail', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveCephStatusDetail', + + layout: { + type: 'hbox', + align: 'stretch', + }, + + bodyPadding: '0 5', + defaults: { + xtype: 'box', + style: { + 'text-align': 'center', + }, + }, + + items: [ + { + flex: 1, + itemId: 'osds', + maxHeight: 250, + scrollable: true, + padding: '0 10 5 10', + data: { + total: 0, + upin: 0, + upout: 0, + downin: 0, + downout: 0, + oldOSD: [], + ghostOSD: [], + }, + tpl: [ + '

    OSDs

    ', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    ', + gettext('In'), + '', + gettext('Out'), + '
    ', + gettext('Up'), + '{upin}{upout}
    ', + gettext('Down'), + '{downin}{downout}
    ', + '
    ', + gettext('Total'), + ': {total}', + '

    ', + '', + ' ' + gettext('Outdated OSDs') + '
    ', + '
    ', + '', + '
    osd.{id}:
    ', + '
    {version}

    ', + '
    ', + '
    ', + '
    ', + '
    ', + '', + '', + '
    ', + ` ${gettext('Ghost OSDs')}
    `, + `
    `, + '', + '
    osd.{id}
    ', + '
    ', + '
    ', + '
    ', + '
    ', + ], + }, + { + flex: 1, + border: false, + itemId: 'pgchart', + xtype: 'polar', + height: 184, + innerPadding: 5, + insetPadding: 5, + colors: ['#CFCFCF', '#21BF4B', '#3892d4', '#FFCC00', '#FF6C59'], + store: {}, + series: [ + { + type: 'pie', + donut: 60, + angleField: 'count', + tooltip: { + trackMouse: true, + renderer: function (tooltip, record, ctx) { + var html = record.get('text'); + html += '
    '; + record.get('states').forEach(function (state) { + html += '
    ' + state.state_name + ': ' + state.count.toString(); + }); + tooltip.setHtml(html); + }, + }, + subStyle: { + strokeStyle: false, + }, + }, + ], + }, + { + flex: 1.6, + itemId: 'pgs', + padding: '0 10', + maxHeight: 250, + scrollable: true, + data: { + states: [], + }, + tpl: [ + '

    PGs

    ', + '', + '
    {state_name}:
    ', + '
    {count}

    ', + '
    ', + '
    ', + ], + }, + ], + + // similar to mgr dashboard + pgstates: { + // clean + clean: 1, + active: 1, + + // busy + activating: 2, + backfill_wait: 2, + backfilling: 2, + creating: 2, + deep: 2, + forced_backfill: 2, + forced_recovery: 2, + peered: 2, + peering: 2, + recovering: 2, + recovery_wait: 2, + remapped: 2, + repair: 2, + scrubbing: 2, + snaptrim: 2, + snaptrim_wait: 2, + + // warning + degraded: 3, + undersized: 3, + + // critical + backfill_toofull: 4, + backfill_unfound: 4, + down: 4, + incomplete: 4, + inconsistent: 4, + recovery_toofull: 4, + recovery_unfound: 4, + snaptrim_error: 4, + stale: 4, + }, + + statecategories: [ + { + text: gettext('Unknown'), + count: 0, + states: [], + cls: 'faded', + }, + { + text: gettext('Clean'), + cls: 'good', + }, + { + text: gettext('Busy'), + cls: 'pve-ceph-status-busy', + }, + { + text: ngettext('Warning', 'Warnings', 1), + cls: 'warning', + }, + { + text: gettext('Critical'), + cls: 'critical', + }, + ], + + checkThemeColors: function () { + let me = this; + let rootStyle = getComputedStyle(document.documentElement); + + // get color + let background = rootStyle.getPropertyValue('--pwt-panel-background').trim() || '#ffffff'; + + // set the colors + me.chart.setBackground(background); + me.chart.redraw(); + }, + + updateAll: function (metadata, status) { + let me = this; + me.suspendLayout = true; + + let maxversion = '0'; + Object.values(metadata.node || {}).forEach(function (node) { + if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) { + maxversion = node.version.parts; + } + }); + + let oldOSD = [], + ghostOSD = []; + metadata.osd?.forEach((osd) => { + let version = PVE.Utils.parse_ceph_version(osd); + if (version !== undefined) { + if (PVE.Utils.compare_ceph_versions(version, maxversion) !== 0) { + oldOSD.push({ + id: osd.id, + version: version, + }); + } + } else { + if (Object.keys(osd).length > 1) { + console.warn('got OSD entry with no valid version but other keys', osd); + } + ghostOSD.push({ + id: osd.id, + }); + } + }); + + // update PGs sorted + let pgmap = status.pgmap || {}; + let pgs_by_state = pgmap.pgs_by_state || []; + pgs_by_state.sort(function (a, b) { + return a.state_name < b.state_name ? -1 : a.state_name === b.state_name ? 0 : 1; + }); + + me.statecategories.forEach(function (cat) { + cat.count = 0; + cat.states = []; + }); + + pgs_by_state.forEach(function (state) { + let states = state.state_name.split(/[^a-z]+/); + let result = 0; + for (let i = 0; i < states.length; i++) { + if (me.pgstates[states[i]] > result) { + result = me.pgstates[states[i]]; + } + } + // for the list + state.cls = me.statecategories[result].cls; + + me.statecategories[result].count += state.count; + me.statecategories[result].states.push(state); + }); + + me.chart.getStore().setData(me.statecategories); + me.getComponent('pgs').update({ states: pgs_by_state }); + + let health = status.health || {}; + // we collect monitor/osd information from the checks + const downinregex = /(\d+) osds down/; + let downin_osds = 0; + Ext.Object.each(health.checks, function (key, value, obj) { + var found = null; + if (key === 'OSD_DOWN') { + found = value.summary.message.match(downinregex); + if (found !== null) { + downin_osds = parseInt(found[1], 10); + } + } + }); + + let osdmap = status.osdmap || {}; + if (typeof osdmap.osdmap !== 'undefined') { + osdmap = osdmap.osdmap; + } + // update OSDs counts + let total_osds = osdmap.num_osds || 0; + let in_osds = osdmap.num_in_osds || 0; + let up_osds = osdmap.num_up_osds || 0; + let down_osds = total_osds - up_osds; + + let downout_osds = down_osds - downin_osds; + let upin_osds = in_osds - downin_osds; + let upout_osds = up_osds - upin_osds; + + let osds = { + total: total_osds, + upin: upin_osds, + upout: upout_osds, + downin: downin_osds, + downout: downout_osds, + oldOSD: oldOSD, + ghostOSD, + }; + let osdcomponent = me.getComponent('osds'); + osdcomponent.update(Ext.apply(osdcomponent.data, osds)); + + me.suspendLayout = false; + me.updateLayout(); + }, + + initComponent: function () { + var me = this; + me.callParent(); + + me.chart = me.getComponent('pgchart'); + me.checkThemeColors(); + + // switch colors on media query changes + me.mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); + me.themeListener = (e) => { + me.checkThemeColors(); + }; + me.mediaQueryList.addEventListener('change', me.themeListener); + }, + + doDestroy: function () { + let me = this; + + me.mediaQueryList.removeEventListener('change', me.themeListener); + + me.callParent(); + }, +}); +Ext.define('PVE.node.ACMEAccountCreate', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + width: 450, + title: gettext('Register Account'), + isCreate: true, + method: 'POST', + submitText: gettext('Register'), + url: '/cluster/acme/account', + showTaskViewer: true, + defaultExists: false, + referenceHolder: true, + onlineHelp: 'sysadmin_certs_acme_account', + + viewModel: { + data: { + customDirectory: false, + eabRequired: false, + }, + formulas: { + eabEmptyText: function (get) { + return get('eabRequired') ? gettext('required') : gettext('optional'); + }, + }, + }, + + items: [ + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Account Name'), + name: 'name', + cbind: { + emptyText: (get) => (get('defaultExists') ? '' : 'default'), + allowBlank: (get) => !get('defaultExists'), + }, + }, + { + xtype: 'textfield', + name: 'contact', + vtype: 'email', + allowBlank: false, + fieldLabel: gettext('E-Mail'), + }, + { + xtype: 'proxmoxComboGrid', + notFoundIsValid: true, + isFormField: false, + allowBlank: false, + valueField: 'url', + displayField: 'name', + fieldLabel: gettext('ACME Directory'), + store: { + listeners: { + load: function () { + this.add({ name: gettext('Custom'), url: '' }); + }, + }, + autoLoad: true, + fields: ['name', 'url'], + idProperty: ['name'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/acme/directories', + }, + }, + listConfig: { + columns: [ + { + header: gettext('Name'), + dataIndex: 'name', + flex: 1, + }, + { + header: gettext('URL'), + dataIndex: 'url', + flex: 1, + }, + ], + }, + listeners: { + change: function (combogrid, value) { + let me = this; + + let vm = me.up('window').getViewModel(); + let dirField = me.up('window').lookupReference('directoryInput'); + let tosButton = me.up('window').lookupReference('queryTos'); + + let isCustom = combogrid.getSelection().get('name') === gettext('Custom'); + vm.set('customDirectory', isCustom); + + dirField.setValue(value); + + if (!isCustom) { + tosButton.click(); + } else { + me.up('window').clearToSFields(); + } + }, + }, + }, + { + xtype: 'fieldcontainer', + layout: 'hbox', + fieldLabel: gettext('URL'), + bind: { + hidden: '{!customDirectory}', + }, + items: [ + { + xtype: 'proxmoxtextfield', + name: 'directory', + reference: 'directoryInput', + flex: 1, + allowBlank: false, + listeners: { + change: function (textbox, value) { + let me = this; + me.up('window').clearToSFields(); + }, + }, + }, + { + xtype: 'proxmoxButton', + margin: '0 0 0 5', + reference: 'queryTos', + text: gettext('Query URL'), + listeners: { + click: function (button) { + let me = this; + + let w = me.up('window'); + let vm = w.getViewModel(); + let disp = w.down('#tos_url_display'); + let field = w.down('#tos_url'); + let checkbox = w.down('#tos_checkbox'); + let value = w.lookupReference('directoryInput').getValue(); + w.clearToSFields(); + + if (!value) { + return; + } else { + disp.setValue(gettext('Loading')); + } + + Proxmox.Utils.API2Request({ + url: '/cluster/acme/meta', + method: 'GET', + params: { + directory: value, + }, + success: function (response, opt) { + if ( + response.result.data && + response.result.data.termsOfService + ) { + field.setValue(response.result.data.termsOfService); + disp.setValue(response.result.data.termsOfService); + checkbox.setHidden(false); + } else { + // Needed to pass input verification and enable register button + // has no influence on the submitted form + checkbox.setValue(true); + disp.setValue('No terms of service agreement required'); + } + vm.set( + 'eabRequired', + !!( + response.result.data && + response.result.data.externalAccountRequired + ), + ); + }, + failure: function (response, opt) { + disp.setValue(undefined); + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }, + }, + ], + }, + { + xtype: 'displayfield', + itemId: 'tos_url_display', + renderer: PVE.Utils.render_optional_url, + name: 'tos_url_display', + }, + { + xtype: 'hidden', + itemId: 'tos_url', + name: 'tos_url', + }, + { + xtype: 'proxmoxcheckbox', + itemId: 'tos_checkbox', + boxLabel: gettext('Accept TOS'), + submitValue: false, + validateValue: function (value) { + if (value && this.checked) { + return true; + } + return false; + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'eab-kid', + fieldLabel: gettext('EAB Key ID'), + bind: { + hidden: '{!customDirectory}', + allowBlank: '{!eabRequired}', + emptyText: '{eabEmptyText}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'eab-hmac-key', + fieldLabel: gettext('EAB Key'), + bind: { + hidden: '{!customDirectory}', + allowBlank: '{!eabRequired}', + emptyText: '{eabEmptyText}', + }, + }, + ], + + clearToSFields: function () { + let me = this; + + let disp = me.down('#tos_url_display'); + let field = me.down('#tos_url'); + let checkbox = me.down('#tos_checkbox'); + + disp.setValue('Terms of service not fetched yet'); + field.setValue(undefined); + checkbox.setValue(undefined); + checkbox.setHidden(true); + }, +}); + +Ext.define('PVE.node.ACMEDomainEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveACMEDomainEdit', + + subject: gettext('Domain'), + isCreate: false, + width: 450, + onlineHelp: 'sysadmin_certificate_management', + + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + let me = this; + let win = me.up('pveACMEDomainEdit'); + let nodeconfig = win.nodeconfig; + let olddomain = win.domain || {}; + + let params = { + digest: nodeconfig.digest, + }; + + let configkey = olddomain.configkey; + let acmeObj = PVE.Parser.parseACME(nodeconfig.acme); + + if (values.type === 'dns') { + if (!olddomain.configkey || olddomain.configkey === 'acme') { + // look for first free slot + for (let i = 0; i < PVE.Utils.acmedomain_count; i++) { + if (nodeconfig[`acmedomain${i}`] === undefined) { + configkey = `acmedomain${i}`; + break; + } + } + if (olddomain.domain) { + // we have to remove the domain from the acme domainlist + PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); + params.acme = PVE.Parser.printACME(acmeObj); + } + } + + delete values.type; + params[configkey] = PVE.Parser.printPropertyString(values, 'domain'); + } else { + if (olddomain.configkey && olddomain.configkey !== 'acme') { + // delete the old dns entry + params.delete = [olddomain.configkey]; + } + + // add new, remove old and make entries unique + PVE.Utils.add_domain_to_acme(acmeObj, values.domain); + PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); + params.acme = PVE.Parser.printACME(acmeObj); + } + + return params; + }, + items: [ + { + xtype: 'proxmoxKVComboBox', + name: 'type', + fieldLabel: gettext('Challenge Type'), + allowBlank: false, + value: 'standalone', + comboItems: [ + ['standalone', 'HTTP'], + ['dns', 'DNS'], + ], + validator: function (value) { + let me = this; + let win = me.up('pveACMEDomainEdit'); + let oldconfigkey = win.domain ? win.domain.configkey : undefined; + let val = me.getValue(); + if (val === 'dns' && (!oldconfigkey || oldconfigkey === 'acme')) { + // we have to check if there is a 'acmedomain' slot left + let found = false; + for (let i = 0; i < PVE.Utils.acmedomain_count; i++) { + if (!win.nodeconfig[`acmedomain${i}`]) { + found = true; + } + } + if (!found) { + return gettext('Only 5 Domains with type DNS can be configured'); + } + } + + return true; + }, + listeners: { + change: function (cb, value) { + let me = this; + let view = me.up('pveACMEDomainEdit'); + let pluginField = view.down('field[name=plugin]'); + pluginField.setDisabled(value !== 'dns'); + pluginField.setHidden(value !== 'dns'); + }, + }, + }, + { + xtype: 'hidden', + name: 'alias', + }, + { + xtype: 'pveACMEPluginSelector', + name: 'plugin', + disabled: true, + hidden: true, + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'domain', + allowBlank: false, + vtype: 'DnsName', + value: '', + fieldLabel: gettext('Domain'), + }, + ], + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + if (!me.nodeconfig) { + throw 'no nodeconfig given'; + } + + me.isCreate = !me.domain; + if (me.isCreate) { + me.domain = `${me.nodename}.`; // TODO: FQDN of node + } + + me.url = `/api2/extjs/nodes/${me.nodename}/config`; + + me.callParent(); + + if (!me.isCreate) { + me.setValues(me.domain); + } else { + me.setValues({ domain: me.domain }); + } + }, +}); + +Ext.define('pve-acme-domains', { + extend: 'Ext.data.Model', + fields: ['domain', 'type', 'alias', 'plugin', 'configkey'], + idProperty: 'domain', +}); + +Ext.define('PVE.node.ACME', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveACMEView', + + margin: '10 0 0 0', + title: 'ACME', + + emptyText: gettext('No Domains configured'), + + viewModel: { + data: { + domaincount: 0, + account: undefined, // the account we display + configaccount: undefined, // the account set in the config + accountEditable: false, + accountsAvailable: false, + }, + + formulas: { + canOrder: (get) => !!get('account') && get('domaincount') > 0, + editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'), + editBtnText: (get) => (get('accountEditable') ? gettext('Apply') : gettext('Edit')), + accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'), + accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'), + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + let accountSelector = this.lookup('accountselector'); + accountSelector.store.on('load', this.onAccountsLoad, this); + }, + + onAccountsLoad: function (store, records, success) { + let me = this; + let vm = me.getViewModel(); + let configaccount = vm.get('configaccount'); + vm.set('accountsAvailable', records.length > 0); + if (me.autoChangeAccount && records.length > 0) { + me.changeAccount(records[0].data.name, () => { + vm.set('accountEditable', false); + me.reload(); + }); + me.autoChangeAccount = false; + } else if (configaccount) { + if (store.findExact('name', configaccount) !== -1) { + vm.set('account', configaccount); + } else { + vm.set('account', null); + } + } + }, + + addDomain: function () { + let me = this; + let view = me.getView(); + + Ext.create('PVE.node.ACMEDomainEdit', { + nodename: view.nodename, + nodeconfig: view.nodeconfig, + apiCallDone: function () { + me.reload(); + }, + }).show(); + }, + + editDomain: function () { + let me = this; + let view = me.getView(); + + let selection = view.getSelection(); + if (selection.length < 1) { + return; + } + + Ext.create('PVE.node.ACMEDomainEdit', { + nodename: view.nodename, + nodeconfig: view.nodeconfig, + domain: selection[0].data, + apiCallDone: function () { + me.reload(); + }, + }).show(); + }, + + removeDomain: function () { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (selection.length < 1) { + return; + } + + let rec = selection[0].data; + let params = {}; + if (rec.configkey !== 'acme') { + params.delete = rec.configkey; + } else { + let acme = PVE.Parser.parseACME(view.nodeconfig.acme); + PVE.Utils.remove_domain_from_acme(acme, rec.domain); + params.acme = PVE.Parser.printACME(acme); + } + + Proxmox.Utils.API2Request({ + method: 'PUT', + url: `/nodes/${view.nodename}/config`, + params, + success: function (response, opt) { + me.reload(); + }, + failure: function (response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + toggleEditAccount: function () { + let me = this; + let vm = me.getViewModel(); + let editable = vm.get('accountEditable'); + if (editable) { + me.changeAccount(vm.get('account'), function () { + vm.set('accountEditable', false); + me.reload(); + }); + } else { + vm.set('accountEditable', true); + } + }, + + changeAccount: function (account, callback) { + let me = this; + let view = me.getView(); + let params = {}; + + let acme = PVE.Parser.parseACME(view.nodeconfig.acme); + acme.account = account; + params.acme = PVE.Parser.printACME(acme); + + Proxmox.Utils.API2Request({ + method: 'PUT', + waitMsgTarget: view, + url: `/nodes/${view.nodename}/config`, + params, + success: function (response, opt) { + if (Ext.isFunction(callback)) { + callback(); + } + }, + failure: function (response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + order: function () { + let me = this; + let view = me.getView(); + + Proxmox.Utils.API2Request({ + method: 'POST', + params: { + force: 1, + }, + url: `/nodes/${view.nodename}/certificates/acme/certificate`, + success: function (response, opt) { + Ext.create('Proxmox.window.TaskViewer', { + upid: response.result.data, + taskDone: function (success) { + me.orderFinished(success); + }, + }).show(); + }, + failure: function (response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + orderFinished: function (success) { + if (!success) { + return; + } + // reload only if the Web UI is open on the same node that the cert was ordered for + if (this.getView().nodename !== Proxmox.NodeName) { + return; + } + var txt = gettext( + 'pveproxy will be restarted with new certificates, please reload the GUI!', + ); + Ext.getBody().mask(txt, ['pve-static-mask']); + // reload after 10 seconds automatically + Ext.defer(function () { + window.location.reload(true); + }, 10000); + }, + + reload: function () { + let me = this; + let view = me.getView(); + view.rstore.load(); + }, + + addAccount: function () { + let me = this; + Ext.create('PVE.node.ACMEAccountCreate', { + autoShow: true, + taskDone: function () { + me.reload(); + let accountSelector = me.lookup('accountselector'); + me.autoChangeAccount = true; + accountSelector.store.load(); + }, + }); + }, + }, + + tbar: [ + { + xtype: 'proxmoxButton', + text: gettext('Add'), + handler: 'addDomain', + selModel: false, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + handler: 'editDomain', + }, + { + xtype: 'proxmoxStdRemoveButton', + handler: 'removeDomain', + }, + '-', + { + xtype: 'button', + reference: 'order', + text: gettext('Order Certificates Now'), + bind: { + disabled: '{!canOrder}', + }, + handler: 'order', + }, + '-', + { + xtype: 'displayfield', + value: gettext('Using Account') + ':', + bind: { + hidden: '{!accountsAvailable}', + }, + }, + { + xtype: 'displayfield', + reference: 'accounttext', + renderer: (val) => val || Proxmox.Utils.NoneText, + bind: { + value: '{account}', + hidden: '{accountTextHidden}', + }, + }, + { + xtype: 'pveACMEAccountSelector', + hidden: true, + reference: 'accountselector', + bind: { + value: '{account}', + hidden: '{accountValueHidden}', + }, + }, + { + xtype: 'button', + iconCls: 'fa black fa-pencil', + bind: { + iconCls: '{editBtnIcon}', + text: '{editBtnText}', + hidden: '{!accountsAvailable}', + }, + handler: 'toggleEditAccount', + }, + { + xtype: 'displayfield', + value: gettext('No Account available.'), + bind: { + hidden: '{accountsAvailable}', + }, + }, + { + xtype: 'button', + hidden: true, + reference: 'accountlink', + text: gettext('Add ACME Account'), + bind: { + hidden: '{accountsAvailable}', + }, + handler: 'addAccount', + }, + ], + + updateStore: function (store, records, success) { + let me = this; + let data = []; + let rec; + if (success && records.length > 0) { + rec = records[0]; + } else { + rec = { + data: {}, + }; + } + + me.nodeconfig = rec.data; // save nodeconfig for updates + + let account = 'default'; + + if (rec.data.acme) { + let obj = PVE.Parser.parseACME(rec.data.acme); + (obj.domains || []).forEach((domain) => { + if (domain === '') { + return; + } + let record = { + domain, + type: 'standalone', + configkey: 'acme', + }; + data.push(record); + }); + + if (obj.account) { + account = obj.account; + } + } + + let vm = me.getViewModel(); + let oldaccount = vm.get('account'); + + // account changed, and we do not edit currently, load again to verify + if (oldaccount !== account && !vm.get('accountEditable')) { + vm.set('configaccount', account); + me.lookup('accountselector').store.load(); + } + + for (let i = 0; i < PVE.Utils.acmedomain_count; i++) { + let acmedomain = rec.data[`acmedomain${i}`]; + if (!acmedomain) { + continue; + } + + let record = PVE.Parser.parsePropertyString(acmedomain, 'domain'); + record.type = 'dns'; + record.configkey = `acmedomain${i}`; + data.push(record); + } + + vm.set('domaincount', data.length); + me.store.loadData(data, false); + }, + + listeners: { + itemdblclick: 'editDomain', + }, + + columns: [ + { + dataIndex: 'domain', + flex: 5, + text: gettext('Domain'), + }, + { + dataIndex: 'type', + flex: 1, + text: gettext('Type'), + }, + { + dataIndex: 'plugin', + flex: 1, + text: gettext('Plugin'), + }, + ], + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + me.rstore = Ext.create('Proxmox.data.UpdateStore', { + interval: 10 * 1000, + autoStart: true, + storeid: `pve-node-domains-${me.nodename}`, + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/config`, + }, + }); + + me.store = Ext.create('Ext.data.Store', { + model: 'pve-acme-domains', + sorters: 'domain', + }); + + me.callParent(); + me.mon(me.rstore, 'load', 'updateStore', me); + Proxmox.Utils.monStoreErrors(me, me.rstore); + me.on('destroy', me.rstore.stopUpdate, me.rstore); + }, +}); +Ext.define('PVE.node.CertificateView', { + extend: 'Ext.container.Container', + xtype: 'pveCertificatesView', + + onlineHelp: 'sysadmin_certificate_management', + + mixins: ['Proxmox.Mixin.CBind'], + scrollable: 'y', + + items: [ + { + xtype: 'pveCertView', + border: 0, + cbind: { + nodename: '{nodename}', + }, + }, + { + xtype: 'pveACMEView', + border: 0, + cbind: { + nodename: '{nodename}', + }, + }, + ], +}); + +Ext.define('PVE.node.CertificateViewer', { + extend: 'Proxmox.window.Edit', + + title: gettext('Certificate'), + + fieldDefaults: { + labelWidth: 120, + }, + width: 800, + + items: { + xtype: 'inputpanel', + maxHeight: 900, + scrollable: 'y', + columnT: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Name'), + name: 'filename', + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Fingerprint'), + name: 'fingerprint', + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Issuer'), + name: 'issuer', + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Subject'), + name: 'subject', + }, + ], + column1: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Public Key Type'), + name: 'public-key-type', + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Public Key Size'), + name: 'public-key-bits', + }, + ], + column2: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Valid Since'), + renderer: Proxmox.Utils.render_timestamp, + name: 'notbefore', + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Expires'), + renderer: Proxmox.Utils.render_timestamp, + name: 'notafter', + }, + ], + columnB: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Subject Alternative Names'), + name: 'san', + renderer: PVE.Utils.render_san, + }, + { + xtype: 'fieldset', + title: gettext('Raw Certificate'), + collapsible: true, + collapsed: true, + items: [ + { + xtype: 'textarea', + name: 'pem', + editable: false, + grow: true, + growMax: 350, + fieldStyle: { + 'white-space': 'pre-wrap', + 'font-family': 'monospace', + }, + }, + ], + }, + ], + }, + + initComponent: function () { + let me = this; + + if (!me.cert) { + throw 'no cert given'; + } + if (!me.nodename) { + throw 'no nodename given'; + } + + me.url = `/nodes/${me.nodename}/certificates/info`; + me.callParent(); + + // hide OK/Reset button, because we just want to show data + me.down('toolbar[dock=bottom]').setVisible(false); + + me.load({ + success: function (response) { + if (Ext.isArray(response.result.data)) { + for (const item of response.result.data) { + if (item.filename === me.cert) { + me.setValues(item); + return; + } + } + } + }, + }); + }, +}); + +Ext.define('PVE.node.CertUpload', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCertUpload', + + title: gettext('Upload Custom Certificate'), + resizable: false, + isCreate: true, + submitText: gettext('Upload'), + method: 'POST', + width: 600, + + apiCallDone: function (success, response, options) { + if (!success) { + return; + } + let txt = gettext( + 'API server will be restarted to use new certificates, please reload web-interface!', + ); + Ext.getBody().mask(txt, ['pve-static-mask']); + Ext.defer(() => window.location.reload(true), 10000); // reload after 10 seconds automatically + }, + + items: { + xtype: 'inputpanel', + onGetValues: function (values) { + values.restart = 1; + values.force = 1; + if (!values.key) { + delete values.key; + } + return values; + }, + items: [ + { + fieldLabel: gettext('Private Key (Optional)'), + labelAlign: 'top', + emptyText: gettext('No change'), + name: 'key', + xtype: 'textarea', + }, + { + xtype: 'filebutton', + text: gettext('From File'), + listeners: { + change: function (btn, e, value) { + let form = this.up('form'); + for (const file of e.event.target.files) { + PVE.Utils.loadFile(file, (res) => + form.down('field[name=key]').setValue(res), + ); + } + btn.reset(); + }, + }, + }, + { + fieldLabel: gettext('Certificate Chain'), + labelAlign: 'top', + allowBlank: false, + name: 'certificates', + xtype: 'textarea', + }, + { + xtype: 'filebutton', + text: gettext('From File'), + listeners: { + change: function (btn, e, value) { + let form = this.up('form'); + for (const file of e.event.target.files) { + PVE.Utils.loadFile(file, (res) => + form.down('field[name=certificates]').setValue(res), + ); + } + btn.reset(); + }, + }, + }, + ], + }, + + initComponent: function () { + let me = this; + if (!me.nodename) { + throw 'no nodename given'; + } + me.url = `/nodes/${me.nodename}/certificates/custom`; + + me.callParent(); + }, +}); + +Ext.define('pve-certificate', { + extend: 'Ext.data.Model', + fields: [ + 'filename', + 'fingerprint', + 'issuer', + 'notafter', + 'notbefore', + 'subject', + 'san', + 'public-key-bits', + 'public-key-type', + ], + idProperty: 'filename', +}); + +Ext.define('PVE.node.Certificates', { + extend: 'Ext.grid.Panel', + xtype: 'pveCertView', + + tbar: [ + { + xtype: 'button', + text: gettext('Upload Custom Certificate'), + handler: function () { + let view = this.up('grid'); + Ext.create('PVE.node.CertUpload', { + nodename: view.nodename, + listeners: { + destroy: () => view.reload(), + }, + autoShow: true, + }); + }, + }, + { + xtype: 'proxmoxStdRemoveButton', + itemId: 'deletebtn', + text: gettext('Delete Custom Certificate'), + dangerous: true, + selModel: false, + getUrl: function (rec) { + let view = this.up('grid'); + return `/nodes/${view.nodename}/certificates/custom?restart=1`; + }, + confirmMsg: gettext('Delete custom certificate and switch to generated one?'), + callback: function (options, success, response) { + if (success) { + let txt = gettext( + 'API server will be restarted to use new certificates, please reload web-interface!', + ); + Ext.getBody().mask(txt, ['pve-static-mask']); + // reload after 10 seconds automatically + Ext.defer(() => window.location.reload(true), 10000); + } + }, + }, + '-', + { + xtype: 'proxmoxButton', + itemId: 'viewbtn', + disabled: true, + text: gettext('View Certificate'), + handler: function () { + this.up('grid').viewCertificate(); + }, + }, + ], + + columns: [ + { + header: gettext('File'), + width: 150, + dataIndex: 'filename', + }, + { + header: gettext('Issuer'), + flex: 1, + dataIndex: 'issuer', + }, + { + header: gettext('Subject'), + flex: 1, + dataIndex: 'subject', + }, + { + header: gettext('Public Key Algorithm'), + flex: 1, + dataIndex: 'public-key-type', + hidden: true, + }, + { + header: gettext('Public Key Size'), + flex: 1, + dataIndex: 'public-key-bits', + hidden: true, + }, + { + header: gettext('Valid Since'), + width: 150, + dataIndex: 'notbefore', + renderer: Proxmox.Utils.render_timestamp, + }, + { + header: gettext('Expires'), + width: 150, + dataIndex: 'notafter', + renderer: Proxmox.Utils.render_timestamp, + }, + { + header: gettext('Subject Alternative Names'), + flex: 1, + dataIndex: 'san', + renderer: PVE.Utils.render_san, + }, + { + header: gettext('Fingerprint'), + dataIndex: 'fingerprint', + hidden: true, + }, + { + header: gettext('PEM'), + dataIndex: 'pem', + hidden: true, + }, + ], + + reload: function () { + this.rstore.load(); + }, + + viewCertificate: function () { + let me = this; + let selection = me.getSelection(); + if (!selection || selection.length < 1) { + return; + } + var win = Ext.create('PVE.node.CertificateViewer', { + cert: selection[0].data.filename, + nodename: me.nodename, + }); + win.show(); + }, + + listeners: { + itemdblclick: 'viewCertificate', + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + me.rstore = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'certs-' + me.nodename, + model: 'pve-certificate', + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/certificates/info', + }, + }); + + me.store = { + type: 'diff', + rstore: me.rstore, + }; + + me.callParent(); + + me.mon(me.rstore, 'load', (store) => + me.down('#deletebtn').setDisabled(!store.getById('pveproxy-ssl.pem')), + ); + me.rstore.startUpdate(); + me.on('destroy', me.rstore.stopUpdate, me.rstore); + }, +}); +Ext.define('PVE.node.CmdMenu', { + extend: 'Ext.menu.Menu', + xtype: 'nodeCmdMenu', + + showSeparator: false, + + items: [ + { + text: gettext('Create VM'), + itemId: 'createvm', + iconCls: 'fa fa-desktop', + handler: function () { + Ext.create('PVE.qemu.CreateWizard', { + nodename: this.up('menu').nodename, + autoShow: true, + }); + }, + }, + { + text: gettext('Create CT'), + itemId: 'createct', + iconCls: 'fa fa-cube', + handler: function () { + Ext.create('PVE.lxc.CreateWizard', { + nodename: this.up('menu').nodename, + autoShow: true, + }); + }, + }, + { xtype: 'menuseparator' }, + { + text: gettext('Bulk Start'), + itemId: 'bulkstart', + iconCls: 'fa fa-fw fa-play', + handler: function () { + Ext.create('PVE.window.BulkAction', { + nodename: this.up('menu').nodename, + title: gettext('Bulk Start'), + btnText: gettext('Start'), + action: 'startall', + autoShow: true, + }); + }, + }, + { + text: gettext('Bulk Shutdown'), + itemId: 'bulkstop', + iconCls: 'fa fa-fw fa-stop', + handler: function () { + Ext.create('PVE.window.BulkAction', { + nodename: this.up('menu').nodename, + title: gettext('Bulk Shutdown'), + btnText: gettext('Shutdown'), + action: 'stopall', + autoShow: true, + }); + }, + }, + { + text: gettext('Bulk Suspend'), + itemId: 'bulksuspend', + iconCls: 'fa fa-fw fa-download', + handler: function () { + Ext.create('PVE.window.BulkAction', { + nodename: this.up('menu').nodename, + title: gettext('Bulk Suspend'), + btnText: gettext('Suspend'), + action: 'suspendall', + autoShow: true, + }); + }, + }, + { + text: gettext('Bulk Migrate'), + itemId: 'bulkmigrate', + iconCls: 'fa fa-fw fa-send-o', + handler: function () { + Ext.create('PVE.window.BulkAction', { + nodename: this.up('menu').nodename, + title: gettext('Bulk Migrate'), + btnText: gettext('Migrate'), + action: 'migrateall', + autoShow: true, + }); + }, + }, + { xtype: 'menuseparator' }, + { + text: gettext('Shell'), + itemId: 'shell', + iconCls: 'fa fa-fw fa-terminal', + handler: function () { + let nodename = this.up('menu').nodename; + PVE.Utils.openDefaultConsoleWindow(true, 'shell', undefined, nodename, undefined); + }, + }, + { xtype: 'menuseparator' }, + { + text: gettext('Wake-on-LAN'), + itemId: 'wakeonlan', + iconCls: 'fa fa-fw fa-power-off', + handler: function () { + let nodename = this.up('menu').nodename; + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/wakeonlan`, + method: 'POST', + failure: (response, opts) => + Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function (response, opts) { + Ext.Msg.show({ + title: 'Success', + icon: Ext.Msg.INFO, + msg: Ext.String.format( + gettext("Wake on LAN packet send for '{0}': '{1}'"), + nodename, + response.result.data, + ), + }); + }, + }); + }, + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no nodename specified'; + } + + me.title = gettext('Node') + " '" + me.nodename + "'"; + me.callParent(); + + let caps = Ext.state.Manager.get('GuiCap'); + + if (!caps.vms['VM.Allocate']) { + me.getComponent('createct').setDisabled(true); + me.getComponent('createvm').setDisabled(true); + } + if (!caps.vms['VM.Migrate']) { + me.getComponent('bulkmigrate').setDisabled(true); + } + if (!caps.vms['VM.PowerMgmt']) { + me.getComponent('bulkstart').setDisabled(true); + me.getComponent('bulkstop').setDisabled(true); + me.getComponent('bulksuspend').setDisabled(true); + } + if (!caps.nodes['Sys.PowerMgmt']) { + me.getComponent('wakeonlan').setDisabled(true); + } + if (!caps.nodes['Sys.Console']) { + me.getComponent('shell').setDisabled(true); + } + if (me.pveSelNode.data.running) { + me.getComponent('wakeonlan').setDisabled(true); + } + + if (PVE.Utils.isStandaloneNode()) { + me.getComponent('bulkmigrate').setVisible(false); + } + }, +}); +Ext.define('PVE.node.Config', { + extend: 'PVE.panel.Config', + alias: 'widget.PVE.node.Config', + + onlineHelp: 'chapter_system_administration', + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var caps = Ext.state.Manager.get('GuiCap'); + + me.statusStore = Ext.create('Proxmox.data.ObjectStore', { + url: '/api2/json/nodes/' + nodename + '/status', + interval: 5000, + }); + + var node_command = function (cmd) { + Proxmox.Utils.API2Request({ + params: { command: cmd }, + url: '/nodes/' + nodename + '/status', + method: 'POST', + waitMsgTarget: me, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }; + + var actionBtn = Ext.create('Ext.Button', { + text: gettext('Bulk Actions'), + iconCls: 'fa fa-fw fa-ellipsis-v', + disabled: !caps.vms['VM.PowerMgmt'] && !caps.vms['VM.Migrate'], + menu: new Ext.menu.Menu({ + items: [ + { + text: gettext('Bulk Start'), + iconCls: 'fa fa-fw fa-play', + disabled: !caps.vms['VM.PowerMgmt'], + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + nodename: nodename, + title: gettext('Bulk Start'), + btnText: gettext('Start'), + action: 'startall', + }); + }, + }, + { + text: gettext('Bulk Shutdown'), + iconCls: 'fa fa-fw fa-stop', + disabled: !caps.vms['VM.PowerMgmt'], + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + nodename: nodename, + title: gettext('Bulk Shutdown'), + btnText: gettext('Shutdown'), + action: 'stopall', + }); + }, + }, + { + text: gettext('Bulk Suspend'), + iconCls: 'fa fa-fw fa-download', + disabled: !caps.vms['VM.PowerMgmt'], + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + nodename: nodename, + title: gettext('Bulk Suspend'), + btnText: gettext('Suspend'), + action: 'suspendall', + }); + }, + }, + { + text: gettext('Bulk Migrate'), + iconCls: 'fa fa-fw fa-send-o', + disabled: !caps.vms['VM.Migrate'], + hidden: PVE.Utils.isStandaloneNode(), + handler: function () { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + nodename: nodename, + title: gettext('Bulk Migrate'), + btnText: gettext('Migrate'), + action: 'migrateall', + }); + }, + }, + ], + }), + }); + + let restartBtn = Ext.create('Proxmox.button.Button', { + text: gettext('Reboot'), + disabled: !caps.nodes['Sys.PowerMgmt'], + dangerous: true, + confirmMsg: Ext.String.format(gettext("Reboot node '{0}'?"), nodename), + handler: function () { + node_command('reboot'); + }, + iconCls: 'fa fa-undo', + }); + + var shutdownBtn = Ext.create('Proxmox.button.Button', { + text: gettext('Shutdown'), + disabled: !caps.nodes['Sys.PowerMgmt'], + dangerous: true, + confirmMsg: Ext.String.format(gettext("Shutdown node '{0}'?"), nodename), + handler: function () { + node_command('shutdown'); + }, + iconCls: 'fa fa-power-off', + }); + + var shellBtn = Ext.create('PVE.button.ConsoleButton', { + disabled: !caps.nodes['Sys.Console'], + text: gettext('Shell'), + consoleType: 'shell', + nodename: nodename, + }); + + me.items = []; + + Ext.apply(me, { + title: gettext('Node') + " '" + nodename + "'", + hstateid: 'nodetab', + defaults: { + statusStore: me.statusStore, + }, + tbar: [restartBtn, shutdownBtn, shellBtn, actionBtn], + }); + + if (caps.nodes['Sys.Audit']) { + me.items.push( + { + xtype: 'pveNodeSummary', + title: gettext('Summary'), + iconCls: 'fa fa-book', + itemId: 'summary', + }, + { + xtype: 'pmxNotesView', + title: gettext('Notes'), + iconCls: 'fa fa-sticky-note-o', + itemId: 'notes', + }, + ); + } + + if (caps.nodes['Sys.Console']) { + me.items.push({ + xtype: 'pveNoVncConsole', + title: gettext('Shell'), + iconCls: 'fa fa-terminal', + itemId: 'jsconsole', + consoleType: 'shell', + xtermjs: true, + nodename: nodename, + }); + } + + if (caps.nodes['Sys.Audit']) { + me.items.push( + { + xtype: 'proxmoxNodeServiceView', + title: gettext('System'), + iconCls: 'fa fa-cogs', + itemId: 'services', + expandedOnInit: true, + restartCommand: 'reload', // avoid disruptions + startOnlyServices: { + pveproxy: true, + pvedaemon: true, + 'pve-cluster': true, + }, + nodename: nodename, + onlineHelp: 'pve_service_daemons', + }, + { + xtype: 'proxmoxNodeNetworkView', + title: gettext('Network'), + iconCls: 'fa fa-exchange', + itemId: 'network', + showApplyBtn: true, + showAltNames: true, + groups: ['services'], + nodename: nodename, + editOptions: { + enableBridgeVlanIds: true, + }, + onlineHelp: 'sysadmin_network_configuration', + }, + { + xtype: 'pveCertificatesView', + title: gettext('Certificates'), + iconCls: 'fa fa-certificate', + itemId: 'certificates', + groups: ['services'], + nodename: nodename, + }, + { + xtype: 'proxmoxNodeDNSView', + title: gettext('DNS'), + iconCls: 'fa fa-globe', + groups: ['services'], + itemId: 'dns', + nodename: nodename, + onlineHelp: 'sysadmin_network_configuration', + }, + { + xtype: 'proxmoxNodeHostsView', + title: gettext('Hosts'), + iconCls: 'fa fa-globe', + groups: ['services'], + itemId: 'hosts', + nodename: nodename, + onlineHelp: 'sysadmin_network_configuration', + }, + { + xtype: 'proxmoxNodeOptionsView', + title: gettext('Options'), + iconCls: 'fa fa-gear', + groups: ['services'], + itemId: 'options', + nodename: nodename, + onlineHelp: 'proxmox_node_management', + }, + { + xtype: 'proxmoxNodeTimeView', + title: gettext('Time'), + itemId: 'time', + groups: ['services'], + nodename: nodename, + iconCls: 'fa fa-clock-o', + }, + ); + } + + if (caps.nodes['Sys.Syslog']) { + me.items.push({ + xtype: 'proxmoxJournalView', + title: gettext('System Log'), + iconCls: 'fa fa-list', + groups: ['services'], + disabled: !caps.nodes['Sys.Syslog'], + itemId: 'syslog', + url: '/api2/extjs/nodes/' + nodename + '/journal', + }); + + if (caps.nodes['Sys.Modify']) { + me.items.push({ + xtype: 'proxmoxNodeAPT', + title: gettext('Updates'), + iconCls: 'fa fa-refresh', + expandedOnInit: true, + disabled: !caps.nodes['Sys.Console'], + // do we want to link to system updates instead? + itemId: 'apt', + upgradeBtn: { + xtype: 'pveConsoleButton', + disabled: Proxmox.UserName !== 'root@pam', + text: gettext('Upgrade'), + consoleType: 'upgrade', + nodename: nodename, + }, + nodename: nodename, + }); + + me.items.push({ + xtype: 'proxmoxNodeAPTRepositories', + title: gettext('Repositories'), + iconCls: 'fa fa-files-o', + itemId: 'aptrepositories', + nodename: nodename, + onlineHelp: 'sysadmin_package_repositories', + groups: ['apt'], + }); + } + } + + if (caps.nodes['Sys.Audit']) { + me.items.push( + { + xtype: 'pveFirewallRules', + iconCls: 'fa fa-shield', + title: gettext('Firewall'), + allow_iface: true, + base_url: '/nodes/' + nodename + '/firewall/rules', + list_refs_url: '/cluster/firewall/refs', + itemId: 'firewall', + firewall_type: 'node', + }, + { + xtype: 'pveFirewallOptions', + title: gettext('Options'), + iconCls: 'fa fa-gear', + onlineHelp: 'pve_firewall_host_specific_configuration', + groups: ['firewall'], + base_url: '/nodes/' + nodename + '/firewall/options', + fwtype: 'node', + itemId: 'firewall-options', + }, + ); + } + + if (caps.nodes['Sys.Audit']) { + me.items.push( + { + xtype: 'pmxDiskList', + title: gettext('Disks'), + itemId: 'storage', + expandedOnInit: true, + iconCls: 'fa fa-hdd-o', + nodename: nodename, + includePartitions: true, + supportsWipeDisk: true, + }, + { + xtype: 'pveLVMList', + title: 'LVM', + itemId: 'lvm', + onlineHelp: 'chapter_lvm', + iconCls: 'fa fa-square', + groups: ['storage'], + }, + { + xtype: 'pveLVMThinList', + title: 'LVM-Thin', + itemId: 'lvmthin', + onlineHelp: 'chapter_lvm', + iconCls: 'fa fa-square-o', + groups: ['storage'], + }, + { + xtype: 'pveDirectoryList', + title: Proxmox.Utils.directoryText, + itemId: 'directory', + onlineHelp: 'chapter_storage', + iconCls: 'fa fa-folder', + groups: ['storage'], + }, + { + title: 'ZFS', + itemId: 'zfs', + onlineHelp: 'chapter_zfs', + iconCls: 'fa fa-th-large', + groups: ['storage'], + xtype: 'pveZFSList', + }, + { + xtype: 'pveNodeCephStatus', + title: 'Ceph', + itemId: 'ceph', + iconCls: 'fa fa-ceph', + }, + { + xtype: 'pveNodeCephConfigCrush', + title: gettext('Configuration'), + iconCls: 'fa fa-gear', + groups: ['ceph'], + itemId: 'ceph-config', + }, + { + xtype: 'pveNodeCephMonMgr', + title: gettext('Monitor'), + iconCls: 'fa fa-tv', + groups: ['ceph'], + itemId: 'ceph-monlist', + }, + { + xtype: 'pveNodeCephOsdTree', + title: 'OSD', + iconCls: 'fa fa-hdd-o', + groups: ['ceph'], + itemId: 'ceph-osdtree', + }, + { + xtype: 'pveNodeCephFSPanel', + title: 'CephFS', + iconCls: 'fa fa-folder', + groups: ['ceph'], + nodename: nodename, + itemId: 'ceph-cephfspanel', + }, + { + xtype: 'pveNodeCephPoolList', + title: gettext('Pools'), + iconCls: 'fa fa-sitemap', + groups: ['ceph'], + itemId: 'ceph-pools', + }, + { + xtype: 'pveReplicaView', + iconCls: 'fa fa-retweet', + title: gettext('Replication'), + itemId: 'replication', + }, + ); + } + + if (caps.nodes['Sys.Syslog']) { + me.items.push( + { + xtype: 'proxmoxLogView', + title: gettext('Log'), + iconCls: 'fa fa-list', + groups: ['firewall'], + onlineHelp: 'chapter_pve_firewall', + url: '/api2/extjs/nodes/' + nodename + '/firewall/log', + itemId: 'firewall-fwlog', + log_select_timespan: true, + submitFormat: 'U', + }, + { + xtype: 'cephLogView', + title: gettext('Log'), + itemId: 'ceph-log', + iconCls: 'fa fa-list', + groups: ['ceph'], + onlineHelp: 'chapter_pveceph', + url: '/api2/extjs/nodes/' + nodename + '/ceph/log', + nodename: nodename, + }, + ); + } + + me.items.push( + { + title: gettext('Task History'), + iconCls: 'fa fa-list-alt', + itemId: 'tasks', + nodename: nodename, + xtype: 'proxmoxNodeTasks', + extraFilter: [ + { + xtype: 'pveGuestIDSelector', + fieldLabel: 'VMID', + allowBlank: true, + name: 'vmid', + }, + ], + }, + { + title: gettext('Subscription'), + iconCls: 'fa fa-support', + itemId: 'support', + xtype: 'pveNodeSubscription', + nodename: nodename, + }, + ); + + me.callParent(); + + me.mon(me.statusStore, 'load', function (store, records, success) { + let uptimerec = store.data.get('uptime'); + let powermgmt = caps.nodes['Sys.PowerMgmt'] && uptimerec && uptimerec.data.value; + + restartBtn.setDisabled(!powermgmt); + shutdownBtn.setDisabled(!powermgmt); + shellBtn.setDisabled(!powermgmt); + }); + + me.on('afterrender', function () { + me.statusStore.startUpdate(); + }); + + me.on('destroy', function () { + me.statusStore.stopUpdate(); + }); + }, +}); +Ext.define('PVE.node.CreateDirectory', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCreateDirectory', + + subject: Proxmox.Utils.directoryText, + + showProgress: true, + + onlineHelp: 'chapter_storage', + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + me.isCreate = true; + + Ext.applyIf(me, { + url: '/nodes/' + me.nodename + '/disks/directory', + method: 'POST', + items: [ + { + xtype: 'pmxDiskSelector', + name: 'device', + nodename: me.nodename, + diskType: 'unused', + includePartitions: true, + fieldLabel: gettext('Disk'), + allowBlank: false, + }, + { + xtype: 'proxmoxKVComboBox', + comboItems: [ + ['ext4', 'ext4'], + ['xfs', 'xfs'], + ], + fieldLabel: gettext('Filesystem'), + name: 'filesystem', + value: '', + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'name', + fieldLabel: gettext('Name'), + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'add_storage', + fieldLabel: gettext('Add Storage'), + value: '1', + }, + ], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.node.Directorylist', { + extend: 'Ext.grid.Panel', + xtype: 'pveDirectoryList', + + viewModel: { + data: { + path: '', + }, + formulas: { + dirName: (get) => get('path')?.replace('/mnt/pve/', '') || undefined, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + destroyDirectory: function () { + let me = this; + let vm = me.getViewModel(); + let view = me.getView(); + + const dirName = vm.get('dirName'); + + if (!view.nodename) { + throw 'no node name specified'; + } + + if (!dirName) { + throw 'no directory name specified'; + } + + Ext.create('PVE.window.SafeDestroyStorage', { + url: `/nodes/${view.nodename}/disks/directory/${dirName}`, + item: { id: dirName }, + taskName: 'dirremove', + taskDone: () => { + view.reload(); + }, + }).show(); + }, + }, + + stateful: true, + stateId: 'grid-node-directory', + columns: [ + { + text: gettext('Path'), + dataIndex: 'path', + flex: 1, + }, + { + header: gettext('Device'), + flex: 1, + dataIndex: 'device', + }, + { + header: gettext('Type'), + width: 100, + dataIndex: 'type', + }, + { + header: gettext('Options'), + width: 100, + dataIndex: 'options', + }, + { + header: gettext('Unit File'), + hidden: true, + dataIndex: 'unitfile', + }, + ], + + rootVisible: false, + useArrows: true, + + tbar: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: function () { + this.up('panel').reload(); + }, + }, + { + text: `${gettext('Create')}: ${gettext('Directory')}`, + handler: function () { + let view = this.up('panel'); + Ext.create('PVE.node.CreateDirectory', { + nodename: view.nodename, + listeners: { + destroy: () => view.reload(), + }, + autoShow: true, + }); + }, + }, + '->', + { + xtype: 'tbtext', + data: { + dirName: undefined, + }, + bind: { + data: { + dirName: '{dirName}', + }, + }, + tpl: [ + '', + gettext('Directory') + ' {dirName}:', + '', + Ext.String.format(gettext('No {0} selected'), gettext('directory')), + '', + ], + }, + { + text: gettext('More'), + iconCls: 'fa fa-bars', + disabled: true, + bind: { + disabled: '{!dirName}', + }, + menu: [ + { + text: gettext('Destroy'), + itemId: 'remove', + iconCls: 'fa fa-fw fa-trash-o', + handler: 'destroyDirectory', + disabled: true, + bind: { + disabled: '{!dirName}', + }, + }, + ], + }, + ], + + reload: function () { + let me = this; + me.store.load(); + me.store.sort(); + }, + + listeners: { + activate: function () { + this.reload(); + }, + selectionchange: function (model, selected) { + let me = this; + let vm = me.getViewModel(); + + vm.set('path', selected[0]?.data.path || ''); + }, + }, + + initComponent: function () { + let me = this; + + me.nodename = me.pveSelNode.data.node; + if (!me.nodename) { + throw 'no node name specified'; + } + + Ext.apply(me, { + store: { + fields: ['path', 'device', 'type', 'options', 'unitfile'], + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/disks/directory`, + }, + sorters: 'path', + }, + }); + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore(), true); + me.reload(); + }, +}); +Ext.define('PVE.node.CreateLVM', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCreateLVM', + + onlineHelp: 'chapter_lvm', + subject: 'LVM Volume Group', + + showProgress: true, + isCreate: true, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + me.isCreate = true; + + Ext.applyIf(me, { + url: `/nodes/${me.nodename}/disks/lvm`, + method: 'POST', + items: [ + { + xtype: 'pmxDiskSelector', + name: 'device', + nodename: me.nodename, + diskType: 'unused', + includePartitions: true, + fieldLabel: gettext('Disk'), + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'name', + fieldLabel: gettext('Name'), + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'add_storage', + fieldLabel: gettext('Add Storage'), + value: '1', + }, + ], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.node.LVMList', { + extend: 'Ext.tree.Panel', + xtype: 'pveLVMList', + + viewModel: { + data: { + volumeGroup: '', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + destroyVolumeGroup: function () { + let me = this; + let vm = me.getViewModel(); + let view = me.getView(); + + const volumeGroup = vm.get('volumeGroup'); + + if (!view.nodename) { + throw 'no node name specified'; + } + + if (!volumeGroup) { + throw 'no volume group specified'; + } + + Ext.create('PVE.window.SafeDestroyStorage', { + url: `/nodes/${view.nodename}/disks/lvm/${volumeGroup}`, + item: { id: volumeGroup }, + taskName: 'lvmremove', + taskDone: () => { + view.reload(); + }, + }).show(); + }, + }, + + emptyText: PVE.Utils.renderNotFound('VGs'), + + stateful: true, + stateId: 'grid-node-lvm', + + rootVisible: false, + useArrows: true, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Name'), + dataIndex: 'name', + flex: 1, + }, + { + text: gettext('Number of LVs'), + dataIndex: 'lvcount', + width: 150, + align: 'right', + }, + { + header: gettext('Assigned to LVs'), + width: 130, + dataIndex: 'usage', + tdCls: 'x-progressbar-default-cell', + xtype: 'widgetcolumn', + widget: { + xtype: 'pveProgressBar', + }, + }, + { + header: gettext('Size'), + width: 100, + align: 'right', + sortable: true, + renderer: Proxmox.Utils.format_size, + dataIndex: 'size', + }, + { + header: gettext('Free'), + width: 100, + align: 'right', + sortable: true, + renderer: Proxmox.Utils.format_size, + dataIndex: 'free', + }, + ], + + tbar: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: function () { + this.up('panel').reload(); + }, + }, + { + text: gettext('Create') + ': Volume Group', + handler: function () { + let view = this.up('panel'); + Ext.create('PVE.node.CreateLVM', { + nodename: view.nodename, + taskDone: () => view.reload(), + autoShow: true, + }); + }, + }, + '->', + { + xtype: 'tbtext', + data: { + volumeGroup: undefined, + }, + bind: { + data: { + volumeGroup: '{volumeGroup}', + }, + }, + tpl: [ + '', + 'Volume group {volumeGroup}:', + '', + Ext.String.format(gettext('No {0} selected'), 'volume group'), + '', + ], + }, + { + text: gettext('More'), + iconCls: 'fa fa-bars', + disabled: true, + bind: { + disabled: '{!volumeGroup}', + }, + menu: [ + { + text: gettext('Destroy'), + itemId: 'remove', + iconCls: 'fa fa-fw fa-trash-o', + handler: 'destroyVolumeGroup', + disabled: true, + bind: { + disabled: '{!volumeGroup}', + }, + }, + ], + }, + ], + + reload: function () { + let me = this; + let sm = me.getSelectionModel(); + Proxmox.Utils.API2Request({ + url: `/nodes/${me.nodename}/disks/lvm`, + waitMsgTarget: me, + method: 'GET', + failure: (response, opts) => Proxmox.Utils.setErrorMask(me, response.htmlStatus), + success: function (response, opts) { + sm.deselectAll(); + me.setRootNode(response.result.data); + me.expandAll(); + }, + }); + }, + + listeners: { + activate: function () { + this.reload(); + }, + selectionchange: function (model, selected) { + let me = this; + let vm = me.getViewModel(); + + if (selected.length < 1 || selected[0].data.parentId !== 'root') { + vm.set('volumeGroup', ''); + } else { + vm.set('volumeGroup', selected[0].data.name); + } + }, + }, + + selModel: 'treemodel', + fields: [ + 'name', + 'size', + 'free', + { + type: 'string', + name: 'iconCls', + calculate: (data) => `fa x-fa-tree fa-${data.leaf ? 'hdd-o' : 'object-group'}`, + }, + { + type: 'number', + name: 'usage', + calculate: (data) => (data.size - data.free) / data.size, + }, + ], + sorters: 'name', + + initComponent: function () { + let me = this; + + me.nodename = me.pveSelNode.data.node; + if (!me.nodename) { + throw 'no node name specified'; + } + me.callParent(); + + me.reload(); + }, +}); +Ext.define('PVE.node.CreateLVMThin', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCreateLVMThin', + + onlineHelp: 'chapter_lvm', + subject: 'LVM Thinpool', + + showProgress: true, + isCreate: true, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + Ext.applyIf(me, { + url: `/nodes/${me.nodename}/disks/lvmthin`, + method: 'POST', + items: [ + { + xtype: 'pmxDiskSelector', + name: 'device', + nodename: me.nodename, + diskType: 'unused', + includePartitions: true, + fieldLabel: gettext('Disk'), + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'name', + fieldLabel: gettext('Name'), + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'add_storage', + fieldLabel: gettext('Add Storage'), + value: '1', + }, + ], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.node.LVMThinList', { + extend: 'Ext.grid.Panel', + xtype: 'pveLVMThinList', + + viewModel: { + data: { + thinPool: '', + volumeGroup: '', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + destroyThinPool: function () { + let me = this; + let vm = me.getViewModel(); + let view = me.getView(); + + const thinPool = vm.get('thinPool'); + const volumeGroup = vm.get('volumeGroup'); + + if (!view.nodename) { + throw 'no node name specified'; + } + + if (!thinPool) { + throw 'no thin pool specified'; + } + + if (!volumeGroup) { + throw 'no volume group specified'; + } + + Ext.create('PVE.window.SafeDestroyStorage', { + url: `/nodes/${view.nodename}/disks/lvmthin/${thinPool}`, + params: { 'volume-group': volumeGroup }, + item: { id: `${volumeGroup}/${thinPool}` }, + taskName: 'lvmthinremove', + taskDone: () => { + view.reload(); + }, + }).show(); + }, + }, + + emptyText: PVE.Utils.renderNotFound('Thin-Pool'), + + stateful: true, + stateId: 'grid-node-lvmthin', + + rootVisible: false, + useArrows: true, + + columns: [ + { + text: gettext('Name'), + dataIndex: 'lv', + flex: 1, + }, + { + header: 'Volume Group', + width: 110, + dataIndex: 'vg', + }, + { + header: gettext('Usage'), + width: 110, + dataIndex: 'usage', + tdCls: 'x-progressbar-default-cell', + xtype: 'widgetcolumn', + widget: { + xtype: 'pveProgressBar', + }, + }, + { + header: gettext('Size'), + width: 100, + align: 'right', + sortable: true, + renderer: Proxmox.Utils.format_size, + dataIndex: 'lv_size', + }, + { + header: gettext('Used'), + width: 100, + align: 'right', + sortable: true, + renderer: Proxmox.Utils.format_size, + dataIndex: 'used', + }, + { + header: gettext('Metadata Usage'), + width: 120, + dataIndex: 'metadata_usage', + tdCls: 'x-progressbar-default-cell', + xtype: 'widgetcolumn', + widget: { + xtype: 'pveProgressBar', + }, + }, + { + header: gettext('Metadata Size'), + width: 120, + align: 'right', + sortable: true, + renderer: Proxmox.Utils.format_size, + dataIndex: 'metadata_size', + }, + { + header: gettext('Metadata Used'), + width: 125, + align: 'right', + sortable: true, + renderer: Proxmox.Utils.format_size, + dataIndex: 'metadata_used', + }, + ], + + tbar: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: function () { + this.up('panel').reload(); + }, + }, + { + text: gettext('Create') + ': Thinpool', + handler: function () { + var view = this.up('panel'); + Ext.create('PVE.node.CreateLVMThin', { + nodename: view.nodename, + taskDone: () => view.reload(), + autoShow: true, + }); + }, + }, + '->', + { + xtype: 'tbtext', + data: { + thinPool: undefined, + volumeGroup: undefined, + }, + bind: { + data: { + thinPool: '{thinPool}', + volumeGroup: '{volumeGroup}', + }, + }, + tpl: [ + '', + '', + 'Thinpool {volumeGroup}/{thinPool}:', + '', // volumeGroup + 'Missing volume group (node running old version?)', + '', + '', // thinPool + Ext.String.format(gettext('No {0} selected'), 'thinpool'), + '', + ], + }, + { + text: gettext('More'), + iconCls: 'fa fa-bars', + disabled: true, + bind: { + disabled: '{!volumeGroup || !thinPool}', + }, + menu: [ + { + text: gettext('Destroy'), + itemId: 'remove', + iconCls: 'fa fa-fw fa-trash-o', + handler: 'destroyThinPool', + disabled: true, + bind: { + disabled: '{!volumeGroup || !thinPool}', + }, + }, + ], + }, + ], + + reload: function () { + let me = this; + me.store.load(); + me.store.sort(); + }, + + listeners: { + activate: function () { + this.reload(); + }, + selectionchange: function (model, selected) { + let me = this; + let vm = me.getViewModel(); + + vm.set('volumeGroup', selected[0]?.data.vg || ''); + vm.set('thinPool', selected[0]?.data.lv || ''); + }, + }, + + initComponent: function () { + let me = this; + + me.nodename = me.pveSelNode.data.node; + if (!me.nodename) { + throw 'no node name specified'; + } + + Ext.apply(me, { + store: { + fields: [ + 'lv', + 'lv_size', + 'used', + 'metadata_size', + 'metadata_used', + { + type: 'number', + name: 'usage', + calculate: (data) => data.used / data.lv_size, + }, + { + type: 'number', + name: 'metadata_usage', + calculate: (data) => data.metadata_used / data.metadata_size, + }, + ], + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/disks/lvmthin`, + }, + sorters: 'lv', + }, + }); + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore(), true); + me.reload(); + }, +}); +Ext.define('PVE.mod.TempHelper', { + //singleton: true, + + requires: ['Ext.util.Format'], + + statics: { + CELSIUS: 0, + FAHRENHEIT: 1 + }, + + srcUnit: null, + dstUnit: null, + + isValidUnit: function (unit) { + return ( + Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) + ); + }, + + constructor: function (config) { + this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; + this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; + }, + + toFahrenheit: function (tempCelsius) { + return Ext.isNumber(tempCelsius) + ? tempCelsius * 9 / 5 + 32 + : NaN; + }, + + toCelsius: function (tempFahrenheit) { + return Ext.isNumber(tempFahrenheit) + ? (tempFahrenheit - 32) * 5 / 9 + : NaN; + }, + + getTemp: function (value) { + if (this.srcUnit !== this.dstUnit) { + switch (this.srcUnit) { + case this.self.CELSIUS: + switch (this.dstUnit) { + case this.self.FAHRENHEIT: + return this.toFahrenheit(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + case this.self.FAHRENHEIT: + switch (this.dstUnit) { + case this.self.CELSIUS: + return this.toCelsius(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + default: + Ext.raise({ + msg: 'Unsupported source temperature unit: ' + this.srcUnit, + }); + } + } else { + return value; + } + }, + + getUnit: function(plainText) { + switch (this.dstUnit) { + case this.self.CELSIUS: + return plainText !== true ? '°C' : '\'C'; + + case this.self.FAHRENHEIT: + return plainText !== true ? '°F' : '\'F'; + + default: + Ext.raise({ + msg: 'Unsupported destination temperature unit: ' + this.srcUnit, + }); + } + }, +}); +Ext.define('PVE.node.StatusView', { + extend: 'Proxmox.panel.StatusView', + alias: 'widget.pveNodeStatus', + + minHeight: 360, + flex: 1, + collapsible: true, + titleCollapse: true, + bodyPadding: '20 15 20 15', + + layout: { + type: 'table', + columns: 2, + trAttrs: { valign: 'top' }, + tableAttrs: { + style: { + width: '100%', + }, + }, + }, + + defaults: { + xtype: 'pmxInfoWidget', + padding: '0 10 5 10', + }, + + items: [ + { + itemId: 'cpuss', + colspan: 2, + printBar: false, + title: gettext('CPU (s)'), + textField: 'cpuinfo', + renderer: Proxmox.Utils.render_cpu_model, + value: '', + }, + { + itemId: 'cpu', + iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', + title: gettext('Usage'), + valueField: 'cpu', + maxField: 'cpuinfo', + renderer: Proxmox.Utils.render_node_cpu_usage, + }, + { + itemId: 'wait', + iconCls: 'fa fa-fw fa-clock-o', + title: gettext('IO delay'), + valueField: 'wait', + rowspan: 2, + }, + { + itemId: 'load', + iconCls: 'fa fa-fw fa-tasks', + title: gettext('Load average'), + printBar: false, + textField: 'loadavg', + }, + { + itemId: 'thermalCpu', + colspan: 2, + printBar: false, + title: gettext('Thermal State'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'sensorsOutput', + renderer: function(value){ + // sensors configuration + const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort(); + const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort(); + const bINTEL = cpuKeysI.length > 0 ? true : false; + const INTELPackagePrefix = 'Core' == 'Core' ? 'Core ' : 'Package id'; + const INTELPackageCaption = 'Core' == 'Core' ? 'Core' : 'Package'; + let AMDPackagePrefix = 'Tccd'; + let AMDPackageCaption = 'CCD'; + + if (cpuKeysA.length > 0) { + let bTccd = false; + let bTctl = false; + let bTdie = false; + let bCpuCoreTemp = false; + cpuKeysA.forEach((cpuKey, cpuIndex) => { + let items = objValue[cpuKey]; + bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; + bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; + bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; + bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; + }); + if (bTccd && 'Core' == 'Core') { + AMDPackagePrefix = 'Tccd'; + AMDPackageCaption = 'ccd'; + } else if (bCpuCoreTemp && 'Core' == 'Package') { + AMDPackagePrefix = 'CPU Core Temp'; + AMDPackageCaption = 'CPU Core Temp'; + } else if (bTdie) { + AMDPackagePrefix = 'Tdie'; + AMDPackageCaption = 'die'; + } else if (bTctl) { + AMDPackagePrefix = 'Tctl'; + AMDPackageCaption = 'ctl'; + } else { + AMDPackagePrefix = 'temp'; + AMDPackageCaption = 'Temp'; + } + } + + const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; + const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; + const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; + const formatTemp = bINTEL ? '0' : '0.0'; + const cpuCount = cpuKeys.length; + let temps = []; + + cpuKeys.forEach((cpuKey, cpuIndex) => { + let cpuTemps = []; + const items = objValue[cpuKey]; + const cpuModel = items.cpu_model || ''; + + const itemKeys = Object.keys(items).filter(item => { + if ('Core' == 'Core') { + // In Core mode: only show individual cores/CCDs, exclude overall CPU temp + return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); + } else { + // In Package mode: show overall CPU temp and package-level readings + return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; + } + }).sort((a, b) => { + // Sort cores numerically + let numA = parseInt(a.match(/\d+/)?.[0] || '0', 10); + let numB = parseInt(b.match(/\d+/)?.[0] || '0', 10); + return numA - numB; + }); + + itemKeys.forEach((coreKey) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN; + Object.keys(items[coreKey]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } + }); + + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + + let tempStr = ''; + + // Enhanced parsing for AMD temperatures + if (coreKey.startsWith('Tccd')) { + let tempIndex = coreKey.match(/Tccd(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + // Handle CPU Core Temp (single overall temperature) + else if (coreKey === 'CPU Core Temp') { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) + else { + let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + // fallback for CPUs which do not have a core index + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + + cpuTemps.push(tempStr); + } + } catch (e) { /*_*/ } + }); + + if(cpuTemps.length > 0) { + temps.push({ model: cpuModel, temps: cpuTemps }); + } + }); + + let result = ''; + temps.forEach((cpuData, cpuIndex) => { + const strCoreTemps = cpuData.temps.map((strTemp, index, arr) => { + return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); + }); + if(strCoreTemps.length > 0) { + let cpuPrefix = cpuCount > 1 ? `CPU ${cpuIndex + 1}` : 'CPU'; + let cpuModelStr = cpuData.model ? ` (${cpuData.model})` : ''; + result += `${cpuPrefix}${cpuModelStr}: ` + strCoreTemps.join('') + (cpuIndex < cpuCount - 1 ? '
    ' : ''); + } + }); + + return '
    ' + (result.length > 0 ? result : 'N/A') + '
    '; + } + }, + { + xtype: 'box', + colspan: 2, + padding: '0 0 10 0', + }, + { + itemId: 'memory2', + colspan: 2, + printBar: false, + title: gettext('Memory'), + textField: 'Memory', + }, + { + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + itemId: 'memory', + title: gettext('Usage'), + valueField: 'memory', + maxField: 'memory', + warningThreshold: 0.9, + criticalThreshold: 0.975, + // TODO: split out ARC usage + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + itemId: 'ksm', + printBar: false, + title: gettext('KSM sharing'), + textField: 'ksm', + renderer: (record) => Proxmox.Utils.render_size(record.shared), + padding: '0 10 10 10', + }, + { + xtype: 'box', + colspan: 2, + padding: '0 0 10 0', + }, + { + itemId: 'gpu_heading', + colspan: 2, + printBar: false, + title: gettext('GPU(s)'), + textField: 'Memory', + }, + { + itemId: 'gpu', + colspan: 2, + iconCls: 'fa fa-desktop', + title: gettext('Device'), + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + console.log(gpuStats); + if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.Intel) { + return 'N/A'; + } + + let html = ''; + + Object.keys(gpuStats.Graphics.Intel).forEach(key => { + const gpuData = gpuStats.Graphics.Intel[key]; + console.log("here1"); + html += `
    `; + html += `
    ${gpuData.name}
    `; + html += `
    `; + + if (gpuData.stats.engines) { + console.log("here2"); + // Render/3D + if (gpuData.stats.engines['Render/3D']) { + html += `Render/3D: ${gpuData.stats.engines['Render/3D'].busy}% | `; + } + + // Video + if (gpuData.stats.engines['Video']) { + html += `Video: ${gpuData.stats.engines['Video'].busy}% | `; + } + + // Blitter + if (gpuData.stats.engines['Blitter']) { + html += `Blitter: ${gpuData.stats.engines['Blitter'].busy}% | `; + } + + // VideoEnhance + if (gpuData.stats.engines['VideoEnhance']) { + html += `VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}% | `; + } + } + + // Power and Frequency info + html += `Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`; + html += ` | Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.frequency?.unit || 'MHz'}`; + + html += `
    `; + }); + + // todo add NVIDIA + + // todo add NVIDIA + + return html; + }, + }, + { + xtype: 'box', + colspan: 2, + padding: '0 0 10 0', + }, + { + itemId: 'Storage', + colspan: 2, + printBar: false, + title: gettext('Storage'), + textField: 'Memory', + }, + { + iconCls: 'fa fa-fw fa-hdd-o', + itemId: 'rootfs', + title: '/ ' + gettext('HD space'), + valueField: 'rootfs', + maxField: 'rootfs', + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + iconCls: 'fa fa-fw fa-refresh', + itemId: 'swap', + printSize: true, + title: gettext('SWAP usage'), + valueField: 'swap', + maxField: 'swap', + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + itemId: 'thermalNvme', + colspan: 2, + printBar: false, + title: gettext('NVMe Thermal State'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'sensorsOutput', + renderer: function(value) { + // sensors configuration + const addressPrefix = "nvme-pci-"; + const sensorName = "Composite"; + const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); + let temps = []; + nvmeKeys.forEach((nvmeKey, index) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN, model = '', serial = ''; + Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } + model = objValue[nvmeKey]['model'] || ''; + serial = objValue[nvmeKey]['serial'] || ''; + }); + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + const tempStr = `${model} (${serial}): ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`; + temps.push(tempStr); + } + } catch(e) { /*_*/ } + }); + const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); }); + return '
    ' + (result.length > 0 ? result.join('') : 'N/A') + '
    '; + } + }, + { + xtype: 'box', + colspan: 2, + padding: '0 0 10 0', + }, + { + itemId: 'Coooling', + colspan: 2, + printBar: false, + title: gettext('Cooling'), + }, + { + itemId: 'speedFan', + colspan: 2, + printBar: false, + title: gettext('Fan Speed(s)'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'sensorsOutput', + renderer: function(value) { + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + // Recursive function to find fan keys and values + function findFanKeys(obj, fanKeys, parentKey = null) { + Object.keys(obj).forEach(key => { + const value = obj[key]; + if (typeof value === 'object' && value !== null) { + // If the value is an object, recursively call the function + findFanKeys(value, fanKeys, key); + } else if (/^fan[0-9]+(_input)?$/.test(key)) { + if (true != true && value === 0) { + // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 + return; + } + // If the key matches the pattern, add the parent key and value to the fanKeys array + fanKeys.push({ key: parentKey, value: value }); + } + }); + } + + let speeds = []; + // Loop through the parent keys + Object.keys(objValue).forEach(parentKey => { + const parentObj = objValue[parentKey]; + // Array to store fan keys and values + const fanKeys = []; + // Call the recursive function to find fan keys and values + findFanKeys(parentObj, fanKeys); + // Sort the fan keys + fanKeys.sort(); + // Process each fan key and value + fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { + try { + const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey + speeds.push(`${fan}: ${fanSpeed} RPM`); + } catch(e) { + console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error + } + }); + }); + return '
    ' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
    '; + } + }, + { + xtype: 'box', + colspan: 2, + padding: '0 0 10 0', + }, + { + colspan: 2, + title: gettext('Kernel Version'), + printBar: false, + // TODO: remove with next major and only use newish current-kernel textfield + multiField: true, + //textField: 'current-kernel', + renderer: ({ data }) => { + if (!data['current-kernel']) { + return data.kversion; + } + let kernel = data['current-kernel']; + let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown'; + return `${kernel.sysname} ${kernel.release} (${buildDate})`; + }, + value: '', + }, + { + colspan: 2, + title: gettext('Boot Mode'), + printBar: false, + textField: 'boot-info', + renderer: (boot) => { + if (boot.mode === 'legacy-bios') { + return 'Legacy BIOS'; + } else if (boot.mode === 'efi') { + return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`; + } + return Proxmox.Utils.unknownText; + }, + value: '', + }, + { + itemId: 'version', + colspan: 2, + printBar: false, + title: gettext('Manager Version'), + textField: 'pveversion', + value: '', + }, + { + itemId: 'pve_mod_version', + colspan: 2, + printBar: false, + title: gettext('Sensor Mod Version'), + textField: 'pveversion', + value: '', + }, + ], + + updateTitle: function () { + var me = this; + var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); + me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); + }, + + initComponent: function () { + let me = this; + + let stateProvider = Ext.state.Manager.getProvider(); + let repoLink = stateProvider.encodeHToken({ + view: 'server', + rid: `node/${me.pveSelNode.data.node}`, + ltab: 'tasks', + nodetab: 'aptrepositories', + }); + + me.items.push({ + xtype: 'pmxNodeInfoRepoStatus', + itemId: 'repositoryStatus', + product: 'Proxmox VE', + repoLink: `#${repoLink}`, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.node.SubscriptionKeyEdit', { + extend: 'Proxmox.window.Edit', + + title: gettext('Upload Subscription Key'), + width: 350, + + items: { + xtype: 'textfield', + name: 'key', + value: '', + fieldLabel: gettext('Subscription Key'), + labelWidth: 120, + getSubmitValue: function () { + return this.processRawValue(this.getRawValue())?.trim(); + }, + }, + + initComponent: function () { + var me = this; + + me.callParent(); + + me.load(); + }, +}); + +Ext.define('PVE.node.Subscription', { + extend: 'Proxmox.grid.ObjectGrid', + + alias: ['widget.pveNodeSubscription'], + + onlineHelp: 'getting_help', + + viewConfig: { + enableTextSelection: true, + }, + + showReport: function () { + var me = this; + + var getReportFileName = function () { + var now = Ext.Date.format(new Date(), 'D-d-F-Y-G-i'); + return `${me.nodename}-pve-report-${now}.txt`; + }; + + var view = Ext.createWidget('component', { + itemId: 'system-report-view', + scrollable: true, + style: { + 'white-space': 'pre', + 'font-family': 'monospace', + padding: '5px', + }, + }); + + var reportWindow = Ext.create('Ext.window.Window', { + title: gettext('System Report'), + width: 1024, + height: 600, + layout: 'fit', + modal: true, + buttons: [ + '->', + { + text: gettext('Download'), + handler: function () { + var fileContent = Ext.String.htmlDecode( + reportWindow.getComponent('system-report-view').html, + ); + var fileName = getReportFileName(); + + // Internet Explorer + if (window.navigator.msSaveOrOpenBlob) { + navigator.msSaveOrOpenBlob(new Blob([fileContent]), fileName); + } else { + let element = document.createElement('a'); + element.setAttribute( + 'href', + 'data:text/plain;charset=utf-8,' + encodeURIComponent(fileContent), + ); + element.setAttribute('download', fileName); + element.style.display = 'none'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + } + }, + }, + ], + items: view, + }); + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/nodes/' + me.nodename + '/report', + method: 'GET', + waitMsgTarget: me, + failure: function (response) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response) { + var report = Ext.htmlEncode(response.result.data); + reportWindow.show(); + view.update(report); + }, + }); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + let rows = { + productname: { + header: gettext('Type'), + }, + key: { + header: gettext('Subscription Key'), + }, + status: { + header: gettext('Status'), + renderer: (v) => { + let message = me.getObjectValue('message'); + return message ? `${v}: ${message}` : v; + }, + }, + message: { + visible: false, + }, + serverid: { + header: gettext('Server ID'), + }, + sockets: { + header: gettext('Sockets'), + }, + checktime: { + header: gettext('Last checked'), + renderer: Proxmox.Utils.render_timestamp, + }, + nextduedate: { + header: gettext('Next due date'), + }, + signature: { + header: gettext('Signed/Offline'), + renderer: (v) => (v ? gettext('Yes') : gettext('No')), + }, + }; + + Ext.apply(me, { + url: `/api2/json/nodes/${me.nodename}/subscription`, + cwidth1: 170, + tbar: [ + { + text: gettext('Upload Subscription Key'), + handler: () => + Ext.create('PVE.node.SubscriptionKeyEdit', { + autoShow: true, + url: `/api2/extjs/nodes/${me.nodename}/subscription`, + listeners: { + destroy: () => me.rstore.load(), + }, + }), + }, + { + text: gettext('Check'), + handler: () => + Proxmox.Utils.API2Request({ + params: { force: 1 }, + url: `/nodes/${me.nodename}/subscription`, + method: 'POST', + waitMsgTarget: me, + failure: (response) => + Ext.Msg.alert(gettext('Error'), response.htmlStatus), + callback: () => me.rstore.load(), + }), + }, + { + text: gettext('Remove Subscription'), + xtype: 'proxmoxStdRemoveButton', + confirmMsg: gettext('Are you sure you want to remove the subscription key?'), + baseurl: `/nodes/${me.nodename}/subscription`, + dangerous: true, + selModel: false, + callback: () => me.rstore.load(), + }, + '-', + { + text: gettext('System Report'), + handler: function () { + Proxmox.Utils.checked_command(function () { + me.showReport(); + }); + }, + }, + ], + rows: rows, + listeners: { + activate: () => me.rstore.load(), + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.node.Summary', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeSummary', + + scrollable: true, + bodyPadding: 5, + + showVersions: function () { + var me = this; + + // Note: we use simply text/html here, because ExtJS grid has problems + // with cut&paste + + var nodename = me.pveSelNode.data.node; + + var view = Ext.createWidget('component', { + autoScroll: true, + id: 'pkgversions', + padding: 5, + style: { + 'white-space': 'pre', + 'font-family': 'monospace', + }, + }); + + var win = Ext.create('Ext.window.Window', { + title: gettext('Package versions'), + width: 600, + height: 600, + layout: 'fit', + modal: true, + items: [view], + buttons: [ + { + xtype: 'button', + iconCls: 'fa fa-clipboard', + handler: function (button) { + window + .getSelection() + .selectAllChildren(document.getElementById('pkgversions')); + document.execCommand('copy'); + }, + text: gettext('Copy'), + }, + { + text: gettext('Ok'), + handler: function () { + this.up('window').close(); + }, + }, + ], + }); + + Proxmox.Utils.API2Request({ + waitMsgTarget: me, + url: `/nodes/${nodename}/apt/versions`, + method: 'GET', + failure: function (response, opts) { + win.close(); + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, opts) { + win.show(); + let text = ''; + Ext.Array.each(response.result.data, function (rec) { + let version = 'not correctly installed'; + let pkg = rec.Package; + if (rec.OldVersion && rec.CurrentState === 'Installed') { + version = rec.OldVersion; + } + if (rec.RunningKernel) { + text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; + } else if (rec.ManagerVersion) { + text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; + } else { + text += `${pkg}: ${version}\n`; + } + }); + + view.update(Ext.htmlEncode(text)); + }, + }); + }, + + updateRepositoryStatus: function () { + let me = this; + let repoStatus = me.nodeStatus.down('#repositoryStatus'); + + let nodename = me.pveSelNode.data.node; + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/apt/repositories`, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: (response) => + repoStatus.setRepositoryInfo(response.result.data['standard-repos']), + }); + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/subscription`, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function (response, opts) { + const res = response.result; + const subscription = res?.data?.status.toLowerCase() === 'active'; + repoStatus.setSubscriptionStatus(subscription); + }, + }); + }, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + if (!me.statusStore) { + throw 'no status storage specified'; + } + + var rstore = me.statusStore; + + var version_btn = new Ext.Button({ + text: gettext('Package versions'), + handler: function () { + Proxmox.Utils.checked_command(function () { + me.showVersions(); + }); + }, + }); + + var rrdstore = Ext.create('Proxmox.data.RRDStore', { + rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', + model: 'pve-rrd-node', + }); + + let nodeStatus = Ext.create('PVE.node.StatusView', { + xtype: 'pveNodeStatus', + rstore: rstore, + width: 770, + pveSelNode: me.pveSelNode, + }); + + Ext.apply(me, { + tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], + nodeStatus: nodeStatus, + items: [ +{ + xtype: 'container', + itemId: 'summarycontainer', + layout: 'column', + minWidth: 700, + defaults: { + minHeight: 350, + padding: 5, + columnWidth: 1, + }, + items: [ + nodeStatus, + ] +}, + { + xtype: 'container', + itemId: 'itemcontainer', + layout: 'column', + minWidth: 700, + defaults: { + minHeight: 360, + padding: 5, + columnWidth: 1, + }, + items: [ + //nodeStatus, + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Usage'), + fields: ['cpu', 'iowait'], + fieldTitles: [gettext('CPU usage'), gettext('IO delay')], + unit: 'percent', + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Server Load'), + fields: ['loadavg'], + fieldTitles: [gettext('Load average')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory usage'), + fields: [ + { + yField: 'memtotal', + title: gettext('Total'), + tooltip: { + trackMouse: true, + renderer: function (toolTip, record, item) { + let value = record.get('memtotal'); + + if (value === null) { + toolTip.setHtml(gettext('No Data')); + } else { + let total = Proxmox.Utils.format_size(value); + let time = new Date(record.get('time')); + + let avail = record.get('memavailable'); + let availText = ''; + if (Ext.isNumeric(avail)) { + let v = Proxmox.Utils.format_size(avail); + availText = ` (${gettext('Available')}: ${v})`; + } + + toolTip.setHtml( + `${gettext('Total')}: ${total}${availText}
    ${time}`, + ); + } + }, + }, + }, + { + yField: 'memused', + title: gettext('Used'), + tooltip: { + trackMouse: true, + renderer: function (toolTip, record, item) { + let value = record.get('memused'); + + if (value === null) { + toolTip.setHtml(gettext('No Data')); + } else { + let total = Proxmox.Utils.format_size(value); + let time = new Date(record.get('time')); + + let arc = record.get('arcsize'); + let arcText = ''; + if (Ext.isNumeric(arc) && arc > 1024 * 1024) { + let v = Proxmox.Utils.format_size(value - arc); + arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; + } + + toolTip.setHtml( + `${gettext('Used')}: ${total}${arcText}
    ${time}`, + ); + } + }, + }, + }, + 'arcsize', + { + type: 'line', + fill: false, + yField: 'memavailable', + title: gettext('Available'), + style: { + lineWidth: 2.5, + opacity: 1, + }, + }, + ], + fieldTitles: [ + gettext('Total'), + gettext('Used'), + gettext('ZFS ARC'), + gettext('Available'), + ], + colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], + unit: 'bytes', + powerOfTwo: true, + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Network Traffic'), + fields: ['netin', 'netout'], + fieldTitles: [gettext('Incoming'), gettext('Outgoing')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Pressure Stall'), + fieldTitles: ['Some'], + fields: ['pressurecpusome'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('IO Pressure Stall'), + fieldTitles: ['Some', 'Full'], + fields: ['pressureiosome', 'pressureiofull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory Pressure Stall'), + fieldTitles: ['Some', 'Full'], + fields: ['pressurememorysome', 'pressurememoryfull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + ], + listeners: { + resize: function (panel) { + Proxmox.Utils.updateColumns(panel); + }, + }, + }, + ], + listeners: { + activate: function () { + rstore.setInterval(1000); + rstore.startUpdate(); // just to be sure + rrdstore.startUpdate(); + }, + destroy: function () { + rstore.setInterval(5000); // don't stop it, it's not ours! + rrdstore.stopUpdate(); + }, + }, + }); + + me.updateRepositoryStatus(); + + me.callParent(); + + let sp = Ext.state.Manager.getProvider(); + me.mon(sp, 'statechange', function (provider, key, value) { + if (key !== 'summarycolumns') { + return; + } + Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); + }); + }, +}); +Ext.define('PVE.node.CreateZFS', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCreateZFS', + + onlineHelp: 'chapter_zfs', + subject: 'ZFS', + + showProgress: true, + isCreate: true, + width: 800, + + viewModel: { + data: { + raidLevel: 'single', + }, + formulas: { + isDraid: (get) => get('raidLevel')?.startsWith('draid'), + }, + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + Ext.apply(me, { + url: `/nodes/${me.nodename}/disks/zfs`, + method: 'POST', + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + if (values.draidData || values.draidSpares) { + let opt = { data: values.draidData, spares: values.draidSpares }; + values['draid-config'] = PVE.Parser.printPropertyString(opt); + } + delete values.draidData; + delete values.draidSpares; + return values; + }, + column1: [ + { + xtype: 'proxmoxtextfield', + name: 'name', + fieldLabel: gettext('Name'), + allowBlank: false, + maxLength: 128, // ZFS_MAX_DATASET_NAME_LEN is (256 - some edge case) + validator: (v) => { + // see zpool_name_valid function in libzfs_zpool.c + if (v.match(/^(mirror|raidz|draid|spare)/) || v === 'log') { + return gettext('Cannot use reserved pool name'); + } else if (!v.match(/^[a-zA-Z][a-zA-Z0-9\-_.]*$/)) { + // note: zfs would support also : and whitespace, but we don't + return gettext('Invalid characters in pool name'); + } + return true; + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'add_storage', + fieldLabel: gettext('Add Storage'), + value: '1', + }, + ], + column2: [ + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('RAID Level'), + name: 'raidlevel', + value: 'single', + comboItems: [ + ['single', gettext('Single Disk')], + ['mirror', 'Mirror'], + ['raid10', 'RAID10'], + ['raidz', 'RAIDZ'], + ['raidz2', 'RAIDZ2'], + ['raidz3', 'RAIDZ3'], + ['draid', 'dRAID'], + ['draid2', 'dRAID2'], + ['draid3', 'dRAID3'], + ], + bind: { + value: '{raidLevel}', + }, + }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Compression'), + name: 'compression', + value: 'on', + comboItems: [ + ['on', 'on'], + ['off', 'off'], + ['gzip', 'gzip'], + ['lz4', 'lz4'], + ['lzjb', 'lzjb'], + ['zle', 'zle'], + ['zstd', 'zstd'], + ], + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('ashift'), + minValue: 9, + maxValue: 16, + value: '12', + name: 'ashift', + }, + ], + columnB: [ + { + xtype: 'fieldset', + title: gettext('dRAID Config'), + collapsible: false, + bind: { + hidden: '{!isDraid}', + }, + layout: 'hbox', + padding: '5px 10px', + defaults: { + flex: 1, + layout: 'anchor', + }, + items: [ + { + xtype: 'proxmoxintegerfield', + name: 'draidData', + fieldLabel: gettext('Data Devs'), + minValue: 1, + allowBlank: false, + disabled: true, + hidden: true, + bind: { + disabled: '{!isDraid}', + hidden: '{!isDraid}', + }, + padding: '0 10 0 0', + }, + { + xtype: 'proxmoxintegerfield', + name: 'draidSpares', + fieldLabel: gettext('Spares'), + minValue: 0, + allowBlank: false, + disabled: true, + hidden: true, + bind: { + disabled: '{!isDraid}', + hidden: '{!isDraid}', + }, + padding: '0 0 0 10', + }, + ], + }, + { + xtype: 'pmxMultiDiskSelector', + name: 'devices', + nodename: me.nodename, + diskType: 'unused', + includePartitions: true, + height: 200, + emptyText: gettext('No Disks unused'), + itemId: 'disklist', + }, + ], + }, + { + xtype: 'displayfield', + padding: '5 0 0 0', + userCls: 'pmx-hint', + value: + 'Note: ZFS is not compatible with disks backed by a hardware ' + + 'RAID controller. For details see the reference documentation.', + }, + ], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.node.ZFSList', { + extend: 'Ext.grid.Panel', + xtype: 'pveZFSList', + + viewModel: { + data: { + pool: '', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + destroyPool: function () { + let me = this; + let vm = me.getViewModel(); + let view = me.getView(); + + const pool = vm.get('pool'); + + if (!view.nodename) { + throw 'no node name specified'; + } + + if (!pool) { + throw 'no pool specified'; + } + + Ext.create('PVE.window.SafeDestroyStorage', { + url: `/nodes/${view.nodename}/disks/zfs/${pool}`, + item: { id: pool }, + taskName: 'zfsremove', + taskDone: () => { + view.reload(); + }, + }).show(); + }, + }, + + stateful: true, + stateId: 'grid-node-zfs', + columns: [ + { + text: gettext('Name'), + dataIndex: 'name', + flex: 1, + }, + { + header: gettext('Size'), + renderer: Proxmox.Utils.format_size, + dataIndex: 'size', + }, + { + header: gettext('Free'), + renderer: Proxmox.Utils.format_size, + dataIndex: 'free', + }, + { + header: gettext('Allocated'), + renderer: Proxmox.Utils.format_size, + dataIndex: 'alloc', + }, + { + header: gettext('Fragmentation'), + renderer: function (value) { + return value.toString() + '%'; + }, + dataIndex: 'frag', + }, + { + header: gettext('Health'), + renderer: PVE.Utils.render_zfs_health, + dataIndex: 'health', + }, + { + header: gettext('Deduplication'), + hidden: true, + renderer: function (value) { + return value.toFixed(2).toString() + 'x'; + }, + dataIndex: 'dedup', + }, + ], + + rootVisible: false, + useArrows: true, + + tbar: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: function () { + this.up('panel').reload(); + }, + }, + { + text: gettext('Create') + ': ZFS', + handler: function () { + let view = this.up('panel'); + Ext.create('PVE.node.CreateZFS', { + nodename: view.nodename, + listeners: { + destroy: () => view.reload(), + }, + autoShow: true, + }); + }, + }, + { + text: gettext('Detail'), + itemId: 'detailbtn', + disabled: true, + handler: function () { + let view = this.up('panel'); + let selection = view.getSelection(); + if (selection.length) { + view.show_detail(selection[0].get('name')); + } + }, + }, + '->', + { + xtype: 'tbtext', + data: { + pool: undefined, + }, + bind: { + data: { + pool: '{pool}', + }, + }, + tpl: [ + '', + 'Pool {pool}:', + '', + Ext.String.format(gettext('No {0} selected'), 'pool'), + '', + ], + }, + { + text: gettext('More'), + iconCls: 'fa fa-bars', + disabled: true, + bind: { + disabled: '{!pool}', + }, + menu: [ + { + text: gettext('Destroy'), + itemId: 'remove', + iconCls: 'fa fa-fw fa-trash-o', + handler: 'destroyPool', + disabled: true, + bind: { + disabled: '{!pool}', + }, + }, + ], + }, + ], + + show_detail: function (zpool) { + let me = this; + + Ext.create('Proxmox.window.ZFSDetail', { + zpool, + nodename: me.nodename, + }).show(); + }, + + set_button_status: function () { + var _me = this; + }, + + reload: function () { + var me = this; + me.store.load(); + me.store.sort(); + }, + + listeners: { + activate: function () { + this.reload(); + }, + selectionchange: function (model, selected) { + let me = this; + let vm = me.getViewModel(); + + me.down('#detailbtn').setDisabled(selected.length === 0); + vm.set('pool', selected[0]?.data.name || ''); + }, + itemdblclick: function (grid, record) { + this.show_detail(record.get('name')); + }, + }, + + initComponent: function () { + let me = this; + + me.nodename = me.pveSelNode.data.node; + if (!me.nodename) { + throw 'no node name specified'; + } + + Ext.apply(me, { + store: { + fields: ['name', 'size', 'free', 'alloc', 'dedup', 'frag', 'health'], + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/disks/zfs`, + }, + sorters: 'name', + }, + }); + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore(), true); + me.reload(); + }, +}); +Ext.define('Proxmox.node.NodeOptionsView', { + extend: 'Proxmox.grid.ObjectGrid', + alias: ['widget.proxmoxNodeOptionsView'], + mixins: ['Proxmox.Mixin.CBind'], + + cwidth1: 250, + + cbindData: function (_initialconfig) { + let me = this; + + let baseUrl = `/nodes/${me.nodename}/config`; + me.url = `/api2/json${baseUrl}`; + me.editorConfig = { + url: `/api2/extjs/${baseUrl}`, + }; + + return {}; + }, + + listeners: { + itemdblclick: function () { + this.run_editor(); + }, + activate: function () { + this.rstore.startUpdate(); + }, + destroy: function () { + this.rstore.stopUpdate(); + }, + deactivate: function () { + this.rstore.stopUpdate(); + }, + }, + + tbar: [ + { + text: gettext('Edit'), + xtype: 'proxmoxButton', + disabled: true, + handler: (btn) => btn.up('grid').run_editor(), + }, + ], + + gridRows: [ + { + xtype: 'integer', + name: 'startall-onboot-delay', + text: gettext('Start on boot delay'), + minValue: 0, + maxValue: 300, + labelWidth: 130, + deleteEmpty: true, + renderer: function (value) { + if (value === undefined) { + return Proxmox.Utils.defaultText; + } + + // TODO: simplify once we can use ngetext + let secString = value === '1' ? gettext('Second') : gettext('Seconds'); + return `${value} ${secString}`; + }, + }, + { + xtype: 'text', + name: 'wakeonlan', + text: gettext('MAC address for Wake on LAN'), + vtype: 'MacAddress', + labelWidth: 150, + deleteEmpty: true, + renderer: (value) => (value !== undefined ? value : Proxmox.Utils.NoneText), + }, + { + xtype: 'integer', + name: 'ballooning-target', + text: gettext('RAM usage target for ballooning'), + minValue: 0, + maxValue: 100, + deleteEmpty: true, + onlineHelp: 'qm_memory', + renderer: (value) => (value !== undefined ? `${value}%` : gettext('Default (80%)')), + }, + ], +}); +Ext.define('PVE.pool.Config', { + extend: 'PVE.panel.Config', + alias: 'widget.pvePoolConfig', + + onlineHelp: 'pveum_pools', + + initComponent: function () { + var me = this; + + var pool = me.pveSelNode.data.pool; + if (!pool) { + throw 'no pool specified'; + } + + Ext.apply(me, { + title: Ext.String.format(gettext('Resource Pool') + ': ' + pool), + hstateid: 'pooltab', + items: [ + { + title: gettext('Summary'), + iconCls: 'fa fa-book', + xtype: 'pvePoolSummary', + itemId: 'summary', + }, + { + title: gettext('Members'), + xtype: 'pvePoolMembers', + iconCls: 'fa fa-th', + pool: pool, + itemId: 'members', + }, + { + xtype: 'pveACLView', + title: gettext('Permissions'), + iconCls: 'fa fa-unlock', + itemId: 'permissions', + path: '/pool/' + pool, + }, + ], + }); + + me.callParent(); + }, +}); +Ext.define('PVE.pool.StatusView', { + extend: 'Proxmox.grid.ObjectGrid', + alias: ['widget.pvePoolStatusView'], + disabled: true, + + title: gettext('Status'), + cwidth1: 150, + interval: 30000, + //height: 195, + initComponent: function () { + var me = this; + + var pool = me.pveSelNode.data.pool; + if (!pool) { + throw 'no pool specified'; + } + + var rows = { + comment: { + header: gettext('Comment'), + renderer: Ext.String.htmlEncode, + required: true, + }, + }; + + Ext.apply(me, { + url: '/api2/json/pools/?poolid=' + pool, + rows: rows, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.pool.Summary', { + extend: 'Ext.panel.Panel', + alias: 'widget.pvePoolSummary', + + initComponent: function () { + var me = this; + + var pool = me.pveSelNode.data.pool; + if (!pool) { + throw 'no pool specified'; + } + + var statusview = Ext.create('PVE.pool.StatusView', { + pveSelNode: me.pveSelNode, + style: 'padding-top:0px', + }); + + var rstore = statusview.rstore; + + Ext.apply(me, { + autoScroll: true, + bodyStyle: 'padding:10px', + defaults: { + style: 'padding-top:10px', + width: 800, + }, + items: [statusview], + }); + + me.on('activate', rstore.startUpdate); + me.on('destroy', rstore.stopUpdate); + + me.callParent(); + }, +}); +Ext.define('PVE.qemu.AudioInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveAudioInputPanel', + + onlineHelp: 'qm_audio_device', + + onGetValues: function (values) { + var ret = PVE.Parser.printPropertyString(values); + if (ret === '') { + return { + delete: 'audio0', + }; + } + return { + audio0: ret, + }; + }, + + items: [ + { + name: 'device', + xtype: 'proxmoxKVComboBox', + value: 'ich9-intel-hda', + fieldLabel: gettext('Audio Device'), + comboItems: [ + ['ich9-intel-hda', 'ich9-intel-hda'], + ['intel-hda', 'intel-hda'], + ['AC97', 'AC97'], + ], + }, + { + name: 'driver', + xtype: 'proxmoxKVComboBox', + value: 'spice', + fieldLabel: gettext('Backend Driver'), + comboItems: [ + ['spice', 'SPICE'], + ['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`], + ], + }, + ], +}); + +Ext.define('PVE.qemu.AudioEdit', { + extend: 'Proxmox.window.Edit', + + vmconfig: undefined, + + subject: gettext('Audio Device'), + + items: [ + { + xtype: 'pveAudioInputPanel', + }, + ], + + initComponent: function () { + var me = this; + + me.callParent(); + + me.load({ + success: function (response) { + me.vmconfig = response.result.data; + + var audio0 = me.vmconfig.audio0; + if (audio0) { + me.setValues(PVE.Parser.parsePropertyString(audio0)); + } + }, + }); + }, +}); +Ext.define('pve-boot-order-entry', { + extend: 'Ext.data.Model', + fields: [ + { name: 'name', type: 'string' }, + { name: 'enabled', type: 'bool' }, + { name: 'desc', type: 'string' }, + ], +}); + +Ext.define('PVE.qemu.BootOrderPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuBootOrderPanel', + + onlineHelp: 'qm_bootorder', + + vmconfig: {}, // store loaded vm config + store: undefined, + + inUpdate: false, + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + let me = this; + + let grid = me.lookup('grid'); + let marker = me.lookup('marker'); + let emptyWarning = me.lookup('emptyWarning'); + + marker.originalValue = undefined; + + view.store = Ext.create('Ext.data.Store', { + model: 'pve-boot-order-entry', + listeners: { + update: function () { + this.commitChanges(); + let val = view.calculateValue(); + if (marker.originalValue === undefined) { + marker.originalValue = val; + } + view.inUpdate = true; + marker.setValue(val); + view.inUpdate = false; + marker.checkDirty(); + emptyWarning.setHidden(val !== ''); + grid.getView().refresh(); + }, + }, + }); + grid.setStore(view.store); + }, + }, + + isCloudinit: (v) => v.match(/media=cdrom/) && v.match(/[:/]vm-\d+-cloudinit/), + + isDisk: function (value) { + return PVE.Utils.bus_match.test(value); + }, + + isBootdev: function (dev, value) { + return ( + (this.isDisk(dev) && !this.isCloudinit(value)) || + /^net\d+/.test(dev) || + /^hostpci\d+/.test(dev) || + (/^usb\d+/.test(dev) && !/spice/.test(value)) + ); + }, + + setVMConfig: function (vmconfig) { + let me = this; + me.vmconfig = vmconfig; + + me.store.removeAll(); + + let boot = PVE.Parser.parsePropertyString(me.vmconfig.boot, 'legacy'); + + let bootorder = []; + if (boot.order) { + bootorder = boot.order.split(';').map((dev) => ({ name: dev, enabled: true })); + } else if (!/^\s*$/.test(me.vmconfig.boot)) { + // legacy style, transform to new bootorder + let order = boot.legacy || 'cdn'; + let bootdisk = me.vmconfig.bootdisk || undefined; + + // get the first 4 characters (acdn) + // ignore the rest (there should never be more than 4) + let orderList = order.split('').slice(0, 4); + + // build bootdev list + for (let i = 0; i < orderList.length; i++) { + let list = []; + if (orderList[i] === 'c') { + if (bootdisk !== undefined && me.vmconfig[bootdisk]) { + list.push(bootdisk); + } + } else if (orderList[i] === 'd') { + Ext.Object.each(me.vmconfig, function (key, value) { + if ( + me.isDisk(key) && + value.match(/media=cdrom/) && + !me.isCloudinit(value) + ) { + list.push(key); + } + }); + } else if (orderList[i] === 'n') { + Ext.Object.each(me.vmconfig, function (key, value) { + if (/^net\d+/.test(key)) { + list.push(key); + } + }); + } + + // Object.each iterates in random order, sort alphabetically + list.sort(); + list.forEach((dev) => bootorder.push({ name: dev, enabled: true })); + } + } + + // add disabled devices as well + let disabled = []; + Ext.Object.each(me.vmconfig, function (key, value) { + if (me.isBootdev(key, value) && !Ext.Array.some(bootorder, (x) => x.name === key)) { + disabled.push(key); + } + }); + disabled.sort(); + disabled.forEach((dev) => bootorder.push({ name: dev, enabled: false })); + + // add descriptions + bootorder.forEach((entry) => { + entry.desc = me.vmconfig[entry.name]; + }); + + me.store.insert(0, bootorder); + me.store.fireEvent('update'); + }, + + calculateValue: function () { + let me = this; + return me.store + .getData() + .items.filter((x) => x.data.enabled) + .map((x) => x.data.name) + .join(';'); + }, + + onGetValues: function () { + let me = this; + // Note: we allow an empty value, so no 'delete' option + let val = { order: me.calculateValue() }; + let res = { boot: PVE.Parser.printPropertyString(val) }; + return res; + }, + + items: [ + { + xtype: 'grid', + reference: 'grid', + margin: '0 0 5 0', + minHeight: 150, + defaults: { + sortable: false, + hideable: false, + draggable: false, + }, + columns: [ + { + header: '#', + flex: 4, + renderer: (value, metaData, record, rowIndex) => { + let dragHandle = + ""; + let idx = (rowIndex + 1).toString(); + if (record.get('enabled')) { + return dragHandle + idx; + } else { + return dragHandle + "" + idx + ''; + } + }, + }, + { + xtype: 'checkcolumn', + header: gettext('Enabled'), + dataIndex: 'enabled', + flex: 4, + }, + { + header: gettext('Device'), + dataIndex: 'name', + flex: 6, + renderer: (value, metaData, record, rowIndex) => { + let desc = record.get('desc'); + + let icon = '', + iconCls; + if (value.match(/^net\d+$/)) { + iconCls = 'exchange'; + } else if (desc.match(/media=cdrom/)) { + metaData.tdCls = 'pve-itype-icon-cdrom'; + } else { + iconCls = 'hdd-o'; + } + if (iconCls !== undefined) { + metaData.tdCls += 'pve-itype-fa'; + icon = ``; + } + + return icon + value; + }, + }, + { + header: gettext('Description'), + dataIndex: 'desc', + flex: 20, + }, + ], + viewConfig: { + plugins: { + ptype: 'gridviewdragdrop', + dragText: gettext('Drag and drop to reorder'), + }, + }, + listeners: { + drop: function () { + // doesn't fire automatically on reorder + this.getStore().fireEvent('update'); + }, + }, + }, + { + xtype: 'component', + html: gettext('Drag and drop to reorder'), + }, + { + xtype: 'displayfield', + reference: 'emptyWarning', + userCls: 'pmx-hint', + value: gettext('Warning: No devices selected, the VM will probably not boot!'), + }, + { + // for dirty marking and 'reset' function + xtype: 'field', + reference: 'marker', + hidden: true, + setValue: function (val) { + let me = this; + let panel = me.up('pveQemuBootOrderPanel'); + + // on form reset, go back to original state + if (!panel.inUpdate) { + panel.setVMConfig(panel.vmconfig); + } + + // not a subclass, so no callParent; just do it manually + me.setRawValue(me.valueToRaw(val)); + return me.mixins.field.setValue.call(me, val); + }, + }, + ], +}); + +Ext.define('PVE.qemu.BootOrderEdit', { + extend: 'Proxmox.window.Edit', + + items: [ + { + xtype: 'pveQemuBootOrderPanel', + itemId: 'inputpanel', + }, + ], + + subject: gettext('Boot Order'), + width: 640, + + initComponent: function () { + let me = this; + me.callParent(); + me.load({ + success: ({ result }) => me.down('#inputpanel').setVMConfig(result.data), + }); + }, +}); +Ext.define('PVE.qemu.CDInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuCDInputPanel', + + insideWizard: false, + + onGetValues: function (values) { + var me = this; + + var confid = me.confid || values.controller + values.deviceid; + + me.drive.media = 'cdrom'; + if (values.mediaType === 'iso') { + me.drive.file = values.cdimage; + } else if (values.mediaType === 'cdrom') { + me.drive.file = 'cdrom'; + } else { + me.drive.file = 'none'; + } + + var params = {}; + + params[confid] = PVE.Parser.printQemuDrive(me.drive); + + return params; + }, + + setVMConfig: function (vmconfig) { + var me = this; + + if (me.bussel) { + me.bussel.setVMConfig(vmconfig, 'cdrom'); + } + }, + + setDrive: function (drive) { + var me = this; + + var values = {}; + if (drive.file === 'cdrom') { + values.mediaType = 'cdrom'; + } else if (drive.file === 'none') { + values.mediaType = 'none'; + } else { + values.mediaType = 'iso'; + values.cdimage = drive.file; + } + + me.drive = drive; + + me.setValues(values); + }, + + setNodename: function (nodename) { + var me = this; + + me.isosel.setNodename(nodename); + }, + + initComponent: function () { + var me = this; + + me.drive = {}; + + var items = []; + + if (!me.confid) { + me.bussel = Ext.create('PVE.form.ControllerSelector', { + withVirtIO: false, + }); + items.push(me.bussel); + } + + items.push({ + xtype: 'radiofield', + name: 'mediaType', + inputValue: 'iso', + boxLabel: gettext('Use CD/DVD disc image file (iso)'), + checked: true, + listeners: { + change: function (f, value) { + if (!me.rendered) { + return; + } + var cdImageField = me.down('pveIsoSelector'); + cdImageField.setDisabled(!value); + if (value) { + cdImageField.validate(); + } else { + cdImageField.reset(); + } + }, + }, + }); + + me.isosel = Ext.create('PVE.form.IsoSelector', { + nodename: me.nodename, + insideWizard: me.insideWizard, + name: 'cdimage', + }); + + items.push(me.isosel); + + items.push({ + xtype: 'radiofield', + name: 'mediaType', + inputValue: 'cdrom', + boxLabel: gettext('Use physical CD/DVD Drive'), + }); + + items.push({ + xtype: 'radiofield', + name: 'mediaType', + inputValue: 'none', + boxLabel: gettext('Do not use any media'), + }); + + me.items = items; + + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.CDEdit', { + extend: 'Proxmox.window.Edit', + + width: 400, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.isCreate = !me.confid; + + var ipanel = Ext.create('PVE.qemu.CDInputPanel', { + confid: me.confid, + nodename: nodename, + }); + + Ext.applyIf(me, { + subject: 'CD/DVD Drive', + items: [ipanel], + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + ipanel.setVMConfig(response.result.data); + if (me.confid) { + let value = response.result.data[me.confid]; + let drive = PVE.Parser.parseQemuDrive(me.confid, value); + if (!drive) { + Ext.Msg.alert('Error', 'Unable to parse drive options'); + me.close(); + return; + } + ipanel.setDrive(drive); + } + }, + }); + }, +}); +Ext.define('PVE.qemu.CIDriveInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveCIDriveInputPanel', + + insideWizard: false, + + vmconfig: {}, // used to select usused disks + + onGetValues: function (values) { + var _me = this; + + var drive = {}; + var params = {}; + drive.file = values.hdstorage + ':cloudinit'; + drive.format = values.diskformat; + params[values.controller + values.deviceid] = PVE.Parser.printQemuDrive(drive); + return params; + }, + + setNodename: function (nodename) { + var me = this; + me.down('#hdstorage').setNodename(nodename); + me.down('#hdimage').setStorage(undefined, nodename); + }, + + setVMConfig: function (config) { + var me = this; + me.down('#drive').setVMConfig(config, 'cdrom'); + }, + + initComponent: function () { + var me = this; + + me.drive = {}; + + me.items = [ + { + xtype: 'pveControllerSelector', + withVirtIO: false, + itemId: 'drive', + fieldLabel: gettext('CloudInit Drive'), + name: 'drive', + }, + { + xtype: 'pveDiskStorageSelector', + itemId: 'storselector', + storageContent: 'images', + nodename: me.nodename, + hideSize: true, + }, + ]; + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.CIDriveEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCIDriveEdit', + + isCreate: true, + subject: gettext('CloudInit Drive'), + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.items = [ + { + xtype: 'pveCIDriveInputPanel', + itemId: 'cipanel', + nodename: nodename, + }, + ]; + + me.callParent(); + + me.load({ + success: function (response, opts) { + me.down('#cipanel').setVMConfig(response.result.data); + }, + }); + }, +}); +Ext.define('PVE.qemu.CloudInit', { + extend: 'Proxmox.grid.PendingObjectGrid', + xtype: 'pveCiPanel', + + onlineHelp: 'qm_cloud_init', + + tbar: [ + { + xtype: 'proxmoxButton', + disabled: true, + dangerous: true, + confirmMsg: function (rec) { + let view = this.up('grid'); + var warn = gettext('Are you sure you want to remove entry {0}'); + + var entry = rec.data.key; + var msg = Ext.String.format(warn, "'" + view.renderKey(entry, {}, rec) + "'"); + + return msg; + }, + enableFn: function (record) { + let view = this.up('grid'); + var caps = Ext.state.Manager.get('GuiCap'); + let caps_ci = caps.vms['VM.Config.Network'] || caps.vms['VM.Config.Cloudinit']; + if (view.rows[record.data.key].never_delete || !caps_ci) { + return false; + } + + if (record.data.key === 'cipassword' && !record.data.value) { + return false; + } + return true; + }, + handler: function () { + let view = this.up('grid'); + let records = view.getSelection(); + if (!records || !records.length) { + return; + } + + var id = records[0].data.key; + var match = id.match(/^net(\d+)$/); + if (match) { + id = 'ipconfig' + match[1]; + } + + var params = {}; + params.delete = id; + Proxmox.Utils.API2Request({ + url: view.baseurl + '/config', + waitMsgTarget: view, + method: 'PUT', + params: params, + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + callback: function () { + view.reload(); + }, + }); + }, + text: gettext('Remove'), + }, + { + xtype: 'proxmoxButton', + disabled: true, + enableFn: function (rec) { + let view = this.up('pveCiPanel'); + return !!view.rows[rec.data.key].editor; + }, + handler: function () { + let view = this.up('grid'); + view.run_editor(); + }, + text: gettext('Edit'), + }, + '-', + { + xtype: 'button', + itemId: 'savebtn', + text: gettext('Regenerate Image'), + handler: function () { + let view = this.up('grid'); + + Proxmox.Utils.API2Request({ + url: view.baseurl + '/cloudinit', + waitMsgTarget: view, + method: 'PUT', + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + callback: function () { + view.reload(); + }, + }); + }, + }, + ], + + border: false, + + set_button_status: function (rstore, records, success) { + if (!success || records.length < 1) { + return; + } + var me = this; + var found; + records.forEach(function (record) { + if (found) { + return; + } + var id = record.data.key; + var value = record.data.value; + var ciregex = new RegExp('vm-' + me.pveSelNode.data.vmid + '-cloudinit'); + if (id.match(/^(ide|scsi|sata)\d+$/) && ciregex.test(value)) { + found = id; + me.ciDriveId = found; + me.ciDrive = value; + } + }); + + let caps = Ext.state.Manager.get('GuiCap'); + let canRegenerateImage = !!caps.vms['VM.Config.Cloudinit']; + me.down('#savebtn').setDisabled(!found || !canRegenerateImage); + + me.setDisabled(!found); + if (!found) { + me.getView().mask(gettext('No CloudInit Drive found'), ['pve-static-mask']); + } else { + me.getView().unmask(); + } + }, + + renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { + var me = this; + var rows = me.rows; + var rowdef = rows[key] || {}; + + var icon = ''; + if (rowdef.iconCls) { + icon = ' '; + } + return icon + (rowdef.header || key); + }, + + listeners: { + activate: function () { + var me = this; + me.rstore.startUpdate(); + }, + itemdblclick: function () { + var me = this; + me.run_editor(); + }, + }, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + var caps = Ext.state.Manager.get('GuiCap'); + me.baseurl = '/api2/extjs/nodes/' + nodename + '/qemu/' + vmid; + me.url = me.baseurl + '/pending'; + me.editorConfig.url = me.baseurl + '/config'; + me.editorConfig.pveSelNode = me.pveSelNode; + + let caps_ci = caps.vms['VM.Config.Cloudinit'] || caps.vms['VM.Config.Network']; + /* editor is string and object */ + me.rows = { + ciuser: { + header: gettext('User'), + iconCls: 'fa fa-user', + never_delete: true, + defaultValue: '', + editor: caps_ci + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('User'), + items: [ + { + xtype: 'proxmoxtextfield', + deleteEmpty: true, + emptyText: Proxmox.Utils.defaultText, + fieldLabel: gettext('User'), + name: 'ciuser', + }, + ], + } + : undefined, + renderer: function (value) { + return Ext.String.htmlEncode(value || Proxmox.Utils.defaultText); + }, + }, + cipassword: { + header: gettext('Password'), + iconCls: 'fa fa-unlock', + defaultValue: '', + editor: caps_ci + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Password'), + items: [ + { + xtype: 'proxmoxtextfield', + inputType: 'password', + deleteEmpty: true, + emptyText: Proxmox.Utils.noneText, + fieldLabel: gettext('Password'), + name: 'cipassword', + }, + ], + } + : undefined, + renderer: function (value) { + return Ext.String.htmlEncode(value || Proxmox.Utils.noneText); + }, + }, + searchdomain: { + header: gettext('DNS domain'), + iconCls: 'fa fa-globe', + editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined, + never_delete: true, + defaultValue: gettext('use host settings'), + }, + nameserver: { + header: gettext('DNS servers'), + iconCls: 'fa fa-globe', + editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined, + never_delete: true, + defaultValue: gettext('use host settings'), + }, + sshkeys: { + header: gettext('SSH public key'), + iconCls: 'fa fa-key', + editor: caps_ci ? 'PVE.qemu.SSHKeyEdit' : undefined, + never_delete: true, + renderer: function (value) { + value = decodeURIComponent(value); + var keys = value.split('\n'); + var text = []; + keys.forEach(function (key) { + if (key.length) { + let res = PVE.Parser.parseSSHKey(key); + if (res) { + key = Ext.String.htmlEncode(res.comment); + if (res.options) { + key += + ' (' + + gettext('with options') + + ')'; + } + text.push(key); + return; + } + // Most likely invalid at this point, so just stick to + // the old value. + text.push(Ext.String.htmlEncode(key)); + } + }); + if (text.length) { + return text.join('
    '); + } else { + return Proxmox.Utils.noneText; + } + }, + defaultValue: '', + }, + ciupgrade: { + header: gettext('Upgrade packages'), + iconCls: 'fa fa-archive', + renderer: Proxmox.Utils.format_boolean, + defaultValue: 1, + editor: { + xtype: 'proxmoxWindowEdit', + subject: gettext('Upgrade packages on boot'), + items: { + xtype: 'proxmoxcheckbox', + name: 'ciupgrade', + uncheckedValue: 0, + value: 1, // serves as default value, using defaultValue is not enough + fieldLabel: gettext('Upgrade packages'), + labelWidth: 140, + }, + }, + }, + }; + var i; + var ipconfig_renderer = function (value, md, record, ri, ci, store, pending) { + var id = record.data.key; + var match = id.match(/^net(\d+)$/); + var val = ''; + if (match) { + val = me.getObjectValue('ipconfig' + match[1], '', pending); + } + return val; + }; + for (i = 0; i < 32; i++) { + // we want to show an entry for every network device + // even if it is empty + me.rows['net' + i.toString()] = { + multiKey: ['ipconfig' + i.toString(), 'net' + i.toString()], + header: gettext('IP Config') + ' (net' + i.toString() + ')', + editor: caps_ci ? 'PVE.qemu.IPConfigEdit' : undefined, + iconCls: 'fa fa-exchange', + renderer: ipconfig_renderer, + }; + me.rows['ipconfig' + i.toString()] = { + visible: false, + }; + } + + PVE.Utils.forEachBus(['ide', 'scsi', 'sata'], function (type, id) { + me.rows[type + id] = { + visible: false, + }; + }); + me.callParent(); + me.mon(me.rstore, 'load', me.set_button_status, me); + }, +}); +Ext.define('PVE.qemu.CmdMenu', { + extend: 'Ext.menu.Menu', + + showSeparator: false, + initComponent: function () { + let me = this; + + let info = me.pveSelNode.data; + if (!info.node) { + throw 'no node name specified'; + } + if (!info.vmid) { + throw 'no VM ID specified'; + } + + let vm_command = function (cmd, params) { + Proxmox.Utils.API2Request({ + params: params, + url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`, + method: 'POST', + failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }; + let confirmedVMCommand = (cmd, params, confirmTask) => { + let task = confirmTask || `qm${cmd}`; + let msg = PVE.Utils.formatGuestTaskConfirmation(task, info.vmid, info.name); + Ext.Msg.confirm(gettext('Confirm'), msg, (btn) => { + if (btn === 'yes') { + vm_command(cmd, params); + } + }); + }; + + let caps = Ext.state.Manager.get('GuiCap'); + let standalone = PVE.Utils.isStandaloneNode(); + + let running = false, + stopped = true, + suspended = false; + switch (info.status) { + case 'running': + running = true; + stopped = false; + break; + case 'suspended': + stopped = false; + suspended = true; + break; + case 'paused': + stopped = false; + suspended = true; + break; + default: + break; + } + + me.title = 'VM ' + info.vmid; + + me.items = [ + { + text: gettext('Start'), + iconCls: 'fa fa-fw fa-play', + hidden: running || suspended, + disabled: running || suspended, + handler: () => vm_command('start'), + }, + { + text: gettext('Pause'), + iconCls: 'fa fa-fw fa-pause', + hidden: stopped || suspended, + disabled: stopped || suspended, + handler: () => confirmedVMCommand('suspend', undefined, 'qmpause'), + }, + { + text: gettext('Hibernate'), + iconCls: 'fa fa-fw fa-download', + hidden: stopped || suspended, + disabled: stopped || suspended, + tooltip: gettext('Suspend to disk'), + handler: () => confirmedVMCommand('suspend', { todisk: 1 }), + }, + { + text: gettext('Resume'), + iconCls: 'fa fa-fw fa-play', + hidden: !suspended, + handler: () => vm_command('resume'), + }, + { + text: gettext('Shutdown'), + iconCls: 'fa fa-fw fa-power-off', + disabled: stopped || suspended, + handler: () => confirmedVMCommand('shutdown'), + }, + { + text: gettext('Stop'), + iconCls: 'fa fa-fw fa-stop', + disabled: stopped, + tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'), + handler: () => { + Ext.create('PVE.GuestStop', { + nodename: info.node, + vm: info, + autoShow: true, + }); + }, + }, + { + text: gettext('Reboot'), + iconCls: 'fa fa-fw fa-refresh', + disabled: stopped, + tooltip: Ext.String.format(gettext('Reboot {0}'), 'VM'), + handler: () => confirmedVMCommand('reboot'), + }, + { + text: gettext('Reset'), + iconCls: 'fa fa-fw fa-bolt', + disabled: stopped, + tooltip: Ext.String.format(gettext('Reset {0}'), 'VM'), + handler: () => confirmedVMCommand('reset'), + }, + { + xtype: 'menuseparator', + hidden: + (standalone || !caps.vms['VM.Migrate']) && + !caps.vms['VM.Allocate'] && + !caps.vms['VM.Clone'], + }, + { + text: gettext('Migrate'), + iconCls: 'fa fa-fw fa-send-o', + hidden: standalone || !caps.vms['VM.Migrate'], + handler: function () { + Ext.create('PVE.window.Migrate', { + vmtype: 'qemu', + nodename: info.node, + vmid: info.vmid, + vmname: info.name, + autoShow: true, + }); + }, + }, + { + text: gettext('Clone'), + iconCls: 'fa fa-fw fa-clone', + hidden: !caps.vms['VM.Clone'], + handler: () => + PVE.window.Clone.wrap(info.node, info.vmid, info.name, me.isTemplate, 'qemu'), + }, + { + text: gettext('Convert to template'), + iconCls: 'fa fa-fw fa-file-o', + hidden: !caps.vms['VM.Allocate'], + handler: function () { + let msg = PVE.Utils.formatGuestTaskConfirmation( + 'qmtemplate', + info.vmid, + info.name, + ); + Ext.Msg.confirm(gettext('Confirm'), msg, (btn) => { + if (btn === 'yes') { + Proxmox.Utils.API2Request({ + url: `/nodes/${info.node}/qemu/${info.vmid}/template`, + method: 'POST', + failure: (response, opts) => + Ext.Msg.alert('Error', response.htmlStatus), + }); + } + }); + }, + }, + { xtype: 'menuseparator' }, + { + text: gettext('Take Snapshot'), + iconCls: 'fa fa-fw fa-history', + itemId: 'takeSnapshotBtn', + disabled: true, + handler: () => { + Ext.create('PVE.window.Snapshot', { + nodename: info.node, + vmid: info.vmid, + vmname: info.name, + viewonly: false, + type: info.type, + isCreate: true, + submitText: gettext('Take Snapshot'), + autoShow: true, + running: running, + }); + }, + }, + { + text: gettext('Backup now'), + iconCls: 'fa fa-fw fa-floppy-o', + disabled: !caps.vms['VM.Backup'], + handler: () => { + Ext.create('PVE.window.Backup', { + nodename: info.node, + vmid: info.vmid, + vmtype: info.type, + vmname: info.name, + autoShow: true, + }); + }, + }, + { xtype: 'menuseparator' }, + { + text: gettext('Console'), + iconCls: 'fa fa-fw fa-terminal', + handler: function () { + Proxmox.Utils.API2Request({ + url: `/nodes/${info.node}/qemu/${info.vmid}/status/current`, + failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus), + success: function ({ result: { data } }, opts) { + PVE.Utils.openDefaultConsoleWindow( + { + spice: data.spice, + xtermjs: data.serial, + }, + 'kvm', + info.vmid, + info.node, + info.name, + ); + }, + }); + }, + }, + ]; + + me.callParent(); + + if (caps.vms['VM.Snapshot']) { + Proxmox.Utils.API2Request({ + url: `/nodes/${info.node}/${info.type}/${info.vmid}/feature`, + params: { feature: 'snapshot' }, + method: 'GET', + success: (response) => { + let hasFeature = response.result.data.hasFeature; + let btn = me.down('#takeSnapshotBtn'); + if (btn) { + btn.setDisabled(!hasFeature); + } + }, + }); + } + }, +}); +Ext.define('PVE.qemu.Config', { + extend: 'PVE.panel.Config', + alias: 'widget.PVE.qemu.Config', + + onlineHelp: 'chapter_virtual_machines', + userCls: 'proxmox-tags-full', + + initComponent: function () { + var me = this; + var vm = me.pveSelNode.data; + + var nodename = vm.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = vm.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var template = !!vm.template; + + var running = !!vm.uptime; + + var caps = Ext.state.Manager.get('GuiCap'); + + var base_url = '/nodes/' + nodename + '/qemu/' + vmid; + + me.statusStore = Ext.create('Proxmox.data.ObjectStore', { + url: '/api2/json' + base_url + '/status/current', + interval: 1000, + }); + + var vm_command = function (cmd, params) { + Proxmox.Utils.API2Request({ + params: params, + url: base_url + '/status/' + cmd, + waitMsgTarget: me, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + }); + }; + + var resumeBtn = Ext.create('Ext.Button', { + text: gettext('Resume'), + disabled: !caps.vms['VM.PowerMgmt'], + hidden: true, + handler: function () { + vm_command('resume'); + }, + iconCls: 'fa fa-play', + }); + + var startBtn = Ext.create('Ext.Button', { + text: gettext('Start'), + disabled: !caps.vms['VM.PowerMgmt'] || running, + hidden: template, + handler: function () { + vm_command('start'); + }, + iconCls: 'fa fa-play', + }); + + var migrateBtn = Ext.create('Ext.Button', { + text: gettext('Migrate'), + disabled: !caps.vms['VM.Migrate'], + hidden: PVE.Utils.isStandaloneNode(), + handler: function () { + var win = Ext.create('PVE.window.Migrate', { + vmtype: 'qemu', + nodename: nodename, + vmid: vmid, + vmname: vm.name, + }); + win.show(); + }, + iconCls: 'fa fa-send-o', + }); + + var moreBtn = Ext.create('Proxmox.button.Button', { + text: gettext('More'), + menu: { + items: [ + { + text: gettext('Clone'), + iconCls: 'fa fa-fw fa-clone', + hidden: !caps.vms['VM.Clone'], + handler: function () { + PVE.window.Clone.wrap(nodename, vmid, vm.name, template, 'qemu'); + }, + }, + { + text: gettext('Convert to template'), + disabled: template, + xtype: 'pveMenuItem', + iconCls: 'fa fa-fw fa-file-o', + hidden: !caps.vms['VM.Allocate'], + confirmMsg: PVE.Utils.formatGuestTaskConfirmation( + 'qmtemplate', + vmid, + vm.name, + ), + handler: function () { + Proxmox.Utils.API2Request({ + url: base_url + '/template', + waitMsgTarget: me, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + }); + }, + }, + { + iconCls: 'fa fa-heartbeat ', + hidden: !caps.nodes['Sys.Console'], + text: gettext('Manage HA'), + handler: function () { + var ha = vm.hastate; + Ext.create('PVE.ha.VMResourceEdit', { + vmid: vmid, + isCreate: !ha || ha === 'unmanaged', + }).show(); + }, + }, + { + text: gettext('Remove'), + itemId: 'removeBtn', + disabled: !caps.vms['VM.Allocate'], + handler: function () { + Ext.create('PVE.window.SafeDestroyGuest', { + url: base_url, + item: { + type: 'VM', + id: vmid, + formattedIdentifier: PVE.Utils.getFormattedGuestIdentifier( + vmid, + vm.name, + ), + }, + taskName: 'qmdestroy', + }).show(); + }, + iconCls: 'fa fa-trash-o', + }, + ], + }, + }); + + var shutdownBtn = Ext.create('PVE.button.Split', { + text: gettext('Shutdown'), + disabled: !caps.vms['VM.PowerMgmt'] || !running, + hidden: template, + confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmshutdown', vmid, vm.name), + handler: function () { + vm_command('shutdown'); + }, + menu: { + items: [ + { + text: gettext('Reboot'), + disabled: !caps.vms['VM.PowerMgmt'], + tooltip: Ext.String.format( + gettext('Shutdown, apply pending changes and reboot {0}'), + 'VM', + ), + confirmMsg: PVE.Utils.formatGuestTaskConfirmation( + 'qmreboot', + vmid, + vm.name, + ), + handler: function () { + vm_command('reboot'); + }, + iconCls: 'fa fa-refresh', + }, + { + text: gettext('Pause'), + disabled: !caps.vms['VM.PowerMgmt'], + confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmpause', vmid, vm.name), + handler: function () { + vm_command('suspend'); + }, + iconCls: 'fa fa-pause', + }, + { + text: gettext('Hibernate'), + disabled: !caps.vms['VM.PowerMgmt'], + confirmMsg: PVE.Utils.formatGuestTaskConfirmation( + 'qmsuspend', + vmid, + vm.name, + ), + tooltip: gettext('Suspend to disk'), + handler: function () { + vm_command('suspend', { todisk: 1 }); + }, + iconCls: 'fa fa-download', + }, + { + text: gettext('Stop'), + disabled: !caps.vms['VM.PowerMgmt'], + tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'), + handler: function () { + Ext.create('PVE.GuestStop', { + nodename: nodename, + vm: vm, + autoShow: true, + }); + }, + iconCls: 'fa fa-stop', + }, + { + text: gettext('Reset'), + disabled: !caps.vms['VM.PowerMgmt'], + tooltip: Ext.String.format(gettext('Reset {0} immediately'), 'VM'), + confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmreset', vmid, vm.name), + handler: function () { + vm_command('reset'); + }, + iconCls: 'fa fa-bolt', + }, + ], + }, + iconCls: 'fa fa-power-off', + }); + + var consoleBtn = Ext.create('PVE.button.ConsoleButton', { + disabled: !caps.vms['VM.Console'], + hidden: template, + consoleType: 'kvm', + // disable spice/xterm for default action until status api call succeeded + enableSpice: false, + enableXtermjs: false, + consoleName: vm.name, + nodename: nodename, + vmid: vmid, + }); + + var statusTxt = Ext.create('Ext.toolbar.TextItem', { + data: { + lock: undefined, + }, + tpl: ['', ' ({lock})', ''], + }); + + let tagsContainer = Ext.create('PVE.panel.TagEditContainer', { + tags: vm.tags, + canEdit: !!caps.vms['VM.Config.Options'], + listeners: { + change: function (tags) { + Proxmox.Utils.API2Request({ + url: base_url + '/config', + method: 'PUT', + params: { + tags, + }, + success: function () { + me.statusStore.load(); + }, + failure: function (response) { + Ext.Msg.alert('Error', response.htmlStatus); + me.statusStore.load(); + }, + }); + }, + }, + }); + + let vm_text = `${vm.vmid} (${vm.name})`; + + Ext.apply(me, { + title: Ext.String.format( + gettext("Virtual Machine {0} on node '{1}'"), + vm_text, + nodename, + ), + hstateid: 'kvmtab', + tbarSpacing: false, + tbar: [ + statusTxt, + tagsContainer, + '->', + resumeBtn, + startBtn, + shutdownBtn, + migrateBtn, + consoleBtn, + moreBtn, + ], + defaults: { statusStore: me.statusStore }, + items: [ + { + title: gettext('Summary'), + xtype: 'pveGuestSummary', + iconCls: 'fa fa-book', + itemId: 'summary', + }, + ], + }); + + if (caps.vms['VM.Console'] && !template) { + me.items.push({ + title: gettext('Console'), + itemId: 'console', + iconCls: 'fa fa-terminal', + xtype: 'pveNoVncConsole', + vmid: vmid, + consoleType: 'kvm', + nodename: nodename, + }); + } + + me.items.push( + { + title: gettext('Hardware'), + itemId: 'hardware', + iconCls: 'fa fa-desktop', + xtype: 'PVE.qemu.HardwareView', + }, + { + title: 'Cloud-Init', + itemId: 'cloudinit', + iconCls: 'fa fa-cloud', + xtype: 'pveCiPanel', + }, + { + title: gettext('Options'), + iconCls: 'fa fa-gear', + itemId: 'options', + xtype: 'PVE.qemu.Options', + }, + { + title: gettext('Task History'), + itemId: 'tasks', + xtype: 'proxmoxNodeTasks', + iconCls: 'fa fa-list-alt', + nodename: nodename, + preFilter: { + vmid, + }, + }, + ); + + if (caps.nodes['Sys.Audit'] && !template) { + me.items.push({ + title: gettext('Monitor'), + iconCls: 'fa fa-eye', + itemId: 'monitor', + xtype: 'pveQemuMonitor', + }); + } + + if (caps.vms['VM.Backup']) { + me.items.push( + { + title: gettext('Backup'), + iconCls: 'fa fa-floppy-o', + xtype: 'pveBackupView', + itemId: 'backup', + }, + { + title: gettext('Replication'), + iconCls: 'fa fa-retweet', + xtype: 'pveReplicaView', + itemId: 'replication', + }, + ); + } + + if ( + (caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] || caps.vms['VM.Audit']) && + !template + ) { + me.items.push({ + title: gettext('Snapshots'), + iconCls: 'fa fa-history', + type: 'qemu', + xtype: 'pveGuestSnapshotTree', + itemId: 'snapshot', + }); + } + + if (caps.vms['VM.Audit']) { + me.items.push( + { + xtype: 'pveFirewallRules', + title: gettext('Firewall'), + iconCls: 'fa fa-shield', + allow_iface: true, + base_url: base_url + '/firewall/rules', + list_refs_url: base_url + '/firewall/refs', + itemId: 'firewall', + firewall_type: 'vm', + }, + { + xtype: 'pveFirewallOptions', + groups: ['firewall'], + iconCls: 'fa fa-gear', + onlineHelp: 'pve_firewall_vm_container_configuration', + title: gettext('Options'), + base_url: base_url + '/firewall/options', + fwtype: 'vm', + itemId: 'firewall-options', + }, + { + xtype: 'pveFirewallAliases', + title: gettext('Alias'), + groups: ['firewall'], + iconCls: 'fa fa-external-link', + base_url: base_url + '/firewall/aliases', + itemId: 'firewall-aliases', + }, + { + xtype: 'pveIPSet', + title: gettext('IPSet'), + groups: ['firewall'], + iconCls: 'fa fa-list-ol', + base_url: base_url + '/firewall/ipset', + list_refs_url: base_url + '/firewall/refs', + itemId: 'firewall-ipset', + }, + ); + } + + if (caps.vms['VM.Console']) { + me.items.push({ + title: gettext('Log'), + groups: ['firewall'], + iconCls: 'fa fa-list', + onlineHelp: 'chapter_pve_firewall', + itemId: 'firewall-fwlog', + xtype: 'proxmoxLogView', + url: '/api2/extjs' + base_url + '/firewall/log', + log_select_timespan: true, + submitFormat: 'U', + }); + } + + if (caps.vms['Permissions.Modify']) { + me.items.push({ + xtype: 'pveACLView', + title: gettext('Permissions'), + iconCls: 'fa fa-unlock', + itemId: 'permissions', + path: '/vms/' + vmid, + }); + } + + me.callParent(); + + var prevQMPStatus = 'unknown'; + me.mon(me.statusStore, 'load', function (s, records, success) { + var status; + var qmpstatus; + var spice = false; + var xtermjs = false; + var lock; + var rec; + + if (!success) { + status = qmpstatus = 'unknown'; + } else { + rec = s.data.get('status'); + status = rec ? rec.data.value : 'unknown'; + rec = s.data.get('qmpstatus'); + qmpstatus = rec ? rec.data.value : 'unknown'; + rec = s.data.get('template'); + template = rec ? rec.data.value : false; + rec = s.data.get('lock'); + lock = rec ? rec.data.value : undefined; + + spice = !!s.data.get('spice'); + xtermjs = !!s.data.get('serial'); + } + + rec = s.data.get('tags'); + tagsContainer.loadTags(rec?.data?.value); + + if (template) { + return; + } + + var resume = ['prelaunch', 'paused', 'suspended'].indexOf(qmpstatus) !== -1; + + if (resume || lock === 'suspended') { + startBtn.setVisible(false); + resumeBtn.setVisible(true); + } else { + startBtn.setVisible(true); + resumeBtn.setVisible(false); + } + + consoleBtn.setEnableSpice(spice); + consoleBtn.setEnableXtermJS(xtermjs); + + statusTxt.update({ lock: lock }); + + let guest_running = + status === 'running' && !(qmpstatus === 'shutdown' || qmpstatus === 'prelaunch'); + startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || template || guest_running); + + shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running'); + me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped'); + consoleBtn.setDisabled(template); + + let wasStopped = ['prelaunch', 'stopped', 'suspended'].indexOf(prevQMPStatus) !== -1; + if (wasStopped && qmpstatus === 'running') { + let con = me.down('#console'); + if (con) { + con.reload(); + } + } + + prevQMPStatus = qmpstatus; + }); + + me.on('afterrender', function () { + me.statusStore.startUpdate(); + }); + + me.on('destroy', function () { + me.statusStore.stopUpdate(); + }); + }, +}); +Ext.define('PVE.qemu.CreateWizard', { + extend: 'PVE.window.Wizard', + alias: 'widget.pveQemuCreateWizard', + mixins: ['Proxmox.Mixin.CBind'], + + viewModel: { + data: { + nodename: '', + current: { + scsihw: '', + }, + }, + formulas: { + cgroupMode: function (get) { + const nodeInfo = PVE.data.ResourceStore.getNodes().find( + (node) => node.node === get('nodename'), + ); + return nodeInfo ? nodeInfo['cgroup-mode'] : 2; + }, + }, + }, + + cbindData: { + nodename: undefined, + }, + + subject: gettext('Virtual Machine'), + + // fot the special case that we have 2 cdrom drives + // + // emulates part of the backend bootorder logic, but includes all cdrom drives since the backend + // cannot know which one is a bootable iso and hardcodes the known values (ide0/2, net0) + calculateBootOrder: function (values) { + // user selected windows + second cdrom + if (values.ide0 && values.ide0.match(/media=cdrom/)) { + let disk; + PVE.Utils.forEachBus(['ide', 'scsi', 'virtio', 'sata'], (type, id) => { + let confId = type + id; + if (!values[confId]) { + return undefined; + } + if (values[confId].match(/media=cdrom/)) { + return undefined; + } + disk = confId; + return false; // abort loop + }); + + let order = []; + if (disk) { + order.push(disk); + } + order.push('ide2', 'ide0'); // ide2 is the install ISO and should be first + if (values.net0) { + order.push('net0'); + } + + return `order=${order.join(';')}`; + } + return undefined; + }, + + items: [ + { + xtype: 'inputpanel', + title: gettext('General'), + onlineHelp: 'qm_general_settings', + column1: [ + { + xtype: 'pveNodeSelector', + name: 'nodename', + cbind: { + selectCurNode: '{!nodename}', + preferredValue: '{nodename}', + }, + bind: { + value: '{nodename}', + }, + fieldLabel: gettext('Node'), + allowBlank: false, + onlineValidator: true, + }, + { + xtype: 'pveGuestIDSelector', + name: 'vmid', + guestType: 'qemu', + value: '', + loadNextFreeID: true, + validateExists: false, + }, + { + xtype: 'textfield', + name: 'name', + vtype: 'DnsName', + value: '', + fieldLabel: gettext('Name'), + allowBlank: true, + }, + { + xtype: 'proxmoxcheckbox', + name: 'ha-managed', + // only submit value of checkbox if checked + uncheckedValue: undefined, + fieldLabel: gettext('Add to HA'), + }, + ], + column2: [ + { + xtype: 'pvePoolSelector', + fieldLabel: gettext('Resource Pool'), + name: 'pool', + value: '', + allowBlank: true, + }, + ], + advancedColumn1: [ + { + xtype: 'proxmoxcheckbox', + name: 'onboot', + uncheckedValue: 0, + defaultValue: 0, + deleteDefaultValue: true, + fieldLabel: gettext('Start at boot'), + }, + ], + advancedColumn2: [ + { + xtype: 'textfield', + name: 'order', + defaultValue: '', + emptyText: 'any', + labelWidth: 120, + fieldLabel: gettext('Start/Shutdown order'), + }, + { + xtype: 'textfield', + name: 'up', + defaultValue: '', + emptyText: 'default', + labelWidth: 120, + fieldLabel: gettext('Startup delay'), + }, + { + xtype: 'textfield', + name: 'down', + defaultValue: '', + emptyText: 'default', + labelWidth: 120, + fieldLabel: gettext('Shutdown timeout'), + }, + ], + + advancedColumnB: [ + { + xtype: 'pveTagFieldSet', + name: 'tags', + maxHeight: 150, + }, + ], + + onGetValues: function (values) { + ['name', 'pool', 'onboot', 'agent'].forEach(function (field) { + if (!values[field]) { + delete values[field]; + } + }); + + var res = PVE.Parser.printStartup({ + order: values.order, + up: values.up, + down: values.down, + }); + + if (res) { + values.startup = res; + } + + delete values.order; + delete values.up; + delete values.down; + + return values; + }, + }, + { + xtype: 'container', + layout: 'hbox', + defaults: { + flex: 1, + padding: '0 10', + }, + title: gettext('OS'), + items: [ + { + xtype: 'pveQemuCDInputPanel', + bind: { + nodename: '{nodename}', + }, + confid: 'ide2', + insideWizard: true, + }, + { + xtype: 'pveQemuOSTypePanel', + insideWizard: true, + bind: { + nodename: '{nodename}', + }, + }, + ], + }, + { + xtype: 'pveQemuSystemPanel', + title: gettext('System'), + isCreate: true, + insideWizard: true, + }, + { + xtype: 'pveMultiHDPanel', + bind: { + nodename: '{nodename}', + }, + title: gettext('Disks'), + }, + { + xtype: 'pveQemuProcessorPanel', + insideWizard: true, + title: gettext('CPU'), + }, + { + xtype: 'pveQemuMemoryPanel', + insideWizard: true, + title: gettext('Memory'), + }, + { + xtype: 'pveQemuNetworkInputPanel', + bind: { + nodename: '{nodename}', + }, + title: gettext('Network'), + insideWizard: true, + }, + { + title: gettext('Confirm'), + layout: 'fit', + items: [ + { + xtype: 'grid', + store: { + model: 'KeyValue', + sorters: [ + { + property: 'key', + direction: 'ASC', + }, + ], + }, + columns: [ + { header: 'Key', width: 150, dataIndex: 'key' }, + { header: 'Value', flex: 1, dataIndex: 'value', renderer: Ext.htmlEncode }, + ], + }, + ], + dockedItems: [ + { + xtype: 'proxmoxcheckbox', + name: 'start', + dock: 'bottom', + margin: '5 0 0 0', + boxLabel: gettext('Start after created'), + }, + ], + listeners: { + show: function (panel) { + let wizard = this.up('window'); + var kv = wizard.getValues(); + var data = []; + + let boot = wizard.calculateBootOrder(kv); + if (boot) { + kv.boot = boot; + } + + Ext.Object.each(kv, function (key, value) { + if (key === 'delete') { + // ignore + return; + } + data.push({ key: key, value: value }); + }); + + var summarystore = panel.down('grid').getStore(); + summarystore.suspendEvents(); + summarystore.removeAll(); + summarystore.add(data); + summarystore.sort(); + summarystore.resumeEvents(); + summarystore.fireEvent('refresh'); + }, + }, + onSubmit: function () { + var wizard = this.up('window'); + var kv = wizard.getValues(); + delete kv.delete; + + var nodename = kv.nodename; + delete kv.nodename; + + let boot = wizard.calculateBootOrder(kv); + if (boot) { + kv.boot = boot; + } + + Proxmox.Utils.API2Request({ + url: '/nodes/' + nodename + '/qemu', + waitMsgTarget: wizard, + method: 'POST', + params: kv, + success: function (response) { + wizard.close(); + }, + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }, + ], +}); +Ext.define('PVE.qemu.DisplayInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveDisplayInputPanel', + onlineHelp: 'qm_display', + + onGetValues: function (values) { + let ret = PVE.Parser.printPropertyString(values, 'type'); + if (ret === '') { + return { delete: 'vga' }; + } + return { vga: ret }; + }, + + viewModel: { + data: { + type: '__default__', + clipboard: '__default__', + }, + formulas: { + matchNonGUIOption: function (get) { + return get('type').match(/^(serial\d|none)$/); + }, + memoryEmptyText: function (get) { + let val = get('type'); + if (val === 'cirrus') { + return '4'; + } else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') { + return '16'; + } else if (val.match(/^virtio/)) { + return '256'; + } else if (get('matchNonGUIOption')) { + return 'N/A'; + } else { + console.debug('unexpected display type', val); + return Proxmox.Utils.defaultText; + } + }, + isVNC: (get) => get('clipboard') === 'vnc', + hideDefaultHint: (get) => get('isVNC') || get('matchNonGUIOption'), + hideVNCHint: (get) => !get('isVNC') || get('matchNonGUIOption'), + }, + }, + + items: [ + { + name: 'type', + xtype: 'proxmoxKVComboBox', + value: '__default__', + deleteEmpty: false, + fieldLabel: gettext('Graphic card'), + comboItems: Object.entries(PVE.Utils.kvm_vga_drivers), + validator: function (v) { + let cfg = this.up('proxmoxWindowEdit').vmconfig || {}; + + if (v.match(/^serial\d+$/) && (!cfg[v] || cfg[v] !== 'socket')) { + let fmt = gettext("Serial interface '{0}' is not correctly configured."); + return Ext.String.format(fmt, v); + } + return true; + }, + bind: { + value: '{type}', + }, + }, + { + xtype: 'proxmoxintegerfield', + emptyText: Proxmox.Utils.defaultText, + fieldLabel: gettext('Memory') + ' (MiB)', + minValue: 4, + maxValue: 512, + step: 4, + name: 'memory', + bind: { + emptyText: '{memoryEmptyText}', + disabled: '{matchNonGUIOption}', + }, + }, + ], + + advancedItems: [ + { + xtype: 'proxmoxKVComboBox', + name: 'clipboard', + deleteEmpty: false, + value: '__default__', + fieldLabel: gettext('Clipboard'), + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['vnc', 'VNC'], + ], + bind: { + value: '{clipboard}', + disabled: '{matchNonGUIOption}', + }, + }, + { + xtype: 'displayfield', + name: 'vncHint', + userCls: 'pmx-hint', + value: + gettext( + 'You cannot use the default SPICE clipboard if the VNC clipboard is selected.', + ) + + ' ' + + gettext('VNC clipboard requires spice-tools installed in the Guest-VM.'), + bind: { + hidden: '{hideVNCHint}', + }, + }, + { + xtype: 'displayfield', + name: 'vncMigration', + userCls: 'pmx-hint', + value: gettext('You cannot live-migrate while using the VNC clipboard.'), + bind: { + hidden: '{hideVNCHint}', + }, + }, + { + xtype: 'displayfield', + name: 'defaultHint', + userCls: 'pmx-hint', + value: + gettext('This option depends on your display type.') + + ' ' + + gettext( + 'If the display type uses SPICE you are able to use the default SPICE clipboard.', + ), + bind: { + hidden: '{hideDefaultHint}', + }, + }, + ], +}); + +Ext.define('PVE.qemu.DisplayEdit', { + extend: 'Proxmox.window.Edit', + + vmconfig: undefined, + + subject: gettext('Display'), + width: 350, + + items: [ + { + xtype: 'pveDisplayInputPanel', + }, + ], + + initComponent: function () { + let me = this; + + me.callParent(); + + me.load({ + success: function (response) { + me.vmconfig = response.result.data; + let vga = me.vmconfig.vga || '__default__'; + me.setValues(PVE.Parser.parsePropertyString(vga, 'type')); + }, + }); + }, +}); +/* 'change' property is assigned a string and then a function */ +Ext.define('PVE.qemu.HDInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuHDInputPanel', + onlineHelp: 'qm_hard_disk', + + insideWizard: false, + + unused: false, // ADD usused disk imaged + + importDisk: false, // use import options + importSelection: undefined, // preselect a disk to import + + vmconfig: {}, // used to select usused disks + + viewModel: { + data: { + isSCSI: false, + isVirtIO: false, + isSCSISingle: false, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + onControllerChange: function (field) { + let me = this; + let vm = this.getViewModel(); + + let value = field.getValue(); + vm.set('isSCSI', value.match(/^scsi/)); + vm.set('isVirtIO', value.match(/^virtio/)); + + me.fireIdChange(); + }, + + fireIdChange: function () { + let view = this.getView(); + view.fireEvent('diskidchange', view, view.bussel.getConfId()); + }, + + control: { + 'field[name=controller]': { + change: 'onControllerChange', + afterrender: 'onControllerChange', + }, + 'field[name=deviceid]': { + change: 'fireIdChange', + }, + 'field[name=scsiController]': { + change: function (f, value) { + let vm = this.getViewModel(); + vm.set('isSCSISingle', value === 'virtio-scsi-single'); + }, + }, + }, + + init: function (view) { + var vm = this.getViewModel(); + if (view.isCreate) { + vm.set('isIncludedInBackup', true); + } + if (view.confid) { + vm.set('isSCSI', view.confid.match(/^scsi/)); + vm.set('isVirtIO', view.confid.match(/^virtio/)); + } + }, + }, + + onGetValues: function (values) { + var me = this; + + var params = {}; + var confid = me.confid || values.controller + values.deviceid; + + if (me.unused) { + me.drive.file = me.vmconfig[values.unusedId]; + confid = values.controller + values.deviceid; + } else if (me.isCreate) { + if (values.hdimage) { + me.drive.file = values.hdimage; + } else { + let disksize = values['import-from'] ? 0 : values.disksize; + me.drive.file = `${values.hdstorage}:${disksize}`; + PVE.Utils.propertyStringSet(me.drive, values['import-from'], 'import-from'); + } + me.drive.format = values.diskformat; + } + + PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0'); + PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no'); + PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on'); + PVE.Utils.propertyStringSet(me.drive, values.ssd, 'ssd', 'on'); + PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on'); + PVE.Utils.propertyStringSet(me.drive, values.readOnly, 'ro', 'on'); + PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache'); + PVE.Utils.propertyStringSet(me.drive, values.aio, 'aio'); + + ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'].forEach((name) => { + let burst_name = `${name}_max`; + PVE.Utils.propertyStringSet(me.drive, values[name], name); + PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name); + }); + + params[confid] = PVE.Parser.printQemuDrive(me.drive); + + return params; + }, + + updateVMConfig: function (vmconfig) { + var me = this; + me.vmconfig = vmconfig; + me.bussel?.updateVMConfig(vmconfig); + }, + + setVMConfig: function (vmconfig) { + var me = this; + + me.vmconfig = vmconfig; + + if (me.bussel) { + me.bussel.setVMConfig(vmconfig); + me.scsiController.setValue(vmconfig.scsihw); + } + if (me.unusedDisks) { + let disklist = []; + Ext.Object.each(vmconfig, function (key, value) { + if (key.match(/^unused\d+$/)) { + disklist.push([key, value]); + } + }); + me.unusedDisks.store.loadData(disklist); + me.unusedDisks.setValue(me.confid); + } + }, + + setDrive: function (drive) { + var me = this; + + me.drive = drive; + + var values = {}; + var match = drive.file.match(/^([^:]+):/); + if (match) { + values.hdstorage = match[1]; + } + + values.hdimage = drive.file; + values.backup = PVE.Parser.parseBoolean(drive.backup, 1); + values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1); + values.diskformat = drive.format || 'raw'; + values.cache = drive.cache || '__default__'; + values.discard = drive.discard === 'on'; + values.ssd = PVE.Parser.parseBoolean(drive.ssd); + values.iothread = PVE.Parser.parseBoolean(drive.iothread); + values.readOnly = PVE.Parser.parseBoolean(drive.ro); + values.aio = drive.aio || '__default__'; + + values.mbps_rd = drive.mbps_rd; + values.mbps_wr = drive.mbps_wr; + values.iops_rd = drive.iops_rd; + values.iops_wr = drive.iops_wr; + values.mbps_rd_max = drive.mbps_rd_max; + values.mbps_wr_max = drive.mbps_wr_max; + values.iops_rd_max = drive.iops_rd_max; + values.iops_wr_max = drive.iops_wr_max; + + me.setValues(values); + }, + + setNodename: function (nodename) { + var me = this; + me.down('#hdstorage').setNodename(nodename); + me.down('#hdimage').setStorage(undefined, nodename); + + me.lookup('new-disk')?.setNodename(nodename); + me.lookup('import-source')?.setNodename(nodename); + me.lookup('import-source-file')?.setNodename(nodename); + me.lookup('import-target')?.setNodename(nodename); + }, + + hasAdvanced: true, + + initComponent: function () { + var me = this; + + me.drive = {}; + + let column1 = []; + let column2 = []; + + let advancedColumn1 = []; + let advancedColumn2 = []; + + if (!me.confid || me.unused) { + me.bussel = Ext.create('PVE.form.ControllerSelector', { + vmconfig: me.vmconfig, + selectFree: true, + }); + column1.push(me.bussel); + + me.scsiController = Ext.create('Ext.form.field.Display', { + fieldLabel: gettext('SCSI Controller'), + reference: 'scsiController', + name: 'scsiController', + bind: me.insideWizard + ? { + value: '{current.scsihw}', + visible: '{isSCSI}', + } + : { + visible: '{isSCSI}', + }, + renderer: PVE.Utils.render_scsihw, + submitValue: false, + hidden: true, + }); + column1.push(me.scsiController); + } + + if (me.unused) { + me.unusedDisks = Ext.create('Proxmox.form.KVComboBox', { + name: 'unusedId', + fieldLabel: gettext('Disk image'), + matchFieldWidth: false, + listConfig: { + width: 350, + }, + data: [], + allowBlank: false, + }); + column1.push(me.unusedDisks); + } else if (me.isCreate) { + if (!me.importDisk) { + column1.push({ + reference: 'new-disk', + xtype: 'pveDiskStorageSelector', + storageContent: 'images', + name: 'disk', + nodename: me.nodename, + autoSelect: me.insideWizard, + }); + } else { + if (me.importSelection) { + column1.push({ + xtype: 'displayfield', + fieldLabel: gettext('Selected Image'), + value: me.importSelection, + }); + column1.push({ + xtype: 'hiddenfield', + name: 'import-from', + value: me.importSelection, + }); + } else { + column1.push({ + xtype: 'pveStorageSelector', + reference: 'import-source', + fieldLabel: gettext('Import Storage'), + name: 'import-source-storage', + storageContent: 'import', + nodename: me.nodename, + autoSelect: me.insideWizard, + disabled: false, + listeners: { + change: function (_selector, storage) { + me.lookup('import-source-file').setStorage(storage); + me.lookup('import-source-file').setDisabled(!storage); + }, + }, + }); + column1.push({ + xtype: 'pveFileSelector', + reference: 'import-source-file', + fieldLabel: gettext('Select Image'), + storageContent: 'import', + name: 'import-from', + filter: (rec) => ['qcow2', 'vmdk', 'raw'].indexOf(rec?.data?.format) !== -1, + nodename: me.nodename, + }); + } + column1.push({ + xtype: 'pveDiskStorageSelector', + reference: 'import-target', + storageLabel: gettext('Target Storage'), + hideSize: true, + storageContent: 'images', + name: 'disk', + nodename: me.nodename, + autoSelect: me.insideWizard, + }); + } + } else { + column1.push({ + xtype: 'textfield', + disabled: true, + submitValue: false, + fieldLabel: gettext('Disk image'), + name: 'hdimage', + }); + } + + column2.push( + { + xtype: 'CacheTypeSelector', + name: 'cache', + value: '__default__', + fieldLabel: gettext('Cache'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Discard'), + reference: 'discard', + name: 'discard', + }, + { + xtype: 'proxmoxcheckbox', + name: 'iothread', + fieldLabel: 'IO thread', + clearOnDisable: true, + bind: + me.insideWizard || me.isCreate + ? { + disabled: '{!isVirtIO && !isSCSI}', + // Checkbox.setValue handles Arrays in a different way, therefore cast to bool + value: '{!!isVirtIO || (isSCSI && isSCSISingle)}', + } + : { + disabled: '{!isVirtIO && !isSCSI}', + }, + }, + ); + + advancedColumn1.push( + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('SSD emulation'), + name: 'ssd', + clearOnDisable: true, + bind: { + disabled: '{isVirtIO}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'readOnly', // `ro` in the config, we map in get/set values + defaultValue: 0, + fieldLabel: gettext('Read-only'), + clearOnDisable: true, + bind: { + disabled: '{!isVirtIO && !isSCSI}', + }, + }, + ); + + advancedColumn2.push( + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Backup'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Include volume in backup job'), + }, + name: 'backup', + bind: { + value: '{isIncludedInBackup}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Skip replication'), + name: 'noreplicate', + }, + { + xtype: 'proxmoxKVComboBox', + name: 'aio', + fieldLabel: gettext('Async IO'), + allowBlank: false, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (io_uring)'], + ['io_uring', 'io_uring'], + ['native', 'native'], + ['threads', 'threads'], + ], + }, + ); + + let labelWidth = 140; + + let bwColumn1 = [ + { + xtype: 'numberfield', + name: 'mbps_rd', + minValue: 1, + step: 1, + fieldLabel: gettext('Read limit') + ' (MB/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + { + xtype: 'numberfield', + name: 'mbps_wr', + minValue: 1, + step: 1, + fieldLabel: gettext('Write limit') + ' (MB/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_rd', + minValue: 10, + step: 10, + fieldLabel: gettext('Read limit') + ' (ops/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_wr', + minValue: 10, + step: 10, + fieldLabel: gettext('Write limit') + ' (ops/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + ]; + + let bwColumn2 = [ + { + xtype: 'numberfield', + name: 'mbps_rd_max', + minValue: 1, + step: 1, + fieldLabel: gettext('Read max burst') + ' (MB)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + { + xtype: 'numberfield', + name: 'mbps_wr_max', + minValue: 1, + step: 1, + fieldLabel: gettext('Write max burst') + ' (MB)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_rd_max', + minValue: 10, + step: 10, + fieldLabel: gettext('Read max burst') + ' (ops)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_wr_max', + minValue: 10, + step: 10, + fieldLabel: gettext('Write max burst') + ' (ops)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + ]; + + me.items = [ + { + xtype: 'tabpanel', + plain: true, + bodyPadding: 10, + border: 0, + items: [ + { + title: gettext('Disk'), + xtype: 'inputpanel', + reference: 'diskpanel', + column1, + column2, + advancedColumn1, + advancedColumn2, + showAdvanced: me.showAdvanced, + getValues: () => ({}), + }, + { + title: gettext('Bandwidth'), + xtype: 'inputpanel', + reference: 'bwpanel', + column1: bwColumn1, + column2: bwColumn2, + showAdvanced: me.showAdvanced, + getValues: () => ({}), + }, + ], + }, + ]; + + me.callParent(); + }, + + setAdvancedVisible: function (visible) { + this.lookup('diskpanel').setAdvancedVisible(visible); + this.lookup('bwpanel').setAdvancedVisible(visible); + }, +}); + +Ext.define('PVE.qemu.HDEdit', { + extend: 'Proxmox.window.Edit', + + isAdd: true, + + backgroundDelay: 5, + + width: 600, + bodyPadding: 0, + + importDisk: false, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var unused = me.confid && me.confid.match(/^unused\d+$/); + + me.isCreate = me.confid ? unused : true; + + var ipanel = Ext.create('PVE.qemu.HDInputPanel', { + confid: me.confid, + nodename: nodename, + unused: unused, + isCreate: me.isCreate, + importDisk: me.importDisk, + }); + + if (unused) { + me.subject = gettext('Unused Disk'); + } else if (me.isCreate) { + me.subject = gettext('Hard Disk'); + } else { + me.subject = gettext('Hard Disk') + ' (' + me.confid + ')'; + } + + me.items = [ipanel]; + + me.callParent(); + /* 'data' is assigned an empty array in same file, and here we + * use it like an object + */ + me.load({ + success: function (response, options) { + ipanel.setVMConfig(response.result.data); + if (me.confid) { + let value = response.result.data[me.confid]; + let drive = PVE.Parser.parseQemuDrive(me.confid, value); + if (!drive) { + Ext.Msg.alert(gettext('Error'), 'Unable to parse drive options'); + me.close(); + return; + } + ipanel.setDrive(drive); + me.isValid(); // trigger validation + } + }, + }); + }, +}); + +Ext.define('PVE.qemu.HDImportEdit', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + isAdd: true, + isCreate: true, + + backgroundDelay: 5, + + width: 600, + bodyPadding: 0, + + title: gettext('Import Hard Disk'), + + url: 'dummy', // will be set on vmid change + + cbindData: function () { + let me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + if (!me.selection) { + throw 'no image preselected'; + } + + return { + nodename: me.nodename, + selection: me.selection, + }; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + onVmidChange: function (_selector, value) { + let me = this; + let view = me.getView(); + let ipanel = me.lookup('ipanel'); + ipanel.setDisabled(true); + ipanel.setVisible(!!value); + let validation = me.lookup('validationProxy'); + validation.setValue(false); + view.url = `/api2/extjs/nodes/${view.nodename}/qemu/${value}/config`; + Proxmox.Utils.setErrorMask(ipanel, true); + + Proxmox.Utils.API2Request({ + url: view.url, + method: 'GET', + success: function (response, opts) { + ipanel.setVMConfig(response.result.data); + + validation.setValue(true); + + ipanel.setDisabled(false); + Proxmox.Utils.setErrorMask(ipanel, false); + }, + failure: function (response, _opts) { + Proxmox.Utils.setErrorMask(ipanel, response.htmlStatus); + }, + }); + }, + }, + + items: [ + { + xtype: 'vmComboSelector', + padding: 10, + allowBlank: false, + fieldLabel: gettext('Target Guest'), + submitValue: false, + cbind: {}, // for nested cbinds + store: { + model: 'PVEResources', + autoLoad: true, + sorters: 'vmid', + cbind: {}, // for nested cbinds + filters: [ + { + property: 'type', + value: 'qemu', + }, + { + property: 'node', + cbind: { + value: '{nodename}', + }, + }, + ], + }, + listeners: { + change: 'onVmidChange', + }, + }, + { + // used to prevent submitting while vm config is being loaded or that returns an error + xtype: 'textfield', + reference: 'validationProxy', + submitValue: false, + hidden: true, + validator: (val) => !!val, + }, + { + xtype: 'pveQemuHDInputPanel', + reference: 'ipanel', + hidden: true, + disabled: true, + isCreate: true, + importDisk: true, + cbind: { + importSelection: '{selection}', + nodename: '{nodename}', + }, + }, + ], +}); +Ext.define('PVE.qemu.EFIDiskInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveEFIDiskInputPanel', + + insideWizard: false, + + unused: false, // ADD usused disk imaged + + vmconfig: {}, // used to select usused disks + + onGetValues: function (values) { + var me = this; + + if (me.disabled) { + return {}; + } + + var confid = 'efidisk0'; + + if (values.hdimage) { + me.drive.file = values.hdimage; + } else { + // we use 1 here, because for efi the size gets overridden from the backend + me.drive.file = values.hdstorage + ':1'; + } + + // always default to newer 4m type with secure boot support, if we're + // adding a new EFI disk there can't be any old state anyway + me.drive.efitype = '4m'; + me.drive['pre-enrolled-keys'] = values.preEnrolledKeys; + delete values.preEnrolledKeys; + + me.drive.format = values.diskformat; + let params = {}; + params[confid] = PVE.Parser.printQemuDrive(me.drive); + return params; + }, + + setNodename: function (nodename) { + var me = this; + me.down('#hdstorage').setNodename(nodename); + me.down('#hdimage').setStorage(undefined, nodename); + }, + + setDisabled: function (disabled) { + let me = this; + me.down('pveDiskStorageSelector').setDisabled(disabled); + me.down('proxmoxcheckbox[name=preEnrolledKeys]').setDisabled(disabled); + me.callParent(arguments); + }, + + initComponent: function () { + var me = this; + + me.drive = {}; + + me.items = [ + { + xtype: 'pveDiskStorageSelector', + name: 'efidisk0', + storageLabel: gettext('EFI Storage'), + storageContent: 'images', + nodename: me.nodename, + disabled: me.disabled, + hideSize: true, + }, + { + xtype: 'proxmoxcheckbox', + name: 'preEnrolledKeys', + checked: true, + fieldLabel: gettext('Pre-Enroll keys'), + disabled: me.disabled, + //boxLabel: '(e.g., Microsoft secure-boot keys')', + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Use EFIvars image with standard distribution and Microsoft secure boot keys enrolled.', + ), + }, + }, + { + xtype: 'label', + text: gettext("Warning: The VM currently does not uses 'OVMF (UEFI)' as BIOS."), + userCls: 'pmx-hint', + hidden: me.usesEFI, + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.EFIDiskEdit', { + extend: 'Proxmox.window.Edit', + + isAdd: true, + subject: gettext('EFI Disk'), + + width: 450, + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.items = [ + { + xtype: 'pveEFIDiskInputPanel', + onlineHelp: 'qm_bios_and_uefi', + confid: me.confid, + nodename: nodename, + usesEFI: me.usesEFI, + isCreate: true, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.qemu.TPMDiskInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveTPMDiskInputPanel', + + unused: false, + vmconfig: {}, + + onGetValues: function (values) { + var me = this; + + if (me.disabled) { + return {}; + } + + var confid = 'tpmstate0'; + + if (values.hdimage) { + me.drive.file = values.hdimage; + } else { + // size is constant, so just use 1 + me.drive.file = values.hdstorage + ':1'; + } + + me.drive.format = values.diskformat; + me.drive.version = values.version; + var params = {}; + params[confid] = PVE.Parser.printQemuDrive(me.drive); + return params; + }, + + setNodename: function (nodename) { + var me = this; + me.down('#hdstorage').setNodename(nodename); + me.down('#hdimage').setStorage(undefined, nodename); + }, + + setDisabled: function (disabled) { + let me = this; + me.down('pveDiskStorageSelector').setDisabled(disabled); + me.down('proxmoxKVComboBox[name=version]').setDisabled(disabled); + me.callParent(arguments); + }, + + initComponent: function () { + var me = this; + + me.drive = {}; + + me.items = [ + { + xtype: 'pveDiskStorageSelector', + name: me.disktype + '0', + storageLabel: gettext('TPM Storage'), + storageContent: 'images', + nodename: me.nodename, + disabled: me.disabled, + hideSize: true, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'version', + value: 'v2.0', + fieldLabel: gettext('Version'), + deleteEmpty: false, + disabled: me.disabled, + comboItems: [ + ['v1.2', 'v1.2'], + ['v2.0', 'v2.0'], + ], + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.TPMDiskEdit', { + extend: 'Proxmox.window.Edit', + + isAdd: true, + subject: gettext('TPM State'), + + width: 450, + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.items = [ + { + xtype: 'pveTPMDiskInputPanel', + onlineHelp: 'qm_tpm', + confid: me.confid, + nodename: nodename, + isCreate: true, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.window.HDMove', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + resizable: false, + modal: true, + width: 350, + border: false, + layout: 'fit', + showReset: false, + showTaskViewer: true, + method: 'POST', + + cbindData: function () { + let me = this; + return { + disk: me.disk, + isQemu: me.type === 'qemu', + nodename: me.nodename, + url: () => { + let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume'; + return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`; + }, + }; + }, + + cbind: { + title: (get) => (get('isQemu') ? gettext('Move disk') : gettext('Move Volume')), + submitText: (get) => get('title'), + qemu: '{isQemu}', + url: '{url}', + }, + + getValues: function () { + let me = this; + let values = me.formPanel.getForm().getValues(); + + let params = { + storage: values.hdstorage, + }; + params[me.qemu ? 'disk' : 'volume'] = me.disk; + + if (values.diskformat && me.qemu) { + params.format = values.diskformat; + } + + if (values.deleteDisk) { + params.delete = 1; + } + return params; + }, + + items: [ + { + xtype: 'form', + reference: 'moveFormPanel', + border: false, + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + items: [ + { + xtype: 'displayfield', + cbind: { + name: (get) => (get('isQemu') ? 'disk' : 'volume'), + fieldLabel: (get) => + get('isQemu') ? gettext('Disk') : gettext('Mount Point'), + value: '{disk}', + }, + allowBlank: false, + }, + { + xtype: 'pveDiskStorageSelector', + storageLabel: gettext('Target Storage'), + cbind: { + nodename: '{nodename}', + storageContent: (get) => (get('isQemu') ? 'images' : 'rootdir'), + }, + hideSize: true, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Delete source'), + name: 'deleteDisk', + uncheckedValue: 0, + checked: false, + }, + ], + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + if (!me.type) { + throw 'no type specified'; + } + + me.callParent(); + }, +}); +Ext.define('PVE.window.HDResize', { + extend: 'Ext.window.Window', + + resizable: false, + + resize_disk: function (disk, size) { + var me = this; + var params = { disk: disk, size: '+' + size + 'G' }; + + Proxmox.Utils.API2Request({ + params: params, + url: '/nodes/' + me.nodename + '/qemu/' + me.vmid + '/resize', + waitMsgTarget: me, + method: 'PUT', + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, options) { + Ext.create('Proxmox.window.TaskProgress', { + autoShow: true, + upid: response.result.data, + }); + me.close(); + }, + }); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.vmid) { + throw 'no VM ID specified'; + } + + var items = [ + { + xtype: 'displayfield', + name: 'disk', + value: me.disk, + fieldLabel: gettext('Disk'), + vtype: 'StorageId', + allowBlank: false, + }, + ]; + + me.hdsizesel = Ext.createWidget('numberfield', { + name: 'size', + minValue: 0, + maxValue: 128 * 1024, + decimalPrecision: 3, + value: '0', + fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`, + allowBlank: false, + }); + + items.push(me.hdsizesel); + + me.formPanel = Ext.create('Ext.form.Panel', { + bodyPadding: 10, + border: false, + fieldDefaults: { + labelWidth: 140, + anchor: '100%', + }, + items: items, + }); + + var form = me.formPanel.getForm(); + + var submitBtn; + + me.title = gettext('Resize disk'); + submitBtn = Ext.create('Ext.Button', { + text: gettext('Resize disk'), + handler: function () { + if (form.isValid()) { + let values = form.getValues(); + me.resize_disk(me.disk, values.size); + } + }, + }); + + Ext.apply(me, { + modal: true, + width: 250, + height: 150, + border: false, + layout: 'fit', + buttons: [submitBtn], + items: [me.formPanel], + }); + + me.callParent(); + }, +}); +Ext.define('PVE.qemu.HardwareView', { + extend: 'Proxmox.grid.PendingObjectGrid', + alias: ['widget.PVE.qemu.HardwareView'], + + onlineHelp: 'qm_virtual_machines_settings', + + renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { + var me = this; + var rows = me.rows; + var rowdef = rows[key] || {}; + var iconCls = rowdef.iconCls; + var icon = ''; + var txt = rowdef.header || key; + + metaData.tdAttr = 'valign=middle'; + + if (rowdef.isOnStorageBus) { + let value = me.getObjectValue(key, '', false); + if (value === '') { + value = me.getObjectValue(key, '', true); + } + if (value.match(/vm-.*-cloudinit/)) { + iconCls = 'cloud'; + txt = rowdef.cloudheader; + } else if (value.match(/media=cdrom/)) { + metaData.tdCls = 'pve-itype-icon-cdrom'; + return rowdef.cdheader; + } + } + + if (rowdef.tdCls) { + metaData.tdCls = rowdef.tdCls; + } else if (iconCls) { + icon = ""; + metaData.tdCls += ' pve-itype-fa'; + } + + // only return icons in grid but not remove dialog + if (rowIndex !== undefined) { + return icon + txt; + } else { + return txt; + } + }, + + initComponent: function () { + var me = this; + + const { node: nodename, vmid } = me.pveSelNode.data; + if (!nodename) { + throw 'no node name specified'; + } else if (!vmid) { + throw 'no VM ID specified'; + } + + const caps = Ext.state.Manager.get('GuiCap'); + const diskCap = caps.vms['VM.Config.Disk']; + const cdromCap = caps.vms['VM.Config.CDROM']; + + let isCloudInitKey = (v) => v && v.toString().match(/vm-.*-cloudinit/); + + const nodeInfo = PVE.data.ResourceStore.getNodes().find((node) => node.node === nodename); + let processorEditor = { + xtype: 'pveQemuProcessorEdit', + cgroupMode: nodeInfo['cgroup-mode'], + }; + + let rows = { + memory: { + header: gettext('Memory'), + editor: caps.vms['VM.Config.Memory'] ? 'PVE.qemu.MemoryEdit' : undefined, + never_delete: true, + defaultValue: '512', + tdCls: 'pve-itype-icon-memory', + group: 2, + multiKey: ['memory', 'balloon', 'shares', 'allow-ksm'], + renderer: function (value, metaData, record, ri, ci, store, pending) { + var res = ''; + + var max = me.getObjectValue('memory', 512, pending); + var balloon = me.getObjectValue('balloon', undefined, pending); + var shares = me.getObjectValue('shares', undefined, pending); + + res = Proxmox.Utils.format_size(max * 1024 * 1024); + + if (balloon !== undefined && balloon > 0) { + res = Proxmox.Utils.format_size(balloon * 1024 * 1024) + '/' + res; + + if (shares) { + res += ' [shares=' + shares + ']'; + } + } else if (balloon === 0) { + res += ' [balloon=0]'; + } + + let allowKsm = me.getObjectValue('allow-ksm', undefined, pending); + if (allowKsm !== undefined) { + res += ' [allow-ksm=' + allowKsm + ']'; + } + + return res; + }, + }, + sockets: { + header: gettext('Processors'), + never_delete: true, + editor: + caps.vms['VM.Config.CPU'] || caps.vms['VM.Config.HWType'] + ? processorEditor + : undefined, + tdCls: 'pve-itype-icon-cpu', + group: 3, + defaultValue: '1', + multiKey: [ + 'sockets', + 'cpu', + 'cores', + 'numa', + 'vcpus', + 'cpulimit', + 'cpuunits', + 'affinity', + ], + renderer: function (value, metaData, record, rowIndex, colIndex, store, pending) { + var sockets = me.getObjectValue('sockets', 1, pending); + var model = me.getObjectValue('cpu', undefined, pending); + var cores = me.getObjectValue('cores', 1, pending); + var numa = me.getObjectValue('numa', undefined, pending); + var vcpus = me.getObjectValue('vcpus', undefined, pending); + var cpulimit = me.getObjectValue('cpulimit', undefined, pending); + var cpuunits = me.getObjectValue('cpuunits', undefined, pending); + var cpuaffinity = me.getObjectValue('affinity', undefined, pending); + + let res = Ext.String.format( + '{0} ({1} sockets, {2} cores)', + sockets * cores, + sockets, + cores, + ); + + if (model) { + res += ' [' + model + ']'; + } + if (numa) { + res += ' [numa=' + numa + ']'; + } + if (vcpus) { + res += ' [vcpus=' + vcpus + ']'; + } + if (cpulimit) { + res += ' [cpulimit=' + cpulimit + ']'; + } + if (cpuunits) { + res += ' [cpuunits=' + cpuunits + ']'; + } + if (cpuaffinity) { + res += ' [cpuaffinity=' + cpuaffinity + ']'; + } + + return res; + }, + }, + bios: { + header: 'BIOS', + group: 4, + never_delete: true, + editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.BiosEdit' : undefined, + defaultValue: '', + iconCls: 'microchip', + renderer: PVE.Utils.render_qemu_bios, + }, + vga: { + header: gettext('Display'), + editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.DisplayEdit' : undefined, + never_delete: true, + iconCls: 'desktop', + group: 5, + defaultValue: '', + renderer: PVE.Utils.render_kvm_vga_driver, + }, + machine: { + header: gettext('Machine'), + editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.MachineEdit' : undefined, + iconCls: 'cogs', + never_delete: true, + group: 6, + defaultValue: '', + renderer: function (value, metaData, record, rowIndex, colIndex, store, pending) { + let ostype = me.getObjectValue('ostype', undefined, pending); + if ( + PVE.Utils.is_windows(ostype) && + (!value || value === 'pc' || value === 'q35') + ) { + return value === 'q35' ? 'pc-q35-5.1' : 'pc-i440fx-5.1'; + } + return PVE.Utils.render_qemu_machine(value); + }, + }, + scsihw: { + header: gettext('SCSI Controller'), + iconCls: 'database', + editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.ScsiHwEdit' : undefined, + renderer: PVE.Utils.render_scsihw, + group: 7, + never_delete: true, + defaultValue: '', + }, + vmstate: { + header: gettext('Hibernation VM State'), + iconCls: 'download', + del_extra_msg: gettext('The saved VM state will be permanently lost.'), + group: 100, + }, + cores: { + visible: false, + }, + cpu: { + visible: false, + }, + numa: { + visible: false, + }, + 'allow-ksm': { + visible: false, + }, + balloon: { + visible: false, + }, + hotplug: { + visible: false, + }, + vcpus: { + visible: false, + }, + cpuunits: { + visible: false, + }, + cpulimit: { + visible: false, + }, + shares: { + visible: false, + }, + ostype: { + visible: false, + }, + affinity: { + visible: false, + }, + }; + + PVE.Utils.forEachBus(undefined, function (type, id) { + let confid = type + id; + rows[confid] = { + group: 10, + iconCls: 'hdd-o', + editor: 'PVE.qemu.HDEdit', + isOnStorageBus: true, + header: gettext('Hard Disk') + ' (' + confid + ')', + cdheader: gettext('CD/DVD Drive') + ' (' + confid + ')', + cloudheader: gettext('CloudInit Drive') + ' (' + confid + ')', + renderer: Ext.htmlEncode, + }; + }); + for (let i = 0; i < PVE.Utils.hardware_counts.net; i++) { + let confid = 'net' + i.toString(); + rows[confid] = { + group: 15, + order: i, + iconCls: 'exchange', + editor: caps.vms['VM.Config.Network'] ? 'PVE.qemu.NetworkEdit' : undefined, + never_delete: !caps.vms['VM.Config.Network'], + header: gettext('Network Device') + ' (' + confid + ')', + }; + } + rows.efidisk0 = { + group: 20, + iconCls: 'hdd-o', + editor: null, + never_delete: !caps.vms['VM.Config.Disk'], + header: gettext('EFI Disk'), + renderer: Ext.htmlEncode, + }; + rows.tpmstate0 = { + group: 22, + iconCls: 'hdd-o', + editor: null, + never_delete: !caps.vms['VM.Config.Disk'], + header: gettext('TPM State'), + renderer: Ext.htmlEncode, + }; + for (let i = 0; i < PVE.Utils.hardware_counts.usb; i++) { + let confid = 'usb' + i.toString(); + rows[confid] = { + group: 25, + order: i, + iconCls: 'usb', + editor: + caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] + ? 'PVE.qemu.USBEdit' + : undefined, + never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], + header: gettext('USB Device') + ' (' + confid + ')', + }; + } + for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) { + let confid = 'hostpci' + i.toString(); + rows[confid] = { + group: 30, + order: i, + tdCls: 'pve-itype-icon-pci', + never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], + editor: + caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] + ? 'PVE.qemu.PCIEdit' + : undefined, + header: gettext('PCI Device') + ' (' + confid + ')', + }; + } + for (let i = 0; i < PVE.Utils.hardware_counts.serial; i++) { + let confid = 'serial' + i.toString(); + rows[confid] = { + group: 35, + order: i, + tdCls: 'pve-itype-icon-serial', + never_delete: !caps.nodes['Sys.Console'], + header: gettext('Serial Port') + ' (' + confid + ')', + }; + } + rows.audio0 = { + group: 40, + iconCls: 'volume-up', + editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.AudioEdit' : undefined, + never_delete: !caps.vms['VM.Config.HWType'], + header: gettext('Audio Device'), + }; + for (let i = 0; i < 256; i++) { + rows['unused' + i.toString()] = { + group: 99, + order: i, + iconCls: 'hdd-o', + del_extra_msg: gettext('This will permanently erase all data.'), + editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.HDEdit' : undefined, + header: gettext('Unused Disk') + ' ' + i.toString(), + renderer: Ext.htmlEncode, + }; + } + rows.rng0 = { + group: 45, + tdCls: 'pve-itype-icon-die', + editor: + caps.vms['VM.Config.HWType'] || caps.mapping['Mapping.Use'] + ? 'PVE.qemu.RNGEdit' + : undefined, + never_delete: !caps.vms['VM.Config.HWType'] && !caps.mapping['Mapping.Use'], + header: gettext('VirtIO RNG'), + }; + for (let i = 0; i < PVE.Utils.hardware_counts.virtiofs; i++) { + let confid = 'virtiofs' + i.toString(); + rows[confid] = { + group: 50, + order: i, + iconCls: 'folder', + editor: 'PVE.qemu.VirtiofsEdit', + header: gettext('Virtiofs') + ' (' + confid + ')', + }; + } + + var sorterFn = function (rec1, rec2) { + var v1 = rec1.data.key; + var v2 = rec2.data.key; + var g1 = rows[v1].group || 0; + var g2 = rows[v2].group || 0; + var order1 = rows[v1].order || 0; + var order2 = rows[v2].order || 0; + + if (g1 - g2 !== 0) { + return g1 - g2; + } + + if (order1 - order2 !== 0) { + return order1 - order2; + } + + if (v1 > v2) { + return 1; + } else if (v1 < v2) { + return -1; + } else { + return 0; + } + }; + + let baseurl = `nodes/${nodename}/qemu/${vmid}/config`; + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec || !rows[rec.data.key]?.editor) { + return; + } + let rowdef = rows[rec.data.key]; + let editor = rowdef.editor; + + if (rowdef.isOnStorageBus) { + let value = me.getObjectValue(rec.data.key, '', true); + if (isCloudInitKey(value)) { + return; + } else if (value.match(/media=cdrom/)) { + editor = 'PVE.qemu.CDEdit'; + } else if (!diskCap) { + return; + } + } + + let commonOpts = { + autoShow: true, + pveSelNode: me.pveSelNode, + confid: rec.data.key, + url: `/api2/extjs/${baseurl}`, + listeners: { + destroy: () => me.reload(), + }, + }; + + if (Ext.isString(editor)) { + Ext.create(editor, commonOpts); + } else { + let win = Ext.createWidget( + rowdef.editor.xtype, + Ext.apply(commonOpts, rowdef.editor), + ); + win.load(); + } + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + selModel: sm, + disabled: true, + handler: run_editor, + }); + + let move_menuitem = new Ext.menu.Item({ + text: gettext('Move Storage'), + tooltip: gettext('Move disk to another storage'), + iconCls: 'fa fa-database', + selModel: sm, + handler: () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + Ext.create('PVE.window.HDMove', { + autoShow: true, + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + type: 'qemu', + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }); + + let reassign_menuitem = new Ext.menu.Item({ + text: gettext('Reassign Owner'), + tooltip: gettext('Reassign disk to another VM'), + iconCls: 'fa fa-desktop', + selModel: sm, + handler: () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + Ext.create('PVE.window.GuestDiskReassign', { + autoShow: true, + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + type: 'qemu', + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }); + + let resize_menuitem = new Ext.menu.Item({ + text: gettext('Resize'), + iconCls: 'fa fa-plus', + selModel: sm, + handler: () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + Ext.create('PVE.window.HDResize', { + autoShow: true, + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }); + + let diskaction_btn = new Proxmox.button.Button({ + text: gettext('Disk Action'), + disabled: true, + menu: { + items: [move_menuitem, reassign_menuitem, resize_menuitem], + }, + }); + + let remove_btn = new Proxmox.button.Button({ + text: gettext('Remove'), + defaultText: gettext('Remove'), + altText: gettext('Detach'), + selModel: sm, + disabled: true, + dangerous: true, + RESTMethod: 'PUT', + confirmMsg: function (rec) { + let warn = gettext('Are you sure you want to remove entry {0}'); + if (this.text === this.altText) { + warn = gettext('Are you sure you want to detach entry {0}'); + } + let rendered = me.renderKey(rec.data.key, {}, rec); + let msg = Ext.String.format(warn, `'${rendered}'`); + + if (rows[rec.data.key].del_extra_msg) { + msg += '
    ' + rows[rec.data.key].del_extra_msg; + } + return msg; + }, + handler: function (btn, e, rec) { + let params = { delete: rec.data.key }; + if (btn.RESTMethod === 'POST') { + params.background_delay = 5; + } + Proxmox.Utils.API2Request({ + url: '/api2/extjs/' + baseurl, + waitMsgTarget: me, + method: btn.RESTMethod, + params: params, + callback: () => me.reload(), + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + success: function (response, options) { + if (btn.RESTMethod === 'POST' && response.result.data !== null) { + Ext.create('Proxmox.window.TaskProgress', { + autoShow: true, + upid: response.result.data, + listeners: { + destroy: () => me.reload(), + }, + }); + } + }, + }); + }, + listeners: { + render: function (btn) { + // hack: calculate the max button width on first display to prevent the whole + // toolbar to move when we switch between the "Remove" and "Detach" labels + var def = btn.getSize().width; + + btn.setText(btn.altText); + var alt = btn.getSize().width; + + btn.setText(btn.defaultText); + + var optimal = alt > def ? alt : def; + btn.setSize({ width: optimal }); + }, + }, + }); + + let revert_btn = new PVE.button.PendingRevert({ + apiurl: '/api2/extjs/' + baseurl, + }); + + let efidisk_menuitem = Ext.create('Ext.menu.Item', { + text: gettext('EFI Disk'), + iconCls: 'fa fa-fw fa-hdd-o black', + disabled: !caps.vms['VM.Config.Disk'], + handler: function () { + let { data: bios } = me.rstore.getData().map.bios || {}; + + Ext.create('PVE.qemu.EFIDiskEdit', { + autoShow: true, + url: '/api2/extjs/' + baseurl, + pveSelNode: me.pveSelNode, + usesEFI: bios?.value === 'ovmf' || bios?.pending === 'ovmf', + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }); + + let counts = {}; + let isAtLimit = (type) => counts[type] >= PVE.Utils.hardware_counts[type]; + let isAtUsbLimit = () => { + let ostype = me.getObjectValue('ostype'); + let machine = me.getObjectValue('machine'); + return counts.usb >= PVE.Utils.get_max_usb_count(ostype, machine); + }; + + let set_button_status = function () { + let selection_model = me.getSelectionModel(); + let rec = selection_model.getSelection()[0]; + + counts = {}; // en/disable hardwarebuttons + let hasCloudInit = false; + me.rstore.getData().items.forEach(function ({ id, data }) { + if (!hasCloudInit && (isCloudInitKey(data.value) || isCloudInitKey(data.pending))) { + hasCloudInit = true; + return; + } + + let match = id.match(/^([^\d]+)\d+$/); + if (match && PVE.Utils.hardware_counts[match[1]] !== undefined) { + let type = match[1]; + counts[type] = (counts[type] || 0) + 1; + } + }); + + // heuristic only for disabling some stuff, the backend has the final word. + const noVMConfigHWTypePerm = !caps.vms['VM.Config.HWType']; + const noVMConfigNetPerm = !caps.vms['VM.Config.Network']; + const noVMConfigDiskPerm = !caps.vms['VM.Config.Disk']; + const noVMConfigCDROMPerm = !caps.vms['VM.Config.CDROM']; + const noVMConfigCloudinitPerm = !caps.vms['VM.Config.Cloudinit']; + const noVMConfigOptionsPerm = !caps.vms['VM.Config.Options']; + + me.down('#addUsb').setDisabled(noVMConfigHWTypePerm || isAtUsbLimit()); + me.down('#addPci').setDisabled(noVMConfigHWTypePerm || isAtLimit('hostpci')); + me.down('#addAudio').setDisabled(noVMConfigHWTypePerm || isAtLimit('audio')); + me.down('#addSerial').setDisabled(noVMConfigHWTypePerm || isAtLimit('serial')); + me.down('#addNet').setDisabled(noVMConfigNetPerm || isAtLimit('net')); + me.down('#addRng').setDisabled(noVMConfigHWTypePerm || isAtLimit('rng')); + efidisk_menuitem.setDisabled(noVMConfigDiskPerm || isAtLimit('efidisk')); + me.down('#addTpmState').setDisabled(noVMConfigDiskPerm || isAtLimit('tpmstate')); + me.down('#addVirtiofs').setDisabled(noVMConfigOptionsPerm || isAtLimit('virtiofs')); + me.down('#addCloudinitDrive').setDisabled( + noVMConfigCDROMPerm || noVMConfigCloudinitPerm || hasCloudInit, + ); + + if (!rec) { + remove_btn.disable(); + edit_btn.disable(); + diskaction_btn.disable(); + revert_btn.disable(); + return; + } + const { key, value } = rec.data; + const row = rows[key]; + + const deleted = !!rec.data.delete; + const pending = deleted || me.hasPendingChanges(key); + const isRunning = me.pveSelNode.data.running; + + const isCloudInit = isCloudInitKey(value); + const isCDRom = value && !!value.toString().match(/media=cdrom/); + + const isUnusedDisk = key.match(/^unused\d+/); + const isUsedDisk = !isUnusedDisk && row.isOnStorageBus && !isCDRom; + const isDisk = isUnusedDisk || isUsedDisk; + const isEfi = key === 'efidisk0'; + const tpmMoveable = key === 'tpmstate0' && !isRunning; + + let cannotDelete = deleted || row.never_delete; + cannotDelete ||= isCDRom && !cdromCap; + cannotDelete ||= isDisk && !diskCap; + cannotDelete ||= isCloudInit && noVMConfigCloudinitPerm; + remove_btn.setDisabled(cannotDelete); + + remove_btn.setText( + isUsedDisk && !isCloudInit ? remove_btn.altText : remove_btn.defaultText, + ); + remove_btn.RESTMethod = isUnusedDisk || (isDisk && isRunning) ? 'POST' : 'PUT'; + + edit_btn.setDisabled( + deleted || + !row.editor || + isCloudInit || + (isCDRom && !cdromCap) || + (isDisk && !diskCap), + ); + + diskaction_btn.setDisabled( + pending || !diskCap || isCloudInit || !(isDisk || isEfi || tpmMoveable), + ); + reassign_menuitem.setDisabled(pending || isEfi || tpmMoveable); + resize_menuitem.setDisabled(pending || !isUsedDisk); + + revert_btn.setDisabled(!pending); + }; + + let editorFactory = (classPath, extraOptions) => { + extraOptions = extraOptions || {}; + return () => + Ext.create(`PVE.qemu.${classPath}`, { + autoShow: true, + url: `/api2/extjs/${baseurl}`, + pveSelNode: me.pveSelNode, + listeners: { + destroy: () => me.reload(), + }, + isAdd: true, + isCreate: true, + ...extraOptions, + }); + }; + + Ext.apply(me, { + url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`, + interval: 5000, + selModel: sm, + run_editor: run_editor, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + cls: 'pve-add-hw-menu', + items: [ + { + text: gettext('Hard Disk'), + iconCls: 'fa fa-fw fa-hdd-o black', + disabled: !caps.vms['VM.Config.Disk'], + handler: editorFactory('HDEdit'), + }, + { + text: gettext('Import Hard Disk'), + iconCls: 'fa fa-fw fa-cloud-download', + disabled: !caps.vms['VM.Config.Disk'], + handler: editorFactory('HDEdit', { importDisk: true }), + }, + { + text: gettext('CD/DVD Drive'), + iconCls: 'pve-itype-icon-cdrom', + disabled: !caps.vms['VM.Config.CDROM'], + handler: editorFactory('CDEdit'), + }, + { + text: gettext('Network Device'), + itemId: 'addNet', + iconCls: 'fa fa-fw fa-exchange black', + disabled: !caps.vms['VM.Config.Network'], + handler: editorFactory('NetworkEdit'), + }, + efidisk_menuitem, + { + text: gettext('TPM State'), + itemId: 'addTpmState', + iconCls: 'fa fa-fw fa-hdd-o black', + disabled: !caps.vms['VM.Config.Disk'], + handler: editorFactory('TPMDiskEdit'), + }, + { + text: gettext('USB Device'), + itemId: 'addUsb', + iconCls: 'fa fa-fw fa-usb black', + disabled: + !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], + handler: editorFactory('USBEdit'), + }, + { + text: gettext('PCI Device'), + itemId: 'addPci', + iconCls: 'pve-itype-icon-pci', + disabled: + !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], + handler: editorFactory('PCIEdit'), + }, + { + text: gettext('Serial Port'), + itemId: 'addSerial', + iconCls: 'pve-itype-icon-serial', + disabled: !caps.vms['VM.Config.Options'], + handler: editorFactory('SerialEdit'), + }, + { + text: gettext('CloudInit Drive'), + itemId: 'addCloudinitDrive', + iconCls: 'fa fa-fw fa-cloud black', + disabled: + !caps.vms['VM.Config.CDROM'] || + !caps.vms['VM.Config.Cloudinit'], + handler: editorFactory('CIDriveEdit'), + }, + { + text: gettext('Audio Device'), + itemId: 'addAudio', + iconCls: 'fa fa-fw fa-volume-up black', + disabled: !caps.vms['VM.Config.HWType'], + handler: editorFactory('AudioEdit'), + }, + { + text: gettext('VirtIO RNG'), + itemId: 'addRng', + iconCls: 'pve-itype-icon-die', + disabled: + !caps.vms['VM.Config.HWType'] && !caps.mapping['Mapping.Use'], + handler: editorFactory('RNGEdit'), + }, + { + text: gettext('Virtiofs'), + itemId: 'addVirtiofs', + iconCls: 'fa fa-folder', + disabled: !caps.nodes['Sys.Console'], + handler: editorFactory('VirtiofsEdit'), + }, + ], + }), + }, + remove_btn, + edit_btn, + diskaction_btn, + revert_btn, + ], + rows: rows, + sorterFn: sorterFn, + listeners: { + itemdblclick: run_editor, + selectionchange: set_button_status, + }, + }); + + me.callParent(); + + me.on('activate', me.rstore.startUpdate, me.rstore); + me.on('destroy', me.rstore.stopUpdate, me.rstore); + + me.mon(me.getStore(), 'datachanged', set_button_status, me); + }, +}); +Ext.define('PVE.qemu.IPConfigPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveIPConfigPanel', + + insideWizard: false, + + vmconfig: {}, + + onGetValues: function (values) { + var me = this; + + if (values.ipv4mode !== 'static') { + values.ip = values.ipv4mode; + } + + if (values.ipv6mode !== 'static') { + values.ip6 = values.ipv6mode; + } + + var params = {}; + + var cfg = PVE.Parser.printIPConfig(values); + if (cfg === '') { + params.delete = [me.confid]; + } else { + params[me.confid] = cfg; + } + return params; + }, + + setVMConfig: function (config) { + var me = this; + me.vmconfig = config; + }, + + setIPConfig: function (confid, data) { + var me = this; + + me.confid = confid; + + if (data.ip === 'dhcp') { + data.ipv4mode = data.ip; + data.ip = ''; + } else { + data.ipv4mode = 'static'; + } + if (data.ip6 === 'dhcp' || data.ip6 === 'auto') { + data.ipv6mode = data.ip6; + data.ip6 = ''; + } else { + data.ipv6mode = 'static'; + } + + me.ipconfig = data; + me.setValues(me.ipconfig); + }, + + initComponent: function () { + var me = this; + + me.ipconfig = {}; + + me.column1 = [ + { + xtype: 'displayfield', + fieldLabel: gettext('Network Device'), + value: me.netid, + }, + { + layout: { + type: 'hbox', + align: 'middle', + }, + border: false, + margin: '0 0 5 0', + items: [ + { + xtype: 'label', + text: gettext('IPv4') + ':', + }, + { + xtype: 'radiofield', + boxLabel: gettext('Static'), + name: 'ipv4mode', + inputValue: 'static', + checked: false, + margin: '0 0 0 10', + listeners: { + change: function (cb, value) { + me.down('field[name=ip]').setDisabled(!value); + me.down('field[name=gw]').setDisabled(!value); + }, + }, + }, + { + xtype: 'radiofield', + boxLabel: gettext('DHCP'), + name: 'ipv4mode', + inputValue: 'dhcp', + checked: false, + margin: '0 0 0 10', + }, + ], + }, + { + xtype: 'textfield', + name: 'ip', + vtype: 'IPCIDRAddress', + value: '', + disabled: true, + fieldLabel: gettext('IPv4/CIDR'), + }, + { + xtype: 'textfield', + name: 'gw', + value: '', + vtype: 'IPAddress', + disabled: true, + fieldLabel: gettext('Gateway') + ' (' + gettext('IPv4') + ')', + }, + ]; + + me.column2 = [ + { + xtype: 'displayfield', + }, + { + layout: { + type: 'hbox', + align: 'middle', + }, + border: false, + margin: '0 0 5 0', + items: [ + { + xtype: 'label', + text: gettext('IPv6') + ':', + }, + { + xtype: 'radiofield', + boxLabel: gettext('Static'), + name: 'ipv6mode', + inputValue: 'static', + checked: false, + margin: '0 0 0 10', + listeners: { + change: function (cb, value) { + me.down('field[name=ip6]').setDisabled(!value); + me.down('field[name=gw6]').setDisabled(!value); + }, + }, + }, + { + xtype: 'radiofield', + boxLabel: gettext('DHCP'), + name: 'ipv6mode', + inputValue: 'dhcp', + checked: false, + margin: '0 0 0 10', + }, + { + xtype: 'radiofield', + boxLabel: gettext('SLAAC'), + name: 'ipv6mode', + inputValue: 'auto', + checked: false, + margin: '0 0 0 10', + }, + ], + }, + { + xtype: 'textfield', + name: 'ip6', + value: '', + vtype: 'IP6CIDRAddress', + disabled: true, + fieldLabel: gettext('IPv6/CIDR'), + }, + { + xtype: 'textfield', + name: 'gw6', + vtype: 'IP6Address', + value: '', + disabled: true, + fieldLabel: gettext('Gateway') + ' (' + gettext('IPv6') + ')', + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.IPConfigEdit', { + extend: 'Proxmox.window.Edit', + + isAdd: true, + + initComponent: function () { + var me = this; + + // convert confid from netX to ipconfigX + var match = me.confid.match(/^net(\d+)$/); + if (match) { + me.netid = me.confid; + me.confid = 'ipconfig' + match[1]; + } + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.isCreate = !me.confid; + + var ipanel = Ext.create('PVE.qemu.IPConfigPanel', { + confid: me.confid, + netid: me.netid, + nodename: nodename, + }); + + Ext.applyIf(me, { + subject: gettext('Network Config'), + items: ipanel, + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + me.vmconfig = response.result.data; + var ipconfig = {}; + var value = me.vmconfig[me.confid]; + if (value) { + ipconfig = PVE.Parser.parseIPConfig(me.confid, value); + if (!ipconfig) { + Ext.Msg.alert( + gettext('Error'), + gettext('Unable to parse network configuration'), + ); + me.close(); + return; + } + } + ipanel.setIPConfig(me.confid, ipconfig); + ipanel.setVMConfig(me.vmconfig); + }, + }); + }, +}); +Ext.define('PVE.qemu.KeyboardEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + Ext.applyIf(me, { + subject: gettext('Keyboard Layout'), + items: { + xtype: 'VNCKeyboardSelector', + name: 'keyboard', + value: '__default__', + fieldLabel: gettext('Keyboard Layout'), + }, + }); + + me.callParent(); + + me.load(); + }, +}); +Ext.define('PVE.qemu.MachineInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveMachineInputPanel', + onlineHelp: 'qm_machine_type', + + viewModel: { + data: { + type: '__default__', + }, + formulas: { + q35: (get) => get('type') === 'q35', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + 'combobox[name=machine]': { + change: 'onMachineChange', + }, + }, + onMachineChange: function (field, value) { + let me = this; + let version = me.lookup('version'); + let store = version.getStore(); + let oldRec = store.findRecord('id', version.getValue(), 0, false, false, true); + let type = value === 'q35' ? 'q35' : 'i440fx'; + store.clearFilter(); + store.addFilter((val) => val.data.id === 'latest' || val.data.type === type); + if (!me.getView().isWindows) { + version.setValue('latest'); + } else { + store.isWindows = true; + if (!oldRec) { + return; + } + let oldVers = oldRec.data.version; + // we already filtered by correct type, so just check version property + let rec = store.findRecord('version', oldVers, 0, false, false, true); + if (rec) { + version.select(rec); + } + } + }, + }, + + onGetValues: function (values) { + if (values.delete === 'machine' && values.viommu) { + delete values.delete; + values.machine = 'pc'; + } + if (values.version && values.version !== 'latest') { + values.machine = values.version; + delete values.delete; + } + delete values.version; + if (values.delete === 'machine' && !values.viommu) { + return values; + } + let ret = {}; + ret.machine = PVE.Parser.printPropertyString(values, 'machine'); + return ret; + }, + + setValues: function (values) { + let me = this; + + let machineConf = PVE.Parser.parsePropertyString(values.machine, 'type'); + values.machine = machineConf.type; + + me.isWindows = values.isWindows; + if (values.machine === 'pc') { + values.machine = '__default__'; + } + + if (me.isWindows) { + if (values.machine === '__default__') { + values.version = 'pc-i440fx-5.1'; + } else if (values.machine === 'q35') { + values.version = 'pc-q35-5.1'; + } + } + + values.viommu = machineConf.viommu || '__default__'; + + if (values.machine !== '__default__' && values.machine !== 'q35') { + values.version = values.machine; + values.machine = values.version.match(/q35/) ? 'q35' : '__default__'; + + // avoid hiding a pinned version + me.setAdvancedVisible(true); + } + + this.callParent(arguments); + }, + + items: { + xtype: 'proxmoxKVComboBox', + name: 'machine', + reference: 'machine', + fieldLabel: gettext('Machine'), + comboItems: [ + ['__default__', PVE.Utils.render_qemu_machine('')], + ['q35', 'q35'], + ], + bind: { + value: '{type}', + }, + }, + + advancedItems: [ + { + xtype: 'combobox', + name: 'version', + reference: 'version', + fieldLabel: gettext('Version'), + emptyText: gettext('Latest'), + value: 'latest', + editable: false, + valueField: 'id', + displayField: 'version', + queryParam: false, + store: { + autoLoad: true, + fields: ['id', 'type', 'version'], + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/localhost/capabilities/qemu/machines', + }, + listeners: { + load: function (records) { + if (!this.isWindows) { + this.insert(0, { + id: 'latest', + type: 'any', + version: gettext('Latest'), + }); + } + }, + }, + }, + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Note'), + value: gettext( + 'Machine version change may affect hardware layout and settings in the guest OS.', + ), + }, + { + xtype: 'proxmoxKVComboBox', + name: 'viommu', + fieldLabel: gettext('vIOMMU'), + reference: 'viommu-q35', + deleteEmpty: false, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (None)'], + ['intel', gettext('Intel (AMD Compatible)')], + ['virtio', 'VirtIO'], + ], + bind: { + hidden: '{!q35}', + disabled: '{!q35}', + }, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'viommu', + fieldLabel: gettext('vIOMMU'), + reference: 'viommu-i440fx', + deleteEmpty: false, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (None)'], + ['virtio', 'VirtIO'], + ], + bind: { + hidden: '{q35}', + disabled: '{q35}', + }, + }, + ], +}); + +Ext.define('PVE.qemu.MachineEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('Machine'), + + items: { + xtype: 'pveMachineInputPanel', + }, + + width: 400, + + initComponent: function () { + let me = this; + + me.callParent(); + + me.load({ + success: function (response) { + let conf = response.result.data; + let values = { + machine: conf.machine || '__default__', + }; + values.isWindows = PVE.Utils.is_windows(conf.ostype); + me.setValues(values); + }, + }); + }, +}); +Ext.define('PVE.qemu.MemoryInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuMemoryPanel', + onlineHelp: 'qm_memory', + + insideWizard: false, + + viewModel: {}, // inherit data from createWizard if insideWizard + + controller: { + xclass: 'Ext.app.ViewController', + + control: { + '#': { + afterrender: 'setMemory', + }, + }, + + setMemory: function () { + let me = this; + let view = me.getView(), + viewModel = me.getViewModel(); + if (view.insideWizard) { + let memory = view.down('pveMemoryField[name=memory]'); + // NOTE: we only set memory but that then sets balloon in its change handler + if (viewModel.get('current.ostype') === 'win11') { + memory.setValue('4096'); + } else { + memory.setValue('2048'); + } + } + }, + }, + + onGetValues: function (values) { + let res = {}; + + let deleteSet = new Set([]); + + // properties that can be passed as-is + let propagate = ['allow-ksm', 'memory']; + + propagate.forEach(function (prop) { + if (values.delete?.split(',').includes(prop)) { + deleteSet.add(prop); + } + if (prop in values) { + res[prop] = values[prop]; + } + }); + + res.balloon = values.balloon; + + if (!values.ballooning) { + res.balloon = 0; + deleteSet.add('shares'); + } else if (values.memory === values.balloon) { + delete res.balloon; + deleteSet.add('balloon'); + deleteSet.add('shares'); + } else if (Ext.isDefined(values.shares) && values.shares !== '') { + res.shares = values.shares; + } else { + deleteSet.add('shares'); + } + + if (deleteSet.size > 0) { + res.delete = deleteSet.keys().toArray().join(','); + } + + return res; + }, + + initComponent: function () { + var me = this; + var labelWidth = 160; + + me.items = [ + { + xtype: 'pveMemoryField', + labelWidth: labelWidth, + fieldLabel: gettext('Memory') + ' (MiB)', + name: 'memory', + value: '512', // better defaults get set via the view controllers afterrender + minValue: 1, + step: 32, + hotplug: me.hotplug, + listeners: { + change: function (f, value, old) { + var bf = me.down('field[name=balloon]'); + var balloon = bf.getValue(); + bf.setMaxValue(value); + if (balloon === old) { + bf.setValue(value); + } + bf.validate(); + }, + }, + }, + ]; + + me.advancedItems = [ + { + xtype: 'pveMemoryField', + name: 'balloon', + minValue: 1, + maxValue: me.insideWizard ? 2048 : 512, + value: '512', // better defaults get set (indirectly) via the view controllers afterrender + step: 32, + fieldLabel: gettext('Minimum memory') + ' (MiB)', + hotplug: me.hotplug, + labelWidth: labelWidth, + allowBlank: false, + listeners: { + change: function (f, value) { + var memory = me.down('field[name=memory]').getValue(); + var shares = me.down('field[name=shares]'); + shares.setDisabled(value === memory); + }, + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'shares', + disabled: true, + minValue: 0, + maxValue: 50000, + value: '', + step: 10, + fieldLabel: gettext('Shares'), + labelWidth: labelWidth, + allowBlank: true, + emptyText: Proxmox.Utils.defaultText + ' (1000)', + submitEmptyText: false, + }, + { + xtype: 'proxmoxcheckbox', + labelWidth: labelWidth, + value: '1', + name: 'ballooning', + fieldLabel: gettext('Ballooning Device'), + listeners: { + change: function (f, value) { + var bf = me.down('field[name=balloon]'); + var shares = me.down('field[name=shares]'); + var memory = me.down('field[name=memory]'); + bf.setDisabled(!value); + shares.setDisabled(!value || bf.getValue() === memory.getValue()); + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'allow-ksm', + labelWidth: labelWidth, + fieldLabel: gettext('Allow KSM'), + checked: true, + uncheckedValue: '0', + defaultValue: '1', + deleteDefaultValue: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Allow the Kernel Samepage Merging daemon to merge memory pages of this VM.', + ), + }, + }, + ]; + + if (me.insideWizard) { + me.column1 = me.items; + me.items = undefined; + me.advancedColumn1 = me.advancedItems; + me.advancedItems = undefined; + } + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.MemoryEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + var memoryhotplug; + if (me.hotplug) { + Ext.each(me.hotplug.split(','), function (el) { + if (el === 'memory') { + memoryhotplug = 1; + } + }); + } + + var ipanel = Ext.create('PVE.qemu.MemoryInputPanel', { + hotplug: memoryhotplug, + }); + + Ext.apply(me, { + subject: gettext('Memory'), + items: [ipanel], + // uncomment the following to use the async configiguration API + // backgroundDelay: 5, + width: 400, + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + var data = response.result.data; + + var values = { + ballooning: data.balloon === 0 ? '0' : '1', + shares: data.shares, + memory: data.memory || '512', + balloon: data.balloon > 0 ? data.balloon : data.memory || '512', + 'allow-ksm': data['allow-ksm'] ?? true, + }; + + ipanel.setValues(values); + }, + }); + }, +}); +Ext.define('PVE.qemu.Monitor', { + extend: 'Ext.panel.Panel', + + alias: 'widget.pveQemuMonitor', + + // start to trim saved command output once there are *both*, more than `commandLimit` commands + // executed and the total of saved in+output is over `lineLimit` lines; repeat by dropping one + // full command output until either condition is false again + commandLimit: 10, + lineLimit: 5000, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var history = []; + var histNum = -1; + let commands = []; + + var textbox = Ext.createWidget('panel', { + region: 'center', + xtype: 'panel', + autoScroll: true, + border: true, + margins: '5 5 5 5', + bodyStyle: 'font-family: monospace;', + }); + + var scrollToEnd = function () { + var el = textbox.getTargetEl(); + var dom = Ext.getDom(el); + + var clientHeight = dom.clientHeight; + // BrowserBug: clientHeight reports 0 in IE9 StrictMode + // Instead we are using offsetHeight and hardcoding borders + if (Ext.isIE9 && Ext.isStrict) { + clientHeight = dom.offsetHeight + 2; + } + dom.scrollTop = dom.scrollHeight - clientHeight; + }; + + var refresh = function () { + textbox.update(`
    ${commands.flat(2).join('\n')}
    `); + scrollToEnd(); + }; + + let recordInput = (line) => { + commands.push([line]); + + // drop oldest commands and their output until we're not over both limits anymore + while (commands.length > me.commandLimit && commands.flat(2).length > me.lineLimit) { + commands.shift(); + } + }; + + let addResponse = (lines) => commands[commands.length - 1].push(lines); + + var executeCmd = function (cmd) { + recordInput('# ' + Ext.htmlEncode(cmd), true); + if (cmd) { + history.unshift(cmd); + if (history.length > 20) { + history.splice(20); + } + } + histNum = -1; + + refresh(); + Proxmox.Utils.API2Request({ + params: { command: cmd }, + url: '/nodes/' + nodename + '/qemu/' + vmid + '/monitor', + method: 'POST', + waitMsgTarget: me, + success: function (response, opts) { + var res = response.result.data; + addResponse(res.split('\n').map((line) => Ext.htmlEncode(line))); + refresh(); + }, + failure: function (response, opts) { + Ext.Msg.alert('Error', response.htmlStatus); + }, + }); + }; + + Ext.apply(me, { + layout: { type: 'border' }, + border: false, + items: [ + textbox, + { + region: 'south', + margins: '0 5 5 5', + border: false, + xtype: 'textfield', + name: 'cmd', + value: '', + fieldStyle: 'font-family: monospace;', + allowBlank: true, + listeners: { + afterrender: function (f) { + f.focus(false); + recordInput("Type 'help' for help."); + refresh(); + }, + specialkey: function (f, e) { + var key = e.getKey(); + switch (key) { + case e.ENTER: { + let cmd = f.getValue(); + f.setValue(''); + executeCmd(cmd); + break; + } + case e.PAGE_UP: + textbox.scrollBy(0, -0.9 * textbox.getHeight(), false); + break; + case e.PAGE_DOWN: + textbox.scrollBy(0, 0.9 * textbox.getHeight(), false); + break; + case e.UP: + if (histNum + 1 < history.length) { + f.setValue(history[++histNum]); + } + e.preventDefault(); + break; + case e.DOWN: + if (histNum > 0) { + f.setValue(history[--histNum]); + } + e.preventDefault(); + break; + default: + break; + } + }, + }, + }, + ], + listeners: { + show: function () { + var field = me.query('textfield[name="cmd"]')[0]; + field.focus(false, true); + }, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.qemu.MultiHDPanel', { + extend: 'PVE.panel.MultiDiskPanel', + alias: 'widget.pveMultiHDPanel', + + onlineHelp: 'qm_hard_disk', + + importDisk: true, + + controller: { + xclass: 'Ext.app.ViewController', + + // maxCount is the sum of all controller ids - 1 (ide2 is fixed in the wizard) + maxCount: + Object.values(PVE.Utils.diskControllerMaxIDs).reduce( + (previous, current) => previous + current, + 0, + ) - 1, + + getNextFreeDisk: function (vmconfig) { + let clist = PVE.Utils.sortByPreviousUsage(vmconfig); + return PVE.Utils.nextFreeDisk(clist, vmconfig); + }, + + addPanel: function (itemId, vmconfig, nextFreeDisk, importDisk) { + let me = this; + return me.getView().add({ + vmconfig, + border: false, + showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'), + xtype: 'pveQemuHDInputPanel', + bind: { + nodename: '{nodename}', + }, + padding: '0 0 0 5', + itemId, + isCreate: true, + insideWizard: true, + importDisk, + }); + }, + + getBaseVMConfig: function () { + let me = this; + let vm = me.getViewModel(); + + let res = { + ide2: 'media=cdrom', + scsihw: vm.get('current.scsihw'), + ostype: vm.get('current.ostype'), + }; + + if (vm.get('current.ide0') === 'some') { + res.ide0 = 'media=cdrom'; + } + + return res; + }, + + diskSorter: { + sorterFn: function (rec1, rec2) { + let [, name1, id1] = PVE.Utils.bus_match.exec(rec1.data.name); + let [, name2, id2] = PVE.Utils.bus_match.exec(rec2.data.name); + + if (name1 === name2) { + return parseInt(id1, 10) - parseInt(id2, 10); + } + + return name1 < name2 ? -1 : 1; + }, + }, + + deleteDisabled: () => false, + }, +}); +Ext.define('PVE.qemu.NetworkInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuNetworkInputPanel', + onlineHelp: 'qm_network_device', + + insideWizard: false, + + onGetValues: function (values) { + var me = this; + + me.network.model = values.model; + if (values.nonetwork) { + return {}; + } else { + me.network.bridge = values.bridge; + me.network.tag = values.tag; + me.network.firewall = values.firewall; + } + me.network.macaddr = values.macaddr; + me.network.disconnect = values.disconnect; + me.network.queues = values.queues; + me.network.mtu = values.mtu; + + if (values.rate) { + me.network.rate = values.rate; + } else { + delete me.network.rate; + } + + var params = {}; + + params[me.confid] = PVE.Parser.printQemuNetwork(me.network); + + return params; + }, + + viewModel: { + data: { + networkModel: undefined, + mtu: '', + }, + formulas: { + isVirtio: (get) => get('networkModel') === 'virtio', + showMtuHint: (get) => get('mtu') === 1, + }, + }, + + setNetwork: function (confid, data) { + var me = this; + + me.confid = confid; + + if (data) { + data.networkmode = data.bridge ? 'bridge' : 'nat'; + } else { + data = {}; + data.networkmode = 'bridge'; + } + me.network = data; + + me.setValues(me.network); + }, + + setNodename: function (nodename) { + var me = this; + + me.bridgesel.setNodename(nodename); + }, + + initComponent: function () { + var me = this; + + me.network = {}; + me.confid = 'net0'; + + me.column1 = []; + me.column2 = []; + + me.bridgesel = Ext.create('PVE.form.BridgeSelector', { + name: 'bridge', + fieldLabel: gettext('Bridge'), + nodename: me.nodename, + autoSelect: true, + allowBlank: false, + }); + + me.column1 = [ + me.bridgesel, + { + xtype: 'pveVlanField', + name: 'tag', + value: '', + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Firewall'), + name: 'firewall', + checked: me.insideWizard || me.isCreate, + }, + ]; + + me.advancedColumn1 = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Disconnect'), + name: 'disconnect', + }, + { + xtype: 'proxmoxintegerfield', + name: 'mtu', + fieldLabel: 'MTU', + bind: { + disabled: '{!isVirtio}', + value: '{mtu}', + }, + emptyText: gettext('Same as bridge'), + minValue: 1, + maxValue: 65520, + allowBlank: true, + validator: (val) => + val === '' || val >= 576 || val === '1' + ? true + : gettext( + 'MTU needs to be >= 576 or 1 to inherit the MTU from the underlying bridge.', + ), + }, + ]; + + if (me.insideWizard) { + me.column1.unshift({ + xtype: 'checkbox', + name: 'nonetwork', + inputValue: 'none', + boxLabel: gettext('No network device'), + listeners: { + change: function (cb, value) { + var fields = [ + 'disconnect', + 'bridge', + 'tag', + 'firewall', + 'model', + 'macaddr', + 'rate', + 'queues', + 'mtu', + ]; + fields.forEach(function (fieldname) { + me.down('field[name=' + fieldname + ']').setDisabled(value); + }); + me.down('field[name=bridge]').validate(); + }, + }, + }); + me.column2.unshift({ + xtype: 'displayfield', + }); + } + + me.column2.push( + { + xtype: 'pveNetworkCardSelector', + name: 'model', + fieldLabel: gettext('Model'), + bind: '{networkModel}', + value: PVE.qemu.OSDefaults.generic.networkCard, + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'macaddr', + fieldLabel: gettext('MAC address'), + vtype: 'MacAddress', + allowBlank: true, + emptyText: 'auto', + }, + ); + me.advancedColumn2 = [ + { + xtype: 'numberfield', + name: 'rate', + fieldLabel: gettext('Rate limit') + ' (MB/s)', + minValue: 0, + maxValue: 10 * 1024, + value: '', + emptyText: 'unlimited', + allowBlank: true, + }, + { + xtype: 'proxmoxintegerfield', + name: 'queues', + fieldLabel: 'Multiqueue', + minValue: 1, + maxValue: 64, + value: '', + allowBlank: true, + }, + ]; + me.advancedColumnB = [ + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + "Use the special value '1' to inherit the MTU value from the underlying bridge", + ), + bind: { + hidden: '{!showMtuHint}', + }, + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.NetworkEdit', { + extend: 'Proxmox.window.Edit', + + isAdd: true, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + me.isCreate = !me.confid; + + var ipanel = Ext.create('PVE.qemu.NetworkInputPanel', { + confid: me.confid, + nodename: nodename, + isCreate: me.isCreate, + }); + + Ext.applyIf(me, { + subject: gettext('Network Device'), + items: ipanel, + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + var i, confid; + me.vmconfig = response.result.data; + if (!me.isCreate) { + let value = me.vmconfig[me.confid]; + let network = PVE.Parser.parseQemuNetwork(me.confid, value); + if (!network) { + Ext.Msg.alert(gettext('Error'), 'Unable to parse network options'); + me.close(); + return; + } + ipanel.setNetwork(me.confid, network); + } else { + for (i = 0; i < 100; i++) { + confid = 'net' + i.toString(); + if (!Ext.isDefined(me.vmconfig[confid])) { + me.confid = confid; + break; + } + } + + let ostype = me.vmconfig.ostype; + let defaults = PVE.qemu.OSDefaults.getDefaults(ostype); + let data = { + model: defaults.networkCard, + }; + + ipanel.setNetwork(me.confid, data); + } + }, + }); + }, +}); +/* + * This class holds performance *recommended* settings for the PVE Qemu wizards + * the *mandatory* settings are set in the PVE::QemuServer + * config_to_command sub + * We store this here until we get the data from the API server + */ + +// this is how you would add an hypothetic FreeBSD > 10 entry +// +//virtio-blk is stable but virtIO net still +// problematic as of 10.3 +// see https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=165059 +// addOS({ +// parent: 'generic', // inherits defaults +// pveOS: 'freebsd10', // must match a radiofield in OSTypeEdit.js +// busType: 'virtio' // must match a pveBusController value +// // networkCard muss match a pveNetworkCardSelector + +Ext.define('PVE.qemu.OSDefaults', { + singleton: true, // will also force creation when loaded + + constructor: function () { + let me = this; + + let addOS = function (settings) { + if (Object.hasOwn(settings, 'parent')) { + let child = Ext.clone(me[settings.parent]); + me[settings.pveOS] = Ext.apply(child, settings); + } else { + throw 'Could not find your genitor'; + } + }; + + // default values + me.generic = { + busType: 'ide', + networkCard: 'e1000', + busPriority: { + ide: 4, + sata: 3, + scsi: 2, + virtio: 1, + }, + scsihw: 'virtio-scsi-single', + cputype: 'x86-64-v2-AES', + }; + + // virtio-net is in kernel since 2.6.25 + // virtio-scsi since 3.2 but backported in RHEL with 2.6 kernel + addOS({ + pveOS: 'l26', + parent: 'generic', + busType: 'scsi', + busPriority: { + scsi: 4, + virtio: 3, + sata: 2, + ide: 1, + }, + networkCard: 'virtio', + }); + + // recommendation from http://wiki.qemu.org/Windows2000 + addOS({ + pveOS: 'w2k', + parent: 'generic', + networkCard: 'rtl8139', + scsihw: '', + }); + // https://pve.proxmox.com/wiki/Windows_XP_Guest_Notes + addOS({ + pveOS: 'wxp', + parent: 'w2k', + }); + + me.getDefaults = function (ostype) { + if (PVE.qemu.OSDefaults[ostype]) { + return PVE.qemu.OSDefaults[ostype]; + } else { + return PVE.qemu.OSDefaults.generic; + } + }; + }, +}); +Ext.define('PVE.qemu.OSTypeInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuOSTypePanel', + onlineHelp: 'qm_os_settings', + insideWizard: false, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + 'combobox[name=osbase]': { + change: 'onOSBaseChange', + }, + 'combobox[name=ostype]': { + afterrender: 'onOSTypeChange', + change: 'onOSTypeChange', + }, + 'checkbox[reference=enableSecondCD]': { + change: 'onSecondCDChange', + }, + }, + onOSBaseChange: function (field, value) { + let me = this; + me.lookup('ostype').getStore().setData(PVE.Utils.kvm_ostypes[value]); + if (me.getView().insideWizard) { + let isWindows = value === 'Microsoft Windows'; + let enableSecondCD = me.lookup('enableSecondCD'); + enableSecondCD.setVisible(isWindows); + if (!isWindows) { + enableSecondCD.setValue(false); + } + } + }, + onOSTypeChange: function (field) { + var me = this, + ostype = field.getValue(); + if (!me.getView().insideWizard) { + return; + } + var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype); + + me.setWidget('pveBusSelector', targetValues.busType); + me.setWidget('pveNetworkCardSelector', targetValues.networkCard); + me.setWidget('CPUModelSelector', targetValues.cputype); + var scsihw = targetValues.scsihw || '__default__'; + this.getViewModel().set('current.scsihw', scsihw); + this.getViewModel().set('current.ostype', ostype); + }, + setWidget: function (widget, newValue) { + // changing a widget is safe only if ComponentQuery.query returns us + // a single value array + var widgets = Ext.ComponentQuery.query('pveQemuCreateWizard ' + widget); + if (widgets.length === 1) { + widgets[0].setValue(newValue); + } else { + // ignore multiple disks, we only want to set the type if there is a single disk + } + }, + onSecondCDChange: function (widget, value, lastValue) { + let me = this; + let vm = me.getViewModel(); + let updateVMConfig = function () { + let widgets = Ext.ComponentQuery.query('pveMultiHDPanel'); + if (widgets.length === 1) { + widgets[0].getController().updateVMConfig(); + } + }; + if (value) { + // only for windows + vm.set('current.ide0', 'some'); + vm.notify(); + updateVMConfig(); + me.setWidget('pveBusSelector', 'scsi'); + me.setWidget('pveNetworkCardSelector', 'virtio'); + } else { + vm.set('current.ide0', ''); + vm.notify(); + updateVMConfig(); + me.setWidget('pveBusSelector', 'scsi'); + let ostype = me.lookup('ostype').getValue(); + let targetValues = PVE.qemu.OSDefaults.getDefaults(ostype); + me.setWidget('pveBusSelector', targetValues.busType); + } + }, + }, + + setNodename: function (nodename) { + var me = this; + me.lookup('isoSelector').setNodename(nodename); + }, + + onGetValues: function (values) { + if (values.ide0) { + let drive = { + media: 'cdrom', + file: values.ide0, + }; + values.ide0 = PVE.Parser.printQemuDrive(drive); + } + return values; + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'displayfield', + value: gettext('Guest OS') + ':', + hidden: !me.insideWizard, + }, + { + xtype: 'combobox', + submitValue: false, + name: 'osbase', + fieldLabel: gettext('Type'), + editable: false, + queryMode: 'local', + value: 'Linux', + store: Object.keys(PVE.Utils.kvm_ostypes), + }, + { + xtype: 'combobox', + name: 'ostype', + reference: 'ostype', + fieldLabel: gettext('Version'), + value: 'l26', + allowBlank: false, + editable: false, + queryMode: 'local', + valueField: 'val', + displayField: 'desc', + store: { + fields: ['desc', 'val'], + data: PVE.Utils.kvm_ostypes.Linux, + listeners: { + datachanged: function (store) { + var ostype = me.lookup('ostype'); + var old_val = ostype.getValue(); + if (!me.insideWizard && old_val && store.find('val', old_val) !== -1) { + ostype.setValue(old_val); + } else { + ostype.setValue(store.getAt(0)); + } + }, + }, + }, + }, + ]; + + if (me.insideWizard) { + me.items.push( + { + xtype: 'proxmoxcheckbox', + reference: 'enableSecondCD', + isFormField: false, + hidden: true, + checked: false, + boxLabel: gettext('Add additional drive for VirtIO drivers'), + listeners: { + change: function (cb, value) { + me.lookup('isoSelector').setDisabled(!value); + me.lookup('isoSelector').setHidden(!value); + }, + }, + }, + { + xtype: 'pveIsoSelector', + reference: 'isoSelector', + name: 'ide0', + nodename: me.nodename, + insideWizard: true, + hidden: true, + disabled: true, + }, + ); + } + + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.OSTypeEdit', { + extend: 'Proxmox.window.Edit', + + subject: 'OS Type', + + items: [{ xtype: 'pveQemuOSTypePanel' }], + + initComponent: function () { + var me = this; + + me.callParent(); + + me.load({ + success: function (response, options) { + var value = response.result.data.ostype || 'other'; + var osinfo = PVE.Utils.get_kvm_osinfo(value); + me.setValues({ ostype: value, osbase: osinfo.base }); + }, + }); + }, +}); +Ext.define('PVE.qemu.Options', { + extend: 'Proxmox.grid.PendingObjectGrid', + alias: ['widget.PVE.qemu.Options'], + + onlineHelp: 'qm_options', + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var vmid = me.pveSelNode.data.vmid; + if (!vmid) { + throw 'no VM ID specified'; + } + + var caps = Ext.state.Manager.get('GuiCap'); + + var rows = { + name: { + required: true, + defaultValue: me.pveSelNode.data.name, + header: gettext('Name'), + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Name'), + items: { + xtype: 'inputpanel', + items: { + xtype: 'textfield', + name: 'name', + vtype: 'DnsName', + value: '', + fieldLabel: gettext('Name'), + allowBlank: true, + }, + onGetValues: function (values) { + var params = values; + if ( + values.name === undefined || + values.name === null || + values.name === '' + ) { + params = { delete: 'name' }; + } + return params; + }, + }, + } + : undefined, + }, + onboot: { + header: gettext('Start at boot'), + defaultValue: '', + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Start at boot'), + items: { + xtype: 'proxmoxcheckbox', + name: 'onboot', + uncheckedValue: 0, + defaultValue: 0, + deleteDefaultValue: true, + fieldLabel: gettext('Start at boot'), + }, + } + : undefined, + }, + startup: { + header: gettext('Start/Shutdown order'), + defaultValue: '', + renderer: PVE.Utils.render_kvm_startup, + editor: + caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify'] + ? { + xtype: 'pveWindowStartupEdit', + onlineHelp: 'qm_startup_and_shutdown', + } + : undefined, + }, + ostype: { + header: gettext('OS Type'), + editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.OSTypeEdit' : undefined, + renderer: PVE.Utils.render_kvm_ostype, + defaultValue: 'other', + }, + bootdisk: { + visible: false, + }, + boot: { + header: gettext('Boot Order'), + defaultValue: 'cdn', + editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.BootOrderEdit' : undefined, + multiKey: ['boot', 'bootdisk'], + renderer: function (order, metaData, record, rowIndex, colIndex, store, pending) { + if (/^\s*$/.test(order)) { + return gettext('(No boot device selected)'); + } + let boot = PVE.Parser.parsePropertyString(order, 'legacy'); + if (boot.order) { + let list = boot.order.split(';'); + let ret = ''; + list.forEach((dev) => { + if (ret) { + ret += ', '; + } + ret += dev; + }); + return ret; + } + + // legacy style and fallback + let i; + var text = ''; + var bootdisk = me.getObjectValue('bootdisk', undefined, pending); + order = boot.legacy || 'cdn'; + for (i = 0; i < order.length; i++) { + if (text) { + text += ', '; + } + let sel = order.substring(i, i + 1); + if (sel === 'c') { + if (bootdisk) { + text += bootdisk; + } else { + text += gettext('first disk'); + } + } else if (sel === 'n') { + text += gettext('any net'); + } else if (sel === 'a') { + text += gettext('Floppy'); + } else if (sel === 'd') { + text += gettext('any CD-ROM'); + } else { + text += sel; + } + } + return text; + }, + }, + tablet: { + header: gettext('Use tablet for pointer'), + defaultValue: true, + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.Config.HWType'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Use tablet for pointer'), + items: { + xtype: 'proxmoxcheckbox', + name: 'tablet', + checked: true, + uncheckedValue: 0, + defaultValue: 1, + deleteDefaultValue: true, + fieldLabel: gettext('Enabled'), + }, + } + : undefined, + }, + hotplug: { + header: gettext('Hotplug'), + defaultValue: 'disk,network,usb', + renderer: PVE.Utils.render_hotplug_features, + editor: caps.vms['VM.Config.HWType'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Hotplug'), + items: { + xtype: 'pveHotplugFeatureSelector', + name: 'hotplug', + value: '', + multiSelect: true, + fieldLabel: gettext('Hotplug'), + allowBlank: true, + }, + } + : undefined, + }, + acpi: { + header: gettext('ACPI support'), + defaultValue: true, + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.Config.HWType'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('ACPI support'), + items: { + xtype: 'proxmoxcheckbox', + name: 'acpi', + checked: true, + uncheckedValue: 0, + defaultValue: 1, + deleteDefaultValue: true, + fieldLabel: gettext('Enabled'), + }, + } + : undefined, + }, + kvm: { + header: gettext('KVM hardware virtualization'), + defaultValue: true, + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.Config.HWType'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('KVM hardware virtualization'), + items: { + xtype: 'proxmoxcheckbox', + name: 'kvm', + checked: true, + uncheckedValue: 0, + defaultValue: 1, + deleteDefaultValue: true, + fieldLabel: gettext('Enabled'), + }, + } + : undefined, + }, + freeze: { + header: gettext('Freeze CPU at startup'), + defaultValue: false, + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.PowerMgmt'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Freeze CPU at startup'), + items: { + xtype: 'proxmoxcheckbox', + name: 'freeze', + uncheckedValue: 0, + defaultValue: 0, + deleteDefaultValue: true, + labelWidth: 140, + fieldLabel: gettext('Freeze CPU at startup'), + }, + } + : undefined, + }, + localtime: { + header: gettext('Use local time for RTC'), + defaultValue: '__default__', + renderer: PVE.Utils.render_localtime, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Use local time for RTC'), + width: 400, + items: { + xtype: 'proxmoxKVComboBox', + name: 'localtime', + value: '__default__', + comboItems: [ + ['__default__', PVE.Utils.render_localtime('__default__')], + [1, PVE.Utils.render_localtime(1)], + [0, PVE.Utils.render_localtime(0)], + ], + labelWidth: 140, + fieldLabel: gettext('Use local time for RTC'), + }, + } + : undefined, + }, + startdate: { + header: gettext('RTC start date'), + defaultValue: 'now', + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('RTC start date'), + items: { + xtype: 'proxmoxtextfield', + name: 'startdate', + deleteEmpty: true, + value: 'now', + fieldLabel: gettext('RTC start date'), + vtype: 'QemuStartDate', + allowBlank: true, + }, + } + : undefined, + }, + smbios1: { + header: gettext('SMBIOS settings (type1)'), + defaultValue: '', + renderer: Ext.String.htmlEncode, + editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.Smbios1Edit' : undefined, + }, + agent: { + header: 'QEMU Guest Agent', + defaultValue: false, + renderer: PVE.Utils.render_qga_features, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Qemu Agent'), + width: 350, + onlineHelp: 'qm_qemu_agent', + items: { + xtype: 'pveAgentFeatureSelector', + name: 'agent', + }, + } + : undefined, + }, + protection: { + header: gettext('Protection'), + defaultValue: false, + renderer: Proxmox.Utils.format_boolean, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Protection'), + items: { + xtype: 'proxmoxcheckbox', + name: 'protection', + uncheckedValue: 0, + defaultValue: 0, + deleteDefaultValue: true, + fieldLabel: gettext('Enabled'), + }, + } + : undefined, + }, + spice_enhancements: { + header: gettext('Spice Enhancements'), + defaultValue: false, + renderer: PVE.Utils.render_spice_enhancements, + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('Spice Enhancements'), + onlineHelp: 'qm_spice_enhancements', + items: { + xtype: 'pveSpiceEnhancementSelector', + name: 'spice_enhancements', + }, + } + : undefined, + }, + vmstatestorage: { + header: gettext('VM State storage'), + defaultValue: '', + renderer: (val) => val || gettext('Automatic'), + editor: caps.vms['VM.Config.Options'] + ? { + xtype: 'proxmoxWindowEdit', + subject: gettext('VM State storage'), + onlineHelp: 'qm_vmstatestorage', + width: 350, + items: { + xtype: 'pveStorageSelector', + storageContent: 'images', + allowBlank: true, + emptyText: gettext("Automatic (Storage used by the VM, or 'local')"), + autoSelect: false, + deleteEmpty: true, + skipEmptyText: true, + nodename: nodename, + name: 'vmstatestorage', + }, + } + : undefined, + }, + 'amd-sev': { + header: gettext('AMD SEV'), + editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.SevEdit' : undefined, + defaultValue: Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', + renderer: function (value, metaData, record, ri, ci, store, pending) { + let amd_sev = PVE.Parser.parsePropertyString(value, 'type'); + if (amd_sev.type === 'std') { + return 'AMD SEV (' + value + ')'; + } + if (amd_sev.type === 'es') { + return 'AMD SEV-ES (' + value + ')'; + } + if (amd_sev.type === 'snp') { + return 'AMD SEV-SNP (' + value + ')'; + } + return value; + }, + }, + 'intel-tdx': { + header: gettext('Intel TDX'), + editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.TdxEdit' : undefined, + defaultValue: Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', + renderer: function (value, metaData, record, ri, ci, store, pending) { + let intel_tdx = PVE.Parser.parsePropertyString(value, 'type'); + if (intel_tdx.type === 'tdx') { + return 'Intel (' + value + ')'; + } + return value; + }, + }, + hookscript: { + header: gettext('Hookscript'), + }, + }; + + var baseurl = 'nodes/' + nodename + '/qemu/' + vmid + '/config'; + + var edit_btn = new Ext.Button({ + text: gettext('Edit'), + disabled: true, + handler: function () { + me.run_editor(); + }, + }); + + var revert_btn = new PVE.button.PendingRevert(); + + var set_button_status = function () { + var sm = me.getSelectionModel(); + var rec = sm.getSelection()[0]; + + if (!rec) { + edit_btn.disable(); + return; + } + + var key = rec.data.key; + var pending = rec.data.delete || me.hasPendingChanges(key); + var rowdef = rows[key]; + + edit_btn.setDisabled(!rowdef.editor); + revert_btn.setDisabled(!pending); + }; + + Ext.apply(me, { + url: '/api2/json/nodes/' + nodename + '/qemu/' + vmid + '/pending', + interval: 5000, + cwidth1: 250, + tbar: [edit_btn, revert_btn], + rows: rows, + editorConfig: { + url: '/api2/extjs/' + baseurl, + }, + listeners: { + itemdblclick: me.run_editor, + selectionchange: set_button_status, + }, + }); + + me.callParent(); + + me.on('activate', () => me.rstore.startUpdate()); + me.on('destroy', () => me.rstore.stopUpdate()); + me.on('deactivate', () => me.rstore.stopUpdate()); + + me.mon(me.getStore(), 'datachanged', function () { + set_button_status(); + }); + }, +}); +Ext.define('PVE.qemu.PCIInputPanel', { + extend: 'Proxmox.panel.InputPanel', + + onlineHelp: 'qm_pci_passthrough_vm_config', + + controller: { + xclass: 'Ext.app.ViewController', + + setVMConfig: function (vmconfig) { + let me = this; + let view = me.getView(); + let vm = me.getViewModel(); + me.vmconfig = vmconfig; + + let hostpci = me.vmconfig[view.confid] || ''; + + let values = PVE.Parser.parsePropertyString(hostpci, 'host'); + if (values.host) { + if (!values.host.match(/^[0-9a-f]{4}:/i)) { + // add optional domain + values.host = '0000:' + values.host; + } + if (values.host.length < 11) { + // 0000:00:00 format not 0000:00:00.0 + values.host += '.0'; + values.multifunction = true; + } + values.type = 'raw'; + } else if (values.mapping) { + values.type = 'mapped'; + } + vm.set('isMapped', values.type !== 'raw'); + + values['x-vga'] = PVE.Parser.parseBoolean(values['x-vga'], 0); + values.pcie = PVE.Parser.parseBoolean(values.pcie, 0); + values.rombar = PVE.Parser.parseBoolean(values.rombar, 1); + + view.setValues(values); + if (!me.vmconfig.machine || me.vmconfig.machine.indexOf('q35') === -1) { + // machine is not set to some variant of q35, so we disable pcie + let pcie = me.lookup('pcie'); + pcie.setDisabled(true); + pcie.setBoxLabel(gettext('Q35 only')); + } + + if (values.romfile) { + me.lookup('romfile').setVisible(true); + } + }, + + selectorEnable: function (selector) { + let me = this; + me.pciDevChange(selector, selector.getValue()); + }, + + pciDevChange: function (pcisel, value) { + let me = this; + let mdevfield = me.lookup('mdev'); + if (!value) { + if (!pcisel.isDisabled()) { + mdevfield.setDisabled(true); + } + return; + } + let pciDev = pcisel.getStore().getById(value); + + mdevfield.setDisabled(!pciDev || !pciDev.data.mdev); + if (!pciDev) { + return; + } + + let path = value; + if (pciDev.data.map) { + path = pciDev.data.id; + } + + if (pciDev.data.mdev) { + mdevfield.setPciIdOrMapping(path); + } + if (pcisel.reference === 'selector') { + let iommu = pciDev.data.iommugroup; + if (iommu === -1) { + return; + } + // try to find out if there are more devices in that iommu group + let id = path.substring(0, 5); // 00:00 + let count = 0; + pcisel.getStore().each(({ data }) => { + if (data.iommugroup === iommu && data.id.substring(0, 5) !== id) { + count++; + return false; + } + return true; + }); + me.lookup('group_warning').setVisible(count > 0); + } + }, + + onGetValues: function (values) { + let me = this; + let view = me.getView(); + if (!view.confid) { + for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) { + if (!me.vmconfig['hostpci' + i.toString()]) { + view.confid = 'hostpci' + i.toString(); + break; + } + } + // FIXME: what if no confid was found?? + } + + values.host?.replace(/^0000:/, ''); // remove optional '0000' domain + + if (values.multifunction && values.host) { + values.host = values.host.substring(0, values.host.indexOf('.')); // skip the '.X' + delete values.multifunction; + } + + if (values.rombar) { + delete values.rombar; + } else { + values.rombar = 0; + } + + if (!values.romfile) { + delete values.romfile; + } + + delete values.type; + + let ret = {}; + ret[view.confid] = PVE.Parser.printPropertyString(values, 'host'); + return ret; + }, + }, + + viewModel: { + data: { + isMapped: true, + }, + }, + + setVMConfig: function (vmconfig) { + return this.getController().setVMConfig(vmconfig); + }, + + onGetValues: function (values) { + return this.getController().onGetValues(values); + }, + + initComponent: function () { + let me = this; + + me.nodename = me.pveSelNode.data.node; + if (!me.nodename) { + throw 'no node name specified'; + } + + me.columnT = [ + { + xtype: 'displayfield', + reference: 'iommu_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + value: + 'No IOMMU detected, please activate it.' + + 'See Documentation for further information.', + userCls: 'pmx-hint', + }, + { + xtype: 'displayfield', + reference: 'group_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + itemId: 'iommuwarning', + value: 'The selected Device is not in a separate IOMMU group, make sure this is intended.', + userCls: 'pmx-hint', + }, + ]; + + me.column1 = [ + { + xtype: 'radiofield', + name: 'type', + inputValue: 'mapped', + checked: true, + boxLabel: gettext('Mapped Device'), + bind: { + value: '{isMapped}', + }, + }, + { + xtype: 'pvePCIMapSelector', + fieldLabel: gettext('Device'), + reference: 'mapped_selector', + name: 'mapping', + labelAlign: 'right', + nodename: me.nodename, + allowBlank: false, + bind: { + disabled: '{!isMapped}', + }, + listeners: { + change: 'pciDevChange', + enable: 'selectorEnable', + }, + }, + { + xtype: 'radiofield', + name: 'type', + inputValue: 'raw', + boxLabel: gettext('Raw Device'), + }, + { + xtype: 'pvePCISelector', + fieldLabel: gettext('Device'), + name: 'host', + reference: 'selector', + nodename: me.nodename, + labelAlign: 'right', + allowBlank: false, + disabled: true, + bind: { + disabled: '{isMapped}', + }, + onLoadCallBack: function (store, records, success) { + if (!success || !records.length) { + return; + } + me.lookup('iommu_warning').setVisible( + records.every((val) => val.data.iommugroup === -1), + ); + }, + listeners: { + change: 'pciDevChange', + enable: 'selectorEnable', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('All Functions'), + reference: 'all_functions', + disabled: true, + labelAlign: 'right', + name: 'multifunction', + bind: { + disabled: '{isMapped}', + }, + }, + ]; + + me.column2 = [ + { + xtype: 'pveMDevSelector', + name: 'mdev', + reference: 'mdev', + disabled: true, + fieldLabel: gettext('MDev Type'), + nodename: me.nodename, + listeners: { + change: function (field, value) { + let multiFunction = me.down('field[name=multifunction]'); + if (value) { + multiFunction.setValue(false); + } + multiFunction.setDisabled(!!value); + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Primary GPU'), + name: 'x-vga', + }, + ]; + + me.advancedColumn1 = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: 'ROM-Bar', + name: 'rombar', + }, + { + xtype: 'displayfield', + submitValue: true, + hidden: true, + fieldLabel: 'ROM-File', + reference: 'romfile', + name: 'romfile', + }, + { + xtype: 'textfield', + name: 'vendor-id', + fieldLabel: gettext('Vendor ID'), + emptyText: gettext('From Device'), + vtype: 'PciId', + allowBlank: true, + submitEmpty: false, + }, + { + xtype: 'textfield', + name: 'device-id', + fieldLabel: gettext('Device ID'), + emptyText: gettext('From Device'), + vtype: 'PciId', + allowBlank: true, + submitEmpty: false, + }, + ]; + + me.advancedColumn2 = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: 'PCI-Express', + reference: 'pcie', + name: 'pcie', + }, + { + xtype: 'textfield', + name: 'sub-vendor-id', + fieldLabel: gettext('Sub-Vendor ID'), + emptyText: gettext('From Device'), + vtype: 'PciId', + allowBlank: true, + submitEmpty: false, + }, + { + xtype: 'textfield', + name: 'sub-device-id', + fieldLabel: gettext('Sub-Device ID'), + emptyText: gettext('From Device'), + vtype: 'PciId', + allowBlank: true, + submitEmpty: false, + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.PCIEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('PCI Device'), + + vmconfig: undefined, + isAdd: true, + + initComponent: function () { + let me = this; + + me.isCreate = !me.confid; + + let ipanel = Ext.create('PVE.qemu.PCIInputPanel', { + confid: me.confid, + pveSelNode: me.pveSelNode, + }); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + + me.load({ + success: ({ result }) => ipanel.setVMConfig(result.data), + }); + }, +}); +// The view model of the parent should contain a 'cgroupMode' variable (or params for v2 are used). +Ext.define('PVE.qemu.ProcessorInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuProcessorPanel', + onlineHelp: 'qm_cpu', + + insideWizard: false, + + viewModel: { + data: { + socketCount: 1, + coreCount: 1, + showCustomModelPermWarning: false, + userIsRoot: false, + }, + formulas: { + totalCoreCount: (get) => get('socketCount') * get('coreCount'), + cpuunitsDefault: (get) => (get('cgroupMode') === 1 ? 1024 : 100), + cpuunitsMin: (get) => (get('cgroupMode') === 1 ? 2 : 1), + cpuunitsMax: (get) => (get('cgroupMode') === 1 ? 262144 : 10000), + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + init: function () { + let me = this; + let viewModel = me.getViewModel(); + + viewModel.set('userIsRoot', Proxmox.UserName === 'root@pam'); + }, + }, + + onGetValues: function (values) { + let me = this; + let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault'); + + if (Array.isArray(values.delete)) { + values.delete = values.delete.join(','); + } + + PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard); + PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard); + + // build the cpu options: + me.cpu.cputype = values.cputype; + + if (values.flags) { + me.cpu.flags = values.flags; + } else { + delete me.cpu.flags; + } + + delete values.cputype; + delete values.flags; + var cpustring = PVE.Parser.printQemuCpu(me.cpu); + + // remove cputype delete request: + var del = values.delete; + delete values.delete; + if (del) { + del = del.split(','); + Ext.Array.remove(del, 'cputype'); + } else { + del = []; + } + + if (cpustring) { + values.cpu = cpustring; + } else { + del.push('cpu'); + } + + var delarr = del.join(','); + if (delarr) { + values.delete = delarr; + } + + return values; + }, + + setValues: function (values) { + let me = this; + + let type = values.cputype; + let typeSelector = me.lookupReference('cputype'); + let typeStore = typeSelector.getStore(); + typeStore.on('load', (store, records, success) => { + if (!success || !type || records.some((x) => x.data.name === type)) { + return; + } + + // if we get here, a custom CPU model is selected for the VM but we + // don't have permission to configure it - it will not be in the + // list retrieved from the API, so add it manually to allow changing + // other processor options + typeStore.add({ + name: type, + displayname: type.replace(/^custom-/, ''), + custom: 1, + vendor: gettext('Unknown'), + }); + typeSelector.select(type); + }); + + me.callParent([values]); + }, + + cpu: {}, + + column1: [ + { + xtype: 'proxmoxintegerfield', + name: 'sockets', + minValue: 1, + maxValue: 4, + value: '1', + fieldLabel: gettext('Sockets'), + allowBlank: false, + bind: { + value: '{socketCount}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'cores', + minValue: 1, + maxValue: 256, + value: '1', + fieldLabel: gettext('Cores'), + allowBlank: false, + bind: { + value: '{coreCount}', + }, + }, + ], + + column2: [ + { + xtype: 'CPUModelSelector', + name: 'cputype', + reference: 'cputype', + fieldLabel: gettext('Type'), + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Total cores'), + name: 'totalcores', + isFormField: false, + bind: { + value: '{totalCoreCount}', + }, + }, + ], + + columnB: [ + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'WARNING: You do not have permission to configure custom CPU types, if you change the type you will not be able to go back!', + ), + hidden: true, + bind: { + hidden: '{!showCustomModelPermWarning}', + }, + }, + ], + + advancedColumn1: [ + { + xtype: 'proxmoxintegerfield', + name: 'vcpus', + minValue: 1, + maxValue: 1, + value: '', + fieldLabel: gettext('VCPUs'), + deleteEmpty: true, + allowBlank: true, + emptyText: '1', + bind: { + emptyText: '{totalCoreCount}', + maxValue: '{totalCoreCount}', + }, + }, + { + xtype: 'numberfield', + name: 'cpulimit', + minValue: 0, + maxValue: 128, // api maximum + value: '', + step: 1, + fieldLabel: gettext('CPU limit'), + allowBlank: true, + emptyText: gettext('unlimited'), + }, + { + xtype: 'proxmoxtextfield', + name: 'affinity', + vtype: 'CpuSet', + value: '', + fieldLabel: gettext('CPU Affinity'), + allowBlank: true, + emptyText: gettext('All Cores'), + deleteEmpty: true, + bind: { + disabled: '{!userIsRoot}', + }, + }, + ], + + advancedColumn2: [ + { + xtype: 'proxmoxintegerfield', + name: 'cpuunits', + fieldLabel: gettext('CPU units'), + minValue: '1', + maxValue: '10000', + value: '', + emptyText: '100', + bind: { + minValue: '{cpuunitsMin}', + maxValue: '{cpuunitsMax}', + emptyText: '{cpuunitsDefault}', + }, + deleteEmpty: true, + allowBlank: true, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enable NUMA'), + name: 'numa', + uncheckedValue: 0, + }, + ], + advancedColumnB: [ + { + xtype: 'label', + text: 'Extra CPU Flags:', + }, + { + xtype: 'vmcpuflagselector', + name: 'flags', + }, + ], +}); + +Ext.define('PVE.qemu.ProcessorEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveQemuProcessorEdit', + + width: 700, + + viewModel: { + data: { + cgroupMode: 2, + }, + }, + + initComponent: function () { + let me = this; + me.getViewModel().set('cgroupMode', me.cgroupMode); + + var ipanel = Ext.create('PVE.qemu.ProcessorInputPanel'); + + Ext.apply(me, { + subject: gettext('Processors'), + items: ipanel, + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + var data = response.result.data; + var value = data.cpu; + if (value) { + let cpu = PVE.Parser.parseQemuCpu(value); + ipanel.cpu = cpu; + data.cputype = cpu.cputype; + if (cpu.flags) { + data.flags = cpu.flags; + } + + let caps = Ext.state.Manager.get('GuiCap'); + if (data.cputype.indexOf('custom-') === 0 && !caps.nodes['Sys.Audit']) { + let vm = ipanel.getViewModel(); + vm.set('showCustomModelPermWarning', true); + } + } + me.setValues(data); + }, + }); + }, +}); +Ext.define('PVE.qemu.BiosEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveQemuBiosEdit', + + onlineHelp: 'qm_bios_and_uefi', + subject: 'BIOS', + autoLoad: true, + + viewModel: { + data: { + bios: '__default__', + efidisk0: false, + }, + formulas: { + showEFIDiskHint: (get) => get('bios') === 'ovmf' && !get('efidisk0'), + }, + }, + + items: [ + { + xtype: 'pveQemuBiosSelector', + onlineHelp: 'qm_bios_and_uefi', + name: 'bios', + value: '__default__', + bind: '{bios}', + fieldLabel: 'BIOS', + }, + { + xtype: 'displayfield', + name: 'efidisk0', + bind: '{efidisk0}', + hidden: true, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'You need to add an EFI disk for storing the EFI settings. See the online help for details.', + ), + bind: { + hidden: '{!showEFIDiskHint}', + }, + }, + ], +}); +Ext.define('PVE.qemu.RNGInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveRNGInputPanel', + + onlineHelp: 'qm_virtio_rng', + + onGetValues: function (values) { + if (values.max_bytes === '') { + values.max_bytes = '0'; + } else if (values.max_bytes === '1024' && values.period === '') { + delete values.max_bytes; + } + + var ret = PVE.Parser.printPropertyString(values); + + return { + rng0: ret, + }; + }, + + setValues: function (values) { + if (values.max_bytes === 0) { + values.max_bytes = null; + } + + this.callParent(arguments); + }, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + '#max_bytes': { + change: function (el, newVal) { + let limitWarning = this.lookupReference('limitWarning'); + limitWarning.setHidden(!!newVal); + }, + }, + }, + }, + + items: [ + { + itemId: 'source', + name: 'source', + xtype: 'proxmoxKVComboBox', + value: '/dev/urandom', + fieldLabel: gettext('Entropy source'), + labelWidth: 130, + comboItems: [ + ['/dev/urandom', '/dev/urandom'], + ['/dev/random', '/dev/random'], + ['/dev/hwrng', '/dev/hwrng'], + ], + }, + { + xtype: 'numberfield', + itemId: 'max_bytes', + name: 'max_bytes', + minValue: 0, + step: 1, + value: 1024, + fieldLabel: gettext('Limit (Bytes/Period)'), + labelWidth: 130, + emptyText: gettext('unlimited'), + }, + { + xtype: 'numberfield', + name: 'period', + minValue: 1, + step: 1, + fieldLabel: gettext('Period') + ' (ms)', + labelWidth: 130, + emptyText: '1000', + }, + { + xtype: 'displayfield', + reference: 'limitWarning', + value: gettext( + 'Disabling the limiter can potentially allow a guest to overload the host. Proceed with caution.', + ), + userCls: 'pmx-hint', + hidden: true, + }, + ], +}); + +Ext.define('PVE.qemu.RNGEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('VirtIO RNG'), + + items: [ + { + xtype: 'pveRNGInputPanel', + }, + ], + + initComponent: function () { + var me = this; + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response) { + me.vmconfig = response.result.data; + + var rng0 = me.vmconfig.rng0; + if (rng0) { + me.setValues(PVE.Parser.parsePropertyString(rng0)); + } + }, + }); + } + }, +}); +Ext.define('PVE.qemu.SSHKeyInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveQemuSSHKeyInputPanel', + + insideWizard: false, + + onGetValues: function (values) { + var _me = this; + if (values.sshkeys) { + values.sshkeys.trim(); + } + if (!values.sshkeys.length) { + values = {}; + values.delete = 'sshkeys'; + return values; + } else { + values.sshkeys = encodeURIComponent(values.sshkeys); + } + return values; + }, + + items: [ + { + xtype: 'textarea', + itemId: 'sshkeys', + name: 'sshkeys', + height: 250, + }, + { + xtype: 'filebutton', + itemId: 'filebutton', + name: 'file', + text: gettext('Load SSH Key File'), + fieldLabel: 'test', + listeners: { + change: function (btn, e, value) { + let view = this.up('inputpanel'); + e = e.event; + Ext.Array.each(e.target.files, function (file) { + PVE.Utils.loadSSHKeyFromFile(file, function (res) { + let keysField = view.down('#sshkeys'); + var old = keysField.getValue(); + keysField.setValue(old + res); + }); + }); + btn.reset(); + }, + }, + }, + ], + + initComponent: function () { + var me = this; + + me.callParent(); + if (!window.FileReader) { + me.down('#filebutton').setVisible(false); + } + }, +}); + +Ext.define('PVE.qemu.SSHKeyEdit', { + extend: 'Proxmox.window.Edit', + + width: 800, + + initComponent: function () { + var me = this; + + var ipanel = Ext.create('PVE.qemu.SSHKeyInputPanel'); + + Ext.apply(me, { + subject: gettext('SSH Keys'), + items: [ipanel], + }); + + me.callParent(); + + if (!me.create) { + me.load({ + success: function (response, options) { + var data = response.result.data; + if (data.sshkeys) { + data.sshkeys = decodeURIComponent(data.sshkeys); + ipanel.setValues(data); + } + }, + }); + } + }, +}); +Ext.define('PVE.qemu.ScsiHwEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + Ext.applyIf(me, { + subject: gettext('SCSI Controller Type'), + items: { + xtype: 'pveScsiHwSelector', + name: 'scsihw', + value: '__default__', + fieldLabel: gettext('Type'), + }, + }); + + me.callParent(); + + me.load(); + }, +}); +Ext.define('PVE.qemu.SerialnputPanel', { + extend: 'Proxmox.panel.InputPanel', + + autoComplete: false, + + setVMConfig: function (vmconfig) { + var me = this, + i; + me.vmconfig = vmconfig; + + for (i = 0; i < 4; i++) { + let port = 'serial' + i.toString(); + if (!me.vmconfig[port]) { + me.down('field[name=serialid]').setValue(i); + break; + } + } + }, + + onGetValues: function (values) { + var _me = this; + + var id = 'serial' + values.serialid; + delete values.serialid; + values[id] = 'socket'; + return values; + }, + + items: [ + { + xtype: 'proxmoxintegerfield', + name: 'serialid', + fieldLabel: gettext('Serial Port'), + minValue: 0, + maxValue: 3, + allowBlank: false, + validator: function (id) { + if (!this.rendered) { + return true; + } + let view = this.up('panel'); + if (view.vmconfig !== undefined && Ext.isDefined(view.vmconfig['serial' + id])) { + return 'This device is already in use.'; + } + return true; + }, + }, + ], +}); + +Ext.define('PVE.qemu.SerialEdit', { + extend: 'Proxmox.window.Edit', + + vmconfig: undefined, + + isAdd: true, + + subject: gettext('Serial Port'), + + initComponent: function () { + var me = this; + + // for now create of (socket) serial port only + me.isCreate = true; + + var ipanel = Ext.create('PVE.qemu.SerialnputPanel', {}); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + ipanel.setVMConfig(response.result.data); + }, + }); + }, +}); +Ext.define('PVE.qemu.SevInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveSevInputPanel', + + onlineHelp: 'qm_memory', // TODO: change to 'qm_memory_encryption' one available + + viewModel: { + data: { + type: '__default__', + }, + formulas: { + sevEnabled: (get) => + get('type') === 'std' || get('type') === 'es' || get('type') === 'snp', + snpEnabled: (get) => get('type') === 'snp', + }, + }, + + onGetValues: function (values) { + if (values.delete === 'type') { + values.delete = 'amd-sev'; + return values; + } + if (!values.debug) { + values['no-debug'] = 1; + } + if (!values.smt && values.type === 'snp') { + values['allow-smt'] = 0; + } + if (!values['key-sharing'] && values.type !== 'snp') { + values['no-key-sharing'] = 1; + } + delete values.debug; + delete values.smt; + delete values['key-sharing']; + let ret = {}; + ret['amd-sev'] = PVE.Parser.printPropertyString(values, 'type'); + return ret; + }, + + setValues: function (values) { + if (PVE.Parser.parseBoolean(values['no-debug'])) { + values.debug = 0; + } + values.smt = PVE.Parser.parseBoolean(values['allow-smt'], 1); + if (PVE.Parser.parseBoolean(values['no-key-sharing'])) { + values['key-sharing'] = 0; + } + this.callParent(arguments); + }, + + items: [ + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('AMD SEV Type'), + labelWidth: 150, + name: 'type', + value: '__default__', + comboItems: [ + [ + '__default__', + Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', + ], + ['std', 'AMD SEV'], + ['es', 'AMD SEV-ES'], + ['snp', 'AMD SEV-SNP'], + ], + bind: { + value: '{type}', + }, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('WARNING: When using SEV-SNP no EFI disk is loaded as pflash.'), + bind: { + hidden: '{!snpEnabled}', + }, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('Note: SEV-SNP requires host kernel version 6.11 or higher.'), + bind: { + hidden: '{!snpEnabled}', + }, + }, + ], + + advancedItems: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Allow Debugging'), + labelWidth: 150, + name: 'debug', + value: 1, + bind: { + hidden: '{!sevEnabled}', + disabled: '{!sevEnabled}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Allow Key-Sharing'), + labelWidth: 150, + name: 'key-sharing', + value: 1, + bind: { + hidden: '{!sevEnabled || snpEnabled}', + disabled: '{!sevEnabled || snpEnabled}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Allow SMT'), + labelWidth: 150, + name: 'smt', + value: 1, + bind: { + hidden: '{!snpEnabled}', + disabled: '{!snpEnabled}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enable Kernel Hashes'), + labelWidth: 150, + name: 'kernel-hashes', + deleteDefaultValue: false, + bind: { + hidden: '{!sevEnabled}', + disabled: '{!sevEnabled}', + }, + }, + ], +}); + +Ext.define('PVE.qemu.SevEdit', { + extend: 'Proxmox.window.Edit', + + subject: 'AMD Secure Encrypted Virtualization (SEV)', + + items: { + xtype: 'pveSevInputPanel', + }, + + width: 400, + + initComponent: function () { + let me = this; + + me.callParent(); + + me.load({ + success: function (response) { + let conf = response.result.data; + let amd_sev = conf['amd-sev'] || '__default__'; + me.setValues(PVE.Parser.parsePropertyString(amd_sev, 'type')); + }, + }); + }, +}); +Ext.define('PVE.qemu.Smbios1InputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.PVE.qemu.Smbios1InputPanel', + + insideWizard: false, + + smbios1: {}, + + onGetValues: function (values) { + var _me = this; + + var params = { + smbios1: PVE.Parser.printQemuSmbios1(values), + }; + + return params; + }, + + setSmbios1: function (data) { + var me = this; + + me.smbios1 = data; + + me.setValues(me.smbios1); + }, + + items: [ + { + xtype: 'textfield', + fieldLabel: 'UUID', + regex: /^[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$/, + name: 'uuid', + }, + { + xtype: 'textareafield', + fieldLabel: gettext('Manufacturer'), + fieldStyle: { + height: '2em', + minHeight: '2em', + }, + name: 'manufacturer', + }, + { + xtype: 'textareafield', + fieldLabel: gettext('Product'), + fieldStyle: { + height: '2em', + minHeight: '2em', + }, + name: 'product', + }, + { + xtype: 'textareafield', + fieldLabel: gettext('Version'), + fieldStyle: { + height: '2em', + minHeight: '2em', + }, + name: 'version', + }, + { + xtype: 'textareafield', + fieldLabel: gettext('Serial'), + fieldStyle: { + height: '2em', + minHeight: '2em', + }, + name: 'serial', + }, + { + xtype: 'textareafield', + fieldLabel: 'SKU', + fieldStyle: { + height: '2em', + minHeight: '2em', + }, + name: 'sku', + }, + { + xtype: 'textareafield', + fieldLabel: gettext('Family'), + fieldStyle: { + height: '2em', + minHeight: '2em', + }, + name: 'family', + }, + ], +}); + +Ext.define('PVE.qemu.Smbios1Edit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + var ipanel = Ext.create('PVE.qemu.Smbios1InputPanel', {}); + + Ext.applyIf(me, { + subject: gettext('SMBIOS settings (type1)'), + width: 450, + items: ipanel, + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + me.vmconfig = response.result.data; + var value = me.vmconfig.smbios1; + if (value) { + let data = PVE.Parser.parseQemuSmbios1(value); + if (!data) { + Ext.Msg.alert(gettext('Error'), 'Unable to parse smbios options'); + me.close(); + return; + } + ipanel.setSmbios1(data); + } + }, + }); + }, +}); +Ext.define('PVE.qemu.SystemInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveQemuSystemPanel', + + onlineHelp: 'qm_system_settings', + + viewModel: { + data: { + efi: false, + addefi: true, + }, + + formulas: { + efidisk: function (get) { + return get('efi') && get('addefi'); + }, + }, + }, + + onGetValues: function (values) { + if (values.vga && values.vga.substr(0, 6) === 'serial') { + values['serial' + values.vga.substr(6, 1)] = 'socket'; + } + + delete values.hdimage; + delete values.hdstorage; + delete values.diskformat; + + delete values.preEnrolledKeys; // efidisk + delete values.version; // tpmstate + + return values; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + scsihwChange: function (field, value) { + var me = this; + if (me.getView().insideWizard) { + me.getViewModel().set('current.scsihw', value); + } + }, + + biosChange: function (field, value) { + var me = this; + if (me.getView().insideWizard) { + me.getViewModel().set('efi', value === 'ovmf'); + } + }, + + control: { + pveScsiHwSelector: { + change: 'scsihwChange', + }, + pveQemuBiosSelector: { + change: 'biosChange', + }, + '#': { + afterrender: 'setMachine', + }, + }, + + setMachine: function () { + let me = this; + let vm = this.getViewModel(); + let ostype = vm.get('current.ostype'); + if (ostype === 'win11') { + me.lookup('machine').setValue('q35'); + me.lookup('bios').setValue('ovmf'); + me.lookup('addtpmbox').setValue(true); + } + }, + }, + + column1: [ + { + xtype: 'proxmoxKVComboBox', + value: '__default__', + deleteEmpty: false, + fieldLabel: gettext('Graphic card'), + name: 'vga', + comboItems: Object.entries(PVE.Utils.kvm_vga_drivers), + }, + { + xtype: 'proxmoxKVComboBox', + name: 'machine', + reference: 'machine', + value: '__default__', + fieldLabel: gettext('Machine'), + comboItems: [ + ['__default__', PVE.Utils.render_qemu_machine('')], + ['q35', 'q35'], + ], + }, + { + xtype: 'displayfield', + value: gettext('Firmware'), + }, + { + xtype: 'pveQemuBiosSelector', + name: 'bios', + reference: 'bios', + value: '__default__', + fieldLabel: 'BIOS', + }, + { + xtype: 'proxmoxcheckbox', + bind: { + value: '{addefi}', + hidden: '{!efi}', + disabled: '{!efi}', + }, + hidden: true, + submitValue: false, + disabled: true, + fieldLabel: gettext('Add EFI Disk'), + }, + { + xtype: 'pveEFIDiskInputPanel', + name: 'efidisk0', + storageContent: 'images', + bind: { + nodename: '{nodename}', + hidden: '{!efi}', + disabled: '{!efidisk}', + }, + autoSelect: false, + disabled: true, + hidden: true, + hideSize: true, + usesEFI: true, + }, + ], + + column2: [ + { + xtype: 'pveScsiHwSelector', + name: 'scsihw', + value: '__default__', + bind: { + value: '{current.scsihw}', + }, + fieldLabel: gettext('SCSI Controller'), + }, + { + xtype: 'proxmoxcheckbox', + name: 'agent', + uncheckedValue: 0, + defaultValue: 0, + deleteDefaultValue: true, + fieldLabel: gettext('Qemu Agent'), + }, + { + // fake for spacing + xtype: 'displayfield', + value: ' ', + }, + { + xtype: 'proxmoxcheckbox', + reference: 'addtpmbox', + bind: { + value: '{addtpm}', + }, + submitValue: false, + fieldLabel: gettext('Add TPM'), + }, + { + xtype: 'pveTPMDiskInputPanel', + name: 'tpmstate0', + storageContent: 'images', + bind: { + nodename: '{nodename}', + hidden: '{!addtpm}', + disabled: '{!addtpm}', + }, + disabled: true, + hidden: true, + }, + ], +}); +Ext.define('PVE.qemu.TdxInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveTdxInputPanel', + + onlineHelp: 'qm_memory', // TODO: change to 'qm_memory_encryption' one available + + viewModel: { + data: { + type: '__default__', + attestation: 1, + }, + formulas: { + tdxEnabled: (get) => get('type') === 'tdx', + attestationEnabled: (get) => Number(get('attestation')) === 1, + }, + }, + + onGetValues: function (values) { + if (values.delete === 'type') { + values.delete = 'intel-tdx'; + return values; + } + let ret = {}; + ret['intel-tdx'] = PVE.Parser.printPropertyString(values, 'type'); + return ret; + }, + + setValues: function (values) { + this.callParent(arguments); + }, + + items: [ + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Intel TDX Type'), + labelWidth: 150, + name: 'type', + value: '__default__', + comboItems: [ + [ + '__default__', + Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', + ], + ['tdx', 'Intel TDX'], + ], + bind: { + value: '{type}', + }, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('WARNING: When using Intel TDX no EFI disk is loaded as pflash.'), + bind: { + hidden: '{!tdxEnabled}', + }, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'Note: Intel TDX is only supported by specific recent CPU models and requires host kernel version 6.16 or higher.', + ), + bind: { + hidden: '{!tdxEnabled}', + }, + }, + ], + + advancedItems: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enable Attestation'), + labelWidth: 150, + name: 'attestation', + value: 1, + uncheckedValue: 0, + bind: { + value: '{attestation}', + hidden: '{!tdxEnabled}', + disabled: '{!tdxEnabled}', + }, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('CID'), + labelWidth: 150, + name: 'vsock-cid', + minValue: 2, + value: '2', + allowBlank: false, + bind: { + hidden: '{!tdxEnabled}', + disabled: '{!attestationEnabled || !tdxEnabled}', + }, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('Port'), + labelWidth: 150, + name: 'vsock-port', + minValue: 0, + value: '4050', + allowBlank: false, + bind: { + hidden: '{!tdxEnabled}', + disabled: '{!attestationEnabled || !tdxEnabled}', + }, + }, + ], +}); + +Ext.define('PVE.qemu.TdxEdit', { + extend: 'Proxmox.window.Edit', + + subject: 'Intel Trust Domain Extension (TDX)', + + items: { + xtype: 'pveTdxInputPanel', + }, + + width: 400, + + initComponent: function () { + let me = this; + + me.callParent(); + + me.load({ + success: function (response) { + let conf = response.result.data; + let intel_tdx = conf['intel-tdx'] || '__default__'; + me.setValues(PVE.Parser.parsePropertyString(intel_tdx, 'type')); + }, + }); + }, +}); +Ext.define('PVE.qemu.USBInputPanel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + autoComplete: false, + onlineHelp: 'qm_usb_passthrough', + + cbindData: function (initialConfig) { + let me = this; + if (!me.pveSelNode) { + throw 'no pveSelNode given'; + } + + return { nodename: me.pveSelNode.data.node }; + }, + + viewModel: { + data: {}, + }, + + setVMConfig: function (vmconfig) { + var me = this; + me.vmconfig = vmconfig; + let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine); + if (max_usb > PVE.Utils.hardware_counts.usb_old) { + me.down('field[name=usb3]').setDisabled(true); + } + }, + + onGetValues: function (values) { + var me = this; + if (!me.confid) { + let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine); + for (let i = 0; i < max_usb; i++) { + let id = 'usb' + i.toString(); + if (!me.vmconfig[id]) { + me.confid = id; + break; + } + } + } + var val = ''; + var type = me.down('radiofield').getGroupValue(); + switch (type) { + case 'spice': + val = 'spice'; + break; + case 'mapped': + val = `mapping=${values[type]}`; + delete values.mapped; + break; + case 'hostdevice': + case 'port': + val = 'host=' + values[type]; + delete values[type]; + break; + default: + throw 'invalid type selected'; + } + + if (values.usb3) { + delete values.usb3; + val += ',usb3=1'; + } + values[me.confid] = val; + return values; + }, + + items: [ + { + xtype: 'fieldcontainer', + defaultType: 'radiofield', + layout: 'fit', + items: [ + { + name: 'usb', + inputValue: 'spice', + boxLabel: gettext('Spice Port'), + submitValue: false, + checked: true, + }, + { + name: 'usb', + inputValue: 'mapped', + boxLabel: gettext('Use mapped Device'), + reference: 'mapped', + submitValue: false, + }, + { + xtype: 'pveUSBMapSelector', + disabled: true, + name: 'mapped', + cbind: { nodename: '{nodename}' }, + bind: { disabled: '{!mapped.checked}' }, + allowBlank: false, + fieldLabel: gettext('Choose Device'), + labelAlign: 'right', + }, + { + name: 'usb', + inputValue: 'hostdevice', + boxLabel: gettext('Use USB Vendor/Device ID'), + reference: 'hostdevice', + submitValue: false, + }, + { + xtype: 'pveUSBSelector', + disabled: true, + type: 'device', + name: 'hostdevice', + cbind: { pveSelNode: '{pveSelNode}' }, + bind: { disabled: '{!hostdevice.checked}' }, + editable: true, + allowBlank: false, + fieldLabel: gettext('Choose Device'), + labelAlign: 'right', + }, + { + name: 'usb', + inputValue: 'port', + boxLabel: gettext('Use USB Port'), + reference: 'port', + submitValue: false, + }, + { + xtype: 'pveUSBSelector', + disabled: true, + name: 'port', + cbind: { pveSelNode: '{pveSelNode}' }, + bind: { disabled: '{!port.checked}' }, + editable: true, + type: 'port', + allowBlank: false, + fieldLabel: gettext('Choose Port'), + labelAlign: 'right', + }, + { + xtype: 'checkbox', + name: 'usb3', + inputValue: true, + checked: true, + reference: 'usb3', + fieldLabel: gettext('Use USB3'), + }, + ], + }, + ], +}); + +Ext.define('PVE.qemu.USBEdit', { + extend: 'Proxmox.window.Edit', + + vmconfig: undefined, + + isAdd: true, + width: 400, + subject: gettext('USB Device'), + + initComponent: function () { + var me = this; + + me.isCreate = !me.confid; + + var ipanel = Ext.create('PVE.qemu.USBInputPanel', { + confid: me.confid, + pveSelNode: me.pveSelNode, + }); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + + me.load({ + success: function (response, options) { + ipanel.setVMConfig(response.result.data); + if (me.isCreate) { + return; + } + + let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'host'); + let port, + hostdevice, + mapped, + usb3 = false; + let usb; + + if (data.host) { + if (/^(0x)?[a-zA-Z0-9]{4}:(0x)?[a-zA-Z0-9]{4}$/.test(data.host)) { + hostdevice = data.host.replace('0x', ''); + usb = 'hostdevice'; + } else if (/^(\d+)-(\d+(\.\d+)*)$/.test(data.host)) { + port = data.host; + usb = 'port'; + } else if (/^spice$/i.test(data.host)) { + usb = 'spice'; + } + } else if (data.mapping) { + mapped = data.mapping; + usb = 'mapped'; + } + + usb3 = data.usb3 ?? false; + + var values = { + usb, + hostdevice, + port, + usb3, + mapped, + }; + + ipanel.setValues(values); + }, + }); + }, +}); +Ext.define('PVE.qemu.VirtiofsInputPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveVirtiofsInputPanel', + onlineHelp: 'qm_virtiofs', + + insideWizard: false, + + onGetValues: function (values) { + var me = this; + var confid = me.confid; + var params = {}; + delete values.delete; + params[confid] = PVE.Parser.printPropertyString(values, 'dirid'); + return params; + }, + + setSharedfiles: function (confid, data) { + var me = this; + me.confid = confid; + me.virtiofs = data; + me.setValues(me.virtiofs); + }, + initComponent: function () { + let me = this; + + me.nodename = me.pveSelNode.data.node; + if (!me.nodename) { + throw 'no node name specified'; + } + me.items = [ + { + xtype: 'pveDirMapSelector', + name: 'dirid', + fieldLabel: gettext('Directory ID'), + emptyText: gettext('Mapping ID'), + nodename: me.nodename, + allowBlank: false, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'Directory Mappings can be managed under Datacenter -> Directory Mappings', + ), + }, + ]; + me.advancedItems = [ + { + xtype: 'proxmoxKVComboBox', + name: 'cache', + fieldLabel: gettext('Cache'), + value: '__default__', + deleteDefaultValue: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (auto)'], + ['auto', 'auto'], + ['always', 'always'], + ['metadata', 'metadata'], + ['never', 'never'], + ], + }, + { + xtype: 'proxmoxcheckbox', + name: 'expose-xattr', + fieldLabel: gettext('xattr Support'), + boxLabel: gettext('Enable support for extended attributes.'), + }, + { + xtype: 'proxmoxcheckbox', + name: 'expose-acl', + fieldLabel: gettext('POSIX ACLs'), + boxLabel: gettext('Implies xattr support.'), + listeners: { + change: function (f, value) { + let xattr = me.down('field[name=expose-xattr]'); + xattr.setDisabled(value); + xattr.setValue(value); + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'direct-io', + fieldLabel: gettext('Allow Direct IO'), + }, + ]; + + me.virtiofs = {}; + me.confid = 'virtiofs0'; + me.callParent(); + }, +}); + +Ext.define('PVE.qemu.VirtiofsEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('Virtiofs Filesystem Passthrough'), + width: 450, + + initComponent: function () { + var me = this; + + me.isCreate = !me.confid; + + var ipanel = Ext.create('PVE.qemu.VirtiofsInputPanel', { + confid: me.confid, + pveSelNode: me.pveSelNode, + isCreate: me.isCreate, + }); + + Ext.applyIf(me, { + items: ipanel, + }); + + me.callParent(); + + me.load({ + success: function (response) { + me.conf = response.result.data; + var i, confid; + if (!me.isCreate) { + let value = me.conf[me.confid]; + let virtiofs = PVE.Parser.parsePropertyString(value, 'dirid'); + if (!virtiofs) { + Ext.Msg.alert(gettext('Error'), 'Unable to parse virtiofs options'); + me.close(); + return; + } + ipanel.setSharedfiles(me.confid, virtiofs); + } else { + for (i = 0; i < PVE.Utils.hardware_counts.virtiofs; i++) { + confid = 'virtiofs' + i.toString(); + if (!Ext.isDefined(me.conf[confid])) { + me.confid = confid; + break; + } + } + ipanel.setSharedfiles(me.confid, {}); + } + }, + }); + }, +}); +Ext.define('PVE.sdn.Browser', { + extend: 'PVE.panel.Config', + alias: 'widget.PVE.sdn.Browser', + + onlineHelp: 'chapter_pvesdn', + + initComponent: function () { + let me = this; + + let nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + let sdnId = me.pveSelNode.data.sdn; + if (!sdnId) { + throw 'no sdn ID specified'; + } + + me.items = []; + + Ext.apply(me, { + title: Ext.String.format( + gettext('Zone {0} on node {1}'), + `'${sdnId}'`, + `'${nodename}'`, + ), + hstateid: 'sdntab', + }); + + const caps = Ext.state.Manager.get('GuiCap'); + + me.items.push({ + nodename: nodename, + zone: sdnId, + xtype: 'pveSDNZoneContentPanel', + title: gettext('Content'), + iconCls: 'fa fa-th', + itemId: 'content', + }); + + if (caps.sdn['Permissions.Modify']) { + me.items.push({ + xtype: 'pveACLView', + title: gettext('Permissions'), + iconCls: 'fa fa-unlock', + itemId: 'permissions', + path: `/sdn/zones/${sdnId}`, + }); + } + + if (me.pveSelNode.data['zone-type'] && me.pveSelNode.data['zone-type'] === 'evpn') { + me.items.push({ + nodename: nodename, + zone: sdnId, + xtype: 'pveSDNEvpnZoneIpVrfPanel', + title: gettext('IP-VRF'), + iconCls: 'fa fa-th-list', + itemId: 'ip-vrf', + }); + + me.items.push({ + nodename: nodename, + zone: sdnId, + xtype: 'pveSDNEvpnZoneMacVrfPanel', + title: gettext('MAC-VRFs'), + iconCls: 'fa fa-th-list', + itemId: 'mac-vrfs', + }); + } + + me.callParent(); + }, +}); +Ext.define('PVE.network.Browser', { + extend: 'PVE.panel.Config', + alias: 'widget.PVE.network.Browser', + + initComponent: function () { + let me = this; + let data = me.pveSelNode.data; + + let node = data.node; + if (!node) { + throw 'no node name specified'; + } + + let name = data.network; + if (!name) { + throw 'no name specified'; + } + + let networkType = data['network-type']; + if (!networkType) { + throw 'no type specified'; + } + + me.items = []; + + if (networkType === 'fabric') { + me.onlineHelp = 'pvesdn_config_fabrics'; + + me.items.push({ + nodename: node, + fabricId: name, + protocol: me.pveSelNode.data.protocol, + xtype: 'pveSDNFabricRoutesContentView', + title: gettext('Routes'), + iconCls: 'fa fa-exchange', + itemId: 'routes', + width: '100%', + store: { + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${node}/sdn/fabrics/${name}/routes`, + reader: { + type: 'json', + rootProperty: 'data', + }, + }, + autoLoad: true, + }, + }); + + me.items.push({ + nodename: node, + fabricId: name, + protocol: me.pveSelNode.data.protocol, + xtype: 'pveSDNFabricNeighborsContentView', + title: gettext('Neighbors'), + iconCls: 'fa fa-handshake-o', + itemId: 'neighbors', + width: '100%', + store: { + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${node}/sdn/fabrics/${name}/neighbors`, + reader: { + type: 'json', + rootProperty: 'data', + }, + }, + autoLoad: true, + }, + }); + + me.items.push({ + nodename: node, + fabricId: name, + protocol: me.pveSelNode.data.protocol, + xtype: 'pveSDNFabricInterfacesContentView', + title: gettext('Interfaces'), + iconCls: 'fa fa-upload', + itemId: 'interfaces', + width: '100%', + store: { + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${node}/sdn/fabrics/${name}/interfaces`, + reader: { + type: 'json', + rootProperty: 'data', + }, + }, + autoLoad: true, + }, + }); + } else if (networkType === 'zone') { + const caps = Ext.state.Manager.get('GuiCap'); + + me.items.push({ + nodename: node, + zone: name, + xtype: 'pveSDNZoneContentPanel', + title: gettext('Content'), + iconCls: 'fa fa-th', + itemId: 'content', + }); + + if (caps.sdn['Permissions.Modify']) { + me.items.push({ + xtype: 'pveACLView', + title: gettext('Permissions'), + iconCls: 'fa fa-unlock', + itemId: 'permissions', + path: `/sdn/zones/${name}`, + }); + } + + me.items.push({ + nodename: node, + zone: name, + xtype: 'pveSDNZoneBridgePanel', + title: gettext('Bridges'), + iconCls: 'fa fa-network-wired x-fa-sdn-treelist', + itemId: 'bridges', + }); + + if (data['zone-type'] && data['zone-type'] === 'evpn') { + me.items.push({ + nodename: node, + zone: name, + xtype: 'pveSDNEvpnZoneIpVrfPanel', + title: gettext('IP-VRF'), + iconCls: 'fa fa-th-list', + itemId: 'ip-vrf', + }); + + me.items.push({ + nodename: node, + zone: name, + xtype: 'pveSDNEvpnZoneMacVrfPanel', + title: gettext('MAC-VRFs'), + iconCls: 'fa fa-th-list', + itemId: 'mac-vrfs', + }); + } + } else { + me.items.push({ + xtype: 'container', + title: gettext('Content'), + iconCls: 'fa fa-th', + itemId: 'content', + html: `unknown network type: ${networkType}`, + width: '100%', + }); + } + + Ext.apply(me, { + title: Ext.String.format( + gettext('{0} {1} on node {2}'), + `${networkType}`, + `'${name}'`, + `'${node}'`, + ), + hstateid: 'networktab', + }); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.ControllerView', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveSDNControllerView'], + + onlineHelp: 'pvesdn_config_controllers', + + stateful: true, + stateId: 'grid-sdn-controller', + + createSDNControllerEditWindow: function (type, sid) { + var schema = PVE.Utils.sdncontrollerSchema[type]; + if (!schema || !schema.ipanel) { + throw 'no editor registered for controller type: ' + type; + } + + Ext.create('PVE.sdn.controllers.BaseEdit', { + paneltype: 'PVE.sdn.controllers.' + schema.ipanel, + type: type, + controllerid: sid, + autoShow: true, + listeners: { + destroy: this.reloadStore, + }, + }); + }, + + initComponent: function () { + var me = this; + + var store = new Ext.data.Store({ + model: 'pve-sdn-controller', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/controllers?pending=1', + }, + sorters: { + property: 'controller', + direction: 'ASC', + }, + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let type = rec.data.type, + controller = rec.data.controller; + me.createSDNControllerEditWindow(type, controller); + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/cluster/sdn/controllers/', + callback: () => store.load(), + }); + + // else we cannot dynamically generate the add menu handlers + let addHandleGenerator = function (type) { + return function () { + me.createSDNControllerEditWindow(type); + }; + }; + let addMenuItems = []; + for (const [type, controller] of Object.entries(PVE.Utils.sdncontrollerSchema)) { + if (controller.hideAdd) { + continue; + } + addMenuItems.push({ + text: PVE.Utils.format_sdncontroller_type(type), + iconCls: 'fa fa-fw fa-' + controller.faIcon, + handler: addHandleGenerator(type), + }); + } + + Ext.apply(me, { + store: store, + reloadStore: () => store.load(), + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + items: addMenuItems, + }), + }, + remove_btn, + edit_btn, + ], + columns: [ + { + header: 'ID', + flex: 2, + sortable: true, + dataIndex: 'controller', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'controller', 1); + }, + }, + { + header: gettext('Type'), + flex: 1, + sortable: true, + dataIndex: 'type', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'type', 1); + }, + }, + { + header: gettext('Node'), + flex: 1, + sortable: true, + dataIndex: 'node', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'node', 1); + }, + }, + { + header: gettext('State'), + width: 100, + dataIndex: 'state', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + ], + listeners: { + activate: () => store.load(), + itemdblclick: run_editor, + }, + }); + store.load(); + me.callParent(); + }, +}); +Ext.define('PVE.sdn.Status', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNStatus', + + onlineHelp: 'chapter_pvesdn', + + layout: { + type: 'vbox', + align: 'stretch', + }, + + initComponent: function () { + var me = this; + + me.rstore = Ext.create('Proxmox.data.ObjectStore', { + interval: me.interval, + model: 'pve-sdn-status', + storeid: 'pve-store-' + ++Ext.idSeed, + groupField: 'type', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/resources', + }, + }); + + me.items = [ + { + xtype: 'pveSDNStatusView', + title: gettext('Status'), + rstore: me.rstore, + border: 0, + collapsible: true, + padding: '0 0 20 0', + }, + ]; + + me.callParent(); + me.on('activate', me.rstore.startUpdate); + }, +}); +Ext.define( + 'PVE.sdn.StatusView', + { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNStatusView', + + sortPriority: { + sdn: 1, + node: 2, + status: 3, + }, + + initComponent: function () { + var me = this; + + if (!me.rstore) { + throw 'no rstore given'; + } + + Proxmox.Utils.monStoreErrors(me, me.rstore); + + var store = Ext.create('Proxmox.data.DiffStore', { + rstore: me.rstore, + sortAfterUpdate: true, + sorters: [ + { + sorterFn: function (rec1, rec2) { + var p1 = me.sortPriority[rec1.data.type]; + var p2 = me.sortPriority[rec2.data.type]; + return p1 !== p2 ? (p1 > p2 ? 1 : -1) : 0; + }, + }, + ], + filters: { + property: 'type', + value: 'network', + operator: '==', + }, + }); + + Ext.apply(me, { + store: store, + stateful: false, + tbar: [ + { + text: gettext('Apply'), + handler: function () { + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.QUESTION, + msg: gettext( + 'Applying pending SDN changes will also apply any pending local node network changes. Proceed?', + ), + buttons: Ext.Msg.YESNO, + callback: function (btn) { + if (btn === 'yes') { + Proxmox.Utils.API2Request({ + url: '/cluster/sdn/', + method: 'PUT', + waitMsgTarget: me, + failure: (response) => + Ext.Msg.alert( + gettext('Error'), + response.htmlStatus, + ), + }); + } + }, + }); + }, + }, + ], + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: 'SDN', + width: 80, + dataIndex: 'network', + }, + { + header: gettext('Node'), + width: 80, + dataIndex: 'node', + }, + { + header: gettext('Type'), + width: 80, + dataIndex: 'network-type', + }, + { + header: gettext('Status'), + width: 80, + flex: 1, + dataIndex: 'status', + }, + ], + }); + + me.callParent(); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + }, + }, + function () { + Ext.define('pve-sdn-status', { + extend: 'Ext.data.Model', + fields: ['id', 'type', 'node', 'status', 'network'], + idProperty: 'id', + }); + }, +); +Ext.define('PVE.sdn.VnetInputPanel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + onGetValues: function (values) { + let me = this; + + if (me.isCreate) { + values.type = 'vnet'; + } + + return values; + }, + + initComponent: function () { + let me = this; + + me.callParent(); + me.setZoneType(undefined); + }, + + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'vnet', + cbind: { + editable: '{isCreate}', + }, + maxLength: 8, + flex: 1, + allowBlank: false, + fieldLabel: gettext('Name'), + }, + { + xtype: 'proxmoxtextfield', + name: 'alias', + fieldLabel: gettext('Alias'), + allowBlank: true, + skipEmptyText: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'pveSDNZoneSelector', + fieldLabel: gettext('Zone'), + name: 'zone', + value: '', + allowBlank: false, + listeners: { + change: function () { + let me = this; + + let record = me.findRecordByValue(me.value); + let zoneType = record?.data?.type; + + let panel = me.up('panel'); + panel.setZoneType(zoneType); + }, + }, + }, + { + xtype: 'proxmoxintegerfield', + itemId: 'sdnVnetTagField', + name: 'tag', + minValue: 1, + maxValue: 16777216, + fieldLabel: gettext('Tag'), + allowBlank: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + advancedItems: [ + { + xtype: 'proxmoxcheckbox', + name: 'isolate-ports', + uncheckedValue: null, + checked: false, + fieldLabel: gettext('Isolate Ports'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxcheckbox', + itemId: 'sdnVnetVlanAwareField', + name: 'vlanaware', + uncheckedValue: null, + checked: false, + fieldLabel: gettext('VLAN Aware'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + + setZoneType: function (zoneType) { + let me = this; + + let tagField = me.down('#sdnVnetTagField'); + if (!zoneType || zoneType === 'simple') { + tagField.setVisible(false); + tagField.setValue(''); + } else { + tagField.setVisible(true); + } + + let vlanField = me.down('#sdnVnetVlanAwareField'); + if (!zoneType || zoneType === 'evpn') { + vlanField.setVisible(false); + vlanField.setValue(''); + } else { + vlanField.setVisible(true); + } + }, +}); + +Ext.define('PVE.sdn.VnetEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('VNet'), + + vnet: undefined, + + width: 350, + + initComponent: function () { + var me = this; + + me.isCreate = me.vnet === undefined; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/sdn/vnets'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/cluster/sdn/vnets/' + me.vnet; + me.method = 'PUT'; + } + + let ipanel = Ext.create('PVE.sdn.VnetInputPanel', { + isCreate: me.isCreate, + }); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + let values = response.result.data; + ipanel.setValues(values); + }, + }); + } + }, +}); +Ext.define('PVE.sdn.VnetView', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNVnetView', + + onlineHelp: 'pvesdn_config_vnet', + emptyText: gettext('No VNet configured.'), + + stateful: true, + stateId: 'grid-sdn-vnet', + + subnetview_panel: undefined, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-vnet', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/vnets?pending=1', + }, + sorters: { + property: 'vnet', + direction: 'ASC', + }, + }); + + let reload = () => store.load(); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + + let win = Ext.create('PVE.sdn.VnetEdit', { + autoShow: true, + onlineHelp: 'pvesdn_config_vnet', + vnet: rec.data.vnet, + }); + win.on('destroy', reload); + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/cluster/sdn/vnets/', + callback: reload, + }); + + let set_button_status = function () { + var rec = me.selModel.getSelection()[0]; + + if (!rec || rec.data.state === 'deleted') { + edit_btn.disable(); + remove_btn.disable(); + } + }; + + Ext.apply(me, { + store: store, + reloadStore: reload, + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [ + { + text: gettext('Create'), + handler: function () { + let win = Ext.create('PVE.sdn.VnetEdit', { + autoShow: true, + onlineHelp: 'pvesdn_config_vnet', + type: 'vnet', + }); + win.on('destroy', reload); + }, + }, + remove_btn, + edit_btn, + ], + columns: [ + { + header: 'ID', + flex: 2, + dataIndex: 'vnet', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'vnet', 1); + }, + }, + { + header: gettext('Alias'), + flex: 1, + dataIndex: 'alias', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'alias'); + }, + }, + { + header: gettext('Zone'), + flex: 1, + dataIndex: 'zone', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'zone'); + }, + }, + { + header: gettext('Tag'), + flex: 1, + dataIndex: 'tag', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'tag'); + }, + }, + { + header: gettext('VLAN Aware'), + flex: 1, + dataIndex: 'vlanaware', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'vlanaware'); + }, + }, + { + header: gettext('State'), + width: 100, + dataIndex: 'state', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + ], + listeners: { + activate: reload, + itemdblclick: run_editor, + selectionchange: set_button_status, + show: reload, + select: function (_sm, rec) { + let url = `/cluster/sdn/vnets/${rec.data.vnet}/subnets`; + me.subnetview_panel.setBaseUrl(url); + }, + deselect: function () { + me.subnetview_panel.setBaseUrl(undefined); + }, + }, + }); + store.load(); + me.callParent(); + }, +}); +Ext.define('PVE.sdn.VnetACLAdd', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveSDNVnetACLAdd'], + + url: '/access/acl', + method: 'PUT', + isAdd: true, + isCreate: true, + + width: 400, + initComponent: function () { + let me = this; + + let items = [ + { + xtype: 'hiddenfield', + name: 'path', + value: me.path, + allowBlank: false, + fieldLabel: gettext('Path'), + }, + ]; + + if (me.aclType === 'group') { + me.subject = gettext('Group Permission'); + items.push({ + xtype: 'pveGroupSelector', + name: 'groups', + fieldLabel: gettext('Group'), + }); + } else if (me.aclType === 'user') { + me.subject = gettext('User Permission'); + items.push({ + xtype: 'pmxUserSelector', + name: 'users', + fieldLabel: gettext('User'), + }); + } else if (me.aclType === 'token') { + me.subject = gettext('API Token Permission'); + items.push({ + xtype: 'pveTokenSelector', + name: 'tokens', + fieldLabel: gettext('API Token'), + }); + } else { + throw 'unknown ACL type'; + } + + items.push({ + xtype: 'pmxRoleSelector', + name: 'roles', + value: 'NoAccess', + fieldLabel: gettext('Role'), + }); + + items.push({ + xtype: 'proxmoxintegerfield', + name: 'vlan', + minValue: 1, + maxValue: 4096, + allowBlank: true, + fieldLabel: 'VLAN', + emptyText: gettext('All'), + }); + + let ipanel = Ext.create('Proxmox.panel.InputPanel', { + items: items, + onlineHelp: 'pveum_permission_management', + onGetValues: function (values) { + if (values.vlan) { + values.path = values.path + '/' + values.vlan; + delete values.vlan; + } + return values; + }, + }); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + }, +}); + +Ext.define( + 'PVE.sdn.VnetACLView', + { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveSDNVnetACLView'], + + onlineHelp: 'chapter_user_management', + + stateful: true, + stateId: 'grid-acls', + + // use fixed path + path: undefined, + + setPath: function (path) { + let me = this; + + me.path = path; + + if (path === undefined) { + me.down('#groupmenu').setDisabled(true); + me.down('#usermenu').setDisabled(true); + me.down('#tokenmenu').setDisabled(true); + } else { + me.down('#groupmenu').setDisabled(false); + me.down('#usermenu').setDisabled(false); + me.down('#tokenmenu').setDisabled(false); + me.store.load(); + } + }, + initComponent: function () { + let me = this; + + let store = Ext.create('Ext.data.Store', { + model: 'pve-acl', + proxy: { + type: 'proxmox', + url: '/api2/json/access/acl', + }, + sorters: { + property: 'path', + direction: 'ASC', + }, + }); + + store.addFilter( + Ext.create('Ext.util.Filter', { + filterFn: (item) => + item.data.path.replace(/(\/sdn\/zones\/(.*)\/(.*))\/[0-9]*$/, '$1') === + me.path, + }), + ); + + let render_ugid = function (ugid, metaData, record) { + if (record.data.type === 'group') { + return '@' + ugid; + } + + return Ext.String.htmlEncode(ugid); + }; + + let render_vlan = function (path, metaData, record) { + let vlan = 'any'; + const match = path.match(/(\/sdn\/zones\/)(.*)\/(.*)\/([0-9]*)$/); + if (match) { + vlan = match[4]; + } + + return Ext.String.htmlEncode(vlan); + }; + + let columns = [ + { + header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'), + flex: 1, + sortable: true, + renderer: render_ugid, + dataIndex: 'ugid', + }, + { + header: gettext('Role'), + flex: 1, + sortable: true, + dataIndex: 'roleid', + }, + { + header: gettext('VLAN'), + flex: 1, + sortable: true, + renderer: render_vlan, + dataIndex: 'path', + }, + ]; + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let remove_btn = new Proxmox.button.Button({ + text: gettext('Remove'), + disabled: true, + selModel: sm, + confirmMsg: gettext('Are you sure you want to remove this entry'), + handler: function (btn, event, rec) { + var params = { + delete: 1, + path: rec.data.path, + roles: rec.data.roleid, + }; + if (rec.data.type === 'group') { + params.groups = rec.data.ugid; + } else if (rec.data.type === 'user') { + params.users = rec.data.ugid; + } else if (rec.data.type === 'token') { + params.tokens = rec.data.ugid; + } else { + throw 'unknown data type'; + } + + Proxmox.Utils.API2Request({ + url: '/access/acl', + params: params, + method: 'PUT', + waitMsgTarget: me, + callback: () => store.load(), + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, + }); + + Proxmox.Utils.monStoreErrors(me, store); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + { + text: gettext('Add'), + menu: { + xtype: 'menu', + items: [ + { + text: gettext('Group Permission'), + disabled: !me.path, + itemId: 'groupmenu', + iconCls: 'fa fa-fw fa-group', + handler: function () { + var win = Ext.create('PVE.sdn.VnetACLAdd', { + aclType: 'group', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + { + text: gettext('User Permission'), + disabled: !me.path, + itemId: 'usermenu', + iconCls: 'fa fa-fw fa-user', + handler: function () { + var win = Ext.create('PVE.sdn.VnetACLAdd', { + aclType: 'user', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + { + text: gettext('API Token Permission'), + disabled: !me.path, + itemId: 'tokenmenu', + iconCls: 'fa fa-fw fa-user-o', + handler: function () { + let win = Ext.create('PVE.sdn.VnetACLAdd', { + aclType: 'token', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + ], + }, + }, + remove_btn, + ], + viewConfig: { + trackOver: false, + }, + columns: columns, + listeners: {}, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-acl-vnet', { + extend: 'Ext.data.Model', + fields: [ + 'path', + 'type', + 'ugid', + 'roleid', + { + name: 'propagate', + type: 'boolean', + }, + ], + }); + }, +); +Ext.define('PVE.sdn.Vnet', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNVnet', + + title: 'VNet', + + onlineHelp: 'pvesdn_config_vnet', + + initComponent: function () { + var me = this; + + var subnetview_panel = Ext.createWidget('pveSDNSubnetView', { + title: gettext('Subnets'), + region: 'center', + border: false, + }); + + var vnetview_panel = Ext.createWidget('pveSDNVnetView', { + title: 'VNets', + region: 'west', + subnetview_panel: subnetview_panel, + width: '50%', + border: false, + split: true, + }); + + Ext.apply(me, { + layout: 'border', + items: [vnetview_panel, subnetview_panel], + listeners: { + show: function () { + subnetview_panel.fireEvent('show', subnetview_panel); + }, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.SubnetInputPanel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + onGetValues: function (values) { + let me = this; + + if (me.isCreate) { + values.type = 'subnet'; + values.subnet = values.cidr; + delete values.cidr; + } + + return values; + }, + + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'cidr', + cbind: { + editable: '{isCreate}', + }, + flex: 1, + allowBlank: false, + fieldLabel: gettext('Subnet'), + }, + { + xtype: 'proxmoxtextfield', + name: 'gateway', + vtype: 'IP64Address', + fieldLabel: gettext('Gateway'), + allowBlank: true, + skipEmptyText: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'snat', + uncheckedValue: null, + checked: false, + fieldLabel: 'SNAT', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'dnszoneprefix', + skipEmptyText: true, + fieldLabel: gettext('DNS Zone Prefix'), + allowBlank: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], +}); + +Ext.define('PVE.sdn.SubnetDhcpRangePanel', { + extend: 'Ext.form.FieldContainer', + mixins: ['Ext.form.field.Field'], + + initComponent: function () { + let me = this; + + me.callParent(); + me.initField(); + }, + + // since value is an array of objects we need to override isEquals here + isEqual: function (value1, value2) { + return JSON.stringify(value1) === JSON.stringify(value2); + }, + + getValue: function () { + let me = this; + let store = me.lookup('grid').getStore(); + + let value = []; + + store.getData().each((item) => { + // needs a deep copy otherwise we run in to ExtJS reference + // shenaningans + value.push({ + 'start-address': item.data['start-address'], + 'end-address': item.data['end-address'], + }); + }); + + return value; + }, + + getSubmitData: function () { + let me = this; + + let data = {}; + + let value = me + .getValue() + .map( + (item) => + `start-address=${item['start-address']},end-address=${item['end-address']}`, + ); + + if (value.length) { + data[me.getName()] = value; + } else if (!me.isCreate) { + data.delete = me.getName(); + } + + return data; + }, + + setValue: function (dhcpRanges) { + let me = this; + let store = me.lookup('grid').getStore(); + + let data = []; + + dhcpRanges.forEach((item) => { + // needs a deep copy otherwise we run in to ExtJS reference + // shenaningans + data.push({ + 'start-address': item['start-address'], + 'end-address': item['end-address'], + }); + }); + + store.setData(data); + }, + + getErrors: function () { + let _me = this; + let errors = []; + + return errors; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + addRange: function () { + let me = this; + me.lookup('grid').getStore().add({}); + + me.getView().checkChange(); + }, + + removeRange: function (field) { + let me = this; + let record = field.getWidgetRecord(); + + me.lookup('grid').getStore().remove(record); + + me.getView().checkChange(); + }, + + onValueChange: function (field, value) { + let me = this; + let record = field.getWidgetRecord(); + let column = field.getWidgetColumn(); + + record.set(column.dataIndex, value); + record.commit(); + + me.getView().checkChange(); + }, + + control: { + 'grid button': { + click: 'removeRange', + }, + field: { + change: 'onValueChange', + }, + }, + }, + + items: [ + { + xtype: 'grid', + reference: 'grid', + scrollable: true, + store: { + fields: ['start-address', 'end-address'], + }, + columns: [ + { + text: gettext('Start Address'), + xtype: 'widgetcolumn', + dataIndex: 'start-address', + flex: 1, + widget: { + xtype: 'textfield', + vtype: 'IP64Address', + }, + }, + { + text: gettext('End Address'), + xtype: 'widgetcolumn', + dataIndex: 'end-address', + flex: 1, + widget: { + xtype: 'textfield', + vtype: 'IP64Address', + }, + }, + { + xtype: 'widgetcolumn', + width: 40, + widget: { + xtype: 'button', + iconCls: 'fa fa-trash-o', + }, + }, + ], + }, + { + xtype: 'container', + layout: { + type: 'hbox', + }, + items: [ + { + xtype: 'button', + text: gettext('Add'), + iconCls: 'fa fa-plus-circle', + handler: 'addRange', + }, + ], + }, + ], +}); + +Ext.define('PVE.sdn.SubnetEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('Subnet'), + + subnet: undefined, + + width: 350, + + base_url: undefined, + + bodyPadding: 0, + + initComponent: function () { + var me = this; + + me.isCreate = me.subnet === undefined; + + if (me.isCreate) { + me.url = me.base_url; + me.method = 'POST'; + } else { + me.url = me.base_url + '/' + me.subnet; + me.method = 'PUT'; + } + + let ipanel = Ext.create('PVE.sdn.SubnetInputPanel', { + isCreate: me.isCreate, + title: gettext('General'), + }); + + let dhcpPanel = Ext.create('PVE.sdn.SubnetDhcpRangePanel', { + isCreate: me.isCreate, + title: gettext('DHCP Ranges'), + name: 'dhcp-range', + }); + + Ext.apply(me, { + items: [ + { + xtype: 'tabpanel', + bodyPadding: 10, + items: [ipanel, dhcpPanel], + }, + ], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + me.setValues(response.result.data); + }, + }); + } + }, +}); +Ext.define( + 'PVE.sdn.SubnetView', + { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNSubnetView', + + stateful: true, + stateId: 'grid-sdn-subnet', + + base_url: undefined, + + remove_btn: undefined, + + setBaseUrl: function (url) { + let me = this; + + me.base_url = url; + + if (url === undefined) { + me.store.removeAll(); + me.create_btn.disable(); + } else { + me.remove_btn.baseurl = url + '/'; + me.store.setProxy({ + type: 'proxmox', + url: '/api2/json/' + url + '?pending=1', + }); + me.create_btn.enable(); + me.store.load(); + } + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-subnet', + }); + + let reload = function () { + store.load(); + }; + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + + let win = Ext.create('PVE.sdn.SubnetEdit', { + autoShow: true, + subnet: rec.data.subnet, + base_url: me.base_url, + }); + win.on('destroy', reload); + }; + + me.create_btn = new Proxmox.button.Button({ + text: gettext('Create'), + disabled: true, + handler: function () { + let win = Ext.create('PVE.sdn.SubnetEdit', { + autoShow: true, + base_url: me.base_url, + type: 'subnet', + }); + win.on('destroy', reload); + }, + }); + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + me.remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: me.base_url + '/', + callback: () => store.load(), + }); + + let set_button_status = function () { + var rec = me.selModel.getSelection()[0]; + + if (!rec || rec.data.state === 'deleted') { + edit_btn.disable(); + me.remove_btn.disable(); + } + }; + + Ext.apply(me, { + store: store, + reloadStore: reload, + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [me.create_btn, me.remove_btn, edit_btn], + columns: [ + { + header: gettext('Subnet'), + flex: 2, + dataIndex: 'cidr', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'cidr', 1); + }, + }, + { + header: gettext('Gateway'), + flex: 1, + dataIndex: 'gateway', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'gateway'); + }, + }, + { + header: 'SNAT', + flex: 1, + dataIndex: 'snat', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'snat'); + }, + }, + { + header: gettext('DNS Prefix'), + flex: 1, + dataIndex: 'dnszoneprefix', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'dnszoneprefix'); + }, + }, + { + header: gettext('State'), + width: 100, + dataIndex: 'state', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + ], + listeners: { + activate: reload, + itemdblclick: run_editor, + selectionchange: set_button_status, + }, + }); + + me.callParent(); + + if (me.base_url) { + me.setBaseUrl(me.base_url); // load + } + }, + }, + function () { + Ext.define('pve-sdn-subnet', { + extend: 'Ext.data.Model', + fields: ['cidr', 'gateway', 'snat'], + idProperty: 'subnet', + }); + }, +); +Ext.define( + 'PVE.sdn.ZoneContentView', + { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNZoneContentView', + + stateful: true, + stateId: 'grid-sdnzone-content', + viewConfig: { + trackOver: false, + loadMask: false, + }, + features: [ + { + ftype: 'grouping', + groupHeaderTpl: '{name} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})', + }, + ], + + sub_panel: null, + + columns: [ + { + header: gettext('VNet'), + width: 100, + sortable: true, + dataIndex: 'vnet', + }, + { + header: gettext('Alias'), + width: 300, + sortable: true, + dataIndex: 'alias', + }, + { + header: gettext('Status'), + width: 100, + sortable: true, + dataIndex: 'status', + }, + { + header: gettext('Details'), + flex: 1, + dataIndex: 'statusmsg', + }, + ], + + on_select: function (selectionModel, record) { + // do nothing by default + }, + + on_deselect: function () { + // do nothing by default + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.zone) { + throw 'no zone ID specified'; + } + + var baseurl = '/nodes/' + me.nodename + '/sdn/zones/' + me.zone + '/content'; + if (me.zone === 'localnetwork') { + baseurl = '/nodes/' + me.nodename + '/network?type=any_local_bridge'; + } + var store = Ext.create('Ext.data.Store', { + model: 'pve-sdnzone-content', + groupField: 'content', + proxy: { + type: 'proxmox', + url: '/api2/json' + baseurl, + }, + sorters: { + property: 'vnet', + direction: 'ASC', + }, + }); + + var reload = function () { + store.load(); + }; + + Proxmox.Utils.monStoreErrors(me, store); + Ext.apply(me, { + store: store, + listeners: { + activate: reload, + show: reload, + select: me.on_select, + deselect: me.on_deselect, + }, + }); + store.load(); + me.callParent(); + }, + }, + function () { + Ext.define('pve-sdnzone-content', { + extend: 'Ext.data.Model', + fields: [ + { + name: 'iface', + convert: function (value, record) { + //map local vmbr to vnet + if (record.data.iface) { + record.data.vnet = record.data.iface; + } + return value; + }, + }, + { + name: 'comments', + convert: function (value, record) { + //map local vmbr comments to vnet alias + if (record.data.comments) { + record.data.alias = record.data.comments; + } + return value; + }, + }, + 'vnet', + 'status', + 'statusmsg', + { + name: 'text', + convert: function (value, record) { + // check for volid, because if you click on a grouping header, + // it calls convert (but with an empty volid) + if (value || record.data.vnet === null) { + return value; + } + return PVE.Utils.format_sdnvnet_type(value, {}, record); + }, + }, + ], + idProperty: 'vnet', + }); + }, +); +Ext.define('PVE.sdn.ZoneContentPanel', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNZoneContentPanel', + + title: 'VNet', + + onlineHelp: 'pvesdn_config_vnet', + + initComponent: function () { + var me = this; + + var permissions_panel = Ext.createWidget('pveSDNVnetACLView', { + title: gettext('VNet Permissions'), + region: 'center', + border: false, + }); + + var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', { + title: 'VNets', + region: 'west', + sub_panel: permissions_panel, + nodename: me.nodename, + zone: me.zone, + width: '50%', + border: false, + split: true, + + on_select: function (_sm, rec) { + let path = `/sdn/zones/${me.zone}/${rec.data.vnet}`; + permissions_panel.setPath(path); + }, + + on_deselect: function () { + permissions_panel.setPath(undefined); + }, + }); + + Ext.apply(me, { + layout: 'border', + items: [vnetview_panel, permissions_panel], + listeners: { + show: function () { + permissions_panel.fireEvent('show', permissions_panel); + }, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.ZoneBridgePanel', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNZoneBridgePanel', + + title: gettext('Bridges'), + onlineHelp: 'pvesdn_zone_plugin_evpn', + + stateful: true, + stateId: 'grid-sdn-zone-bridges', + + initComponent: function () { + var me = this; + let nodename = me.nodename; + + var bridge_ports_panel = Ext.createWidget('pveSDNZoneBridgePortsPanel', { + title: gettext('Bridge Ports'), + region: 'center', + border: false, + }); + + var vnetview_panel = Ext.createWidget('pveSDNZoneBridgeView', { + title: gettext('VNets'), + region: 'west', + nodename: me.nodename, + zone: me.zone, + + width: '50%', + border: false, + split: true, + + on_select: function (_sm, rec) { + let deepCopy = structuredClone(rec.data.ports); + bridge_ports_panel.setPorts(deepCopy, nodename); + }, + + on_deselect: function () { + bridge_ports_panel.clearPorts(); + }, + }); + + Ext.apply(me, { + layout: 'border', + items: [vnetview_panel, bridge_ports_panel], + }); + + me.callParent(); + }, +}); + +Ext.define('ZoneBridgePort', { + extend: 'Ext.data.Model', + fields: ['index', 'name', 'primary_vlan', 'vlans', 'vmid'], +}); + +Ext.define('PVE.sdn.ZoneBridgePortsPanel', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNZoneBridgePortsPanel', + + title: gettext('IP-VRF'), + onlineHelp: 'pvesdn_zone_plugin_evpn', + + stateful: true, + stateId: 'grid-sdn-zone-ports', + + columns: [ + { + text: gettext('Name'), + flex: 2, + sortable: true, + dataIndex: 'name', + }, + { + text: gettext('VMID'), + flex: 1, + sortable: true, + dataIndex: 'vmid', + }, + { + text: gettext('Guest Network Device'), + flex: 1, + sortable: true, + dataIndex: 'index', + }, + { + text: gettext('Primary VLAN'), + flex: 1, + sortable: true, + dataIndex: 'primary_vlan', + }, + { + text: gettext('VLANs'), + flex: 1, + sortable: true, + dataIndex: 'vlans', + }, + ], + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'ZoneBridge', + sorters: [ + { + property: 'vmid', + direction: 'ASC', + }, + { + property: 'index', + direction: 'ASC', + }, + ], + }); + + Ext.apply(me, { + store, + }); + + me.callParent(); + }, + + setPorts: function (ports) { + let me = this; + me.getStore().setData(ports); + }, + + clearPorts: function (ports) { + let me = this; + me.getStore().removeAll(); + }, +}); +Ext.define('ZoneBridge', { + extend: 'Ext.data.Model', + fields: ['name', 'vlan_filtering', 'ports'], +}); + +Ext.define('PVE.sdn.ZoneBridgeView', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNZoneBridgeView', + + stateful: true, + stateId: 'grid-sdnzone-bridges', + + viewConfig: { + trackOver: false, + loadMask: false, + }, + + columns: [ + { + header: gettext('Bridge'), + width: 100, + sortable: true, + dataIndex: 'name', + flex: 1, + }, + { + header: gettext('VLAN-aware'), + width: 300, + sortable: true, + dataIndex: 'vlan_filtering', + flex: 1, + renderer: function (value) { + return value === 1 ? gettext('Yes') : gettext('No'); + }, + }, + ], + + on_select: function (selectionModel, record) { + // do nothing by default + }, + + on_deselect: function () { + // do nothing by default + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + if (!me.zone) { + throw 'no zone ID specified'; + } + + let baseUrl = `/nodes/${me.nodename}/sdn/zones/${me.zone}/bridges`; + + let store = Ext.create('Ext.data.Store', { + model: 'ZoneBridge', + proxy: { + type: 'proxmox', + url: '/api2/json' + baseUrl, + }, + sorters: { + property: 'name', + direction: 'ASC', + }, + }); + + let reload = function () { + store.load(); + }; + + Proxmox.Utils.monStoreErrors(me, store); + Ext.apply(me, { + store: store, + listeners: { + activate: reload, + show: reload, + select: me.on_select, + deselect: me.on_deselect, + }, + }); + store.load(); + me.callParent(); + }, +}); +Ext.define('IpVrfRoute', { + extend: 'Ext.data.Model', + fields: ['ip', 'metric', 'nexthops', 'protocol'], +}); + +Ext.define('PVE.sdn.EvpnZoneIpVrfPanel', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNEvpnZoneIpVrfPanel', + + title: gettext('IP-VRF'), + onlineHelp: 'pvesdn_zone_plugin_evpn', + + stateful: true, + stateId: 'grid-sdn-ip-vrf', + + columns: [ + { + text: gettext('CIDR'), + flex: 2, + sortable: true, + dataIndex: 'ip', + }, + { + text: gettext('Nexthop'), + flex: 3, + dataIndex: 'nexthops', + renderer: (value) => { + if (Ext.isArray(value)) { + return value.join('
    '); + } + return value || ''; + }, + }, + { + text: gettext('Protocol'), + flex: 1, + sortable: true, + dataIndex: 'protocol', + }, + { + text: gettext('Metric'), + flex: 1, + sortable: true, + dataIndex: 'metric', + }, + ], + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'IpVrfRoute', + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/sdn/zones/${me.zone}/ip-vrf`, + reader: { + type: 'json', + rootProperty: 'data', + }, + }, + sorters: [ + { + property: 'ip', + direction: 'ASC', + }, + { + property: 'nexthop', + direction: 'ASC', + }, + { + property: 'metric', + direction: 'ASC', + }, + ], + autoLoad: true, + }); + + Ext.apply(me, { + store, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.EvpnZoneMacVrfPanel', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNEvpnZoneMacVrfPanel', + + title: 'MAC-VRFs', + onlineHelp: 'pvesdn_zone_plugin_evpn', + + initComponent: function () { + var me = this; + let nodename = me.nodename; + + var mac_vrf_panel = Ext.createWidget('pveSDNEvpnZoneMacVrfGridPanel', { + title: gettext('VNet MAC-VRF'), + region: 'center', + border: false, + }); + + var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', { + title: gettext('VNets'), + region: 'west', + sub_panel: mac_vrf_panel, + nodename: me.nodename, + zone: me.zone, + + width: '50%', + border: false, + split: true, + + on_select: function (_sm, rec) { + mac_vrf_panel.setVnet(rec.data.vnet, nodename); + }, + + on_deselect: function () { + mac_vrf_panel.clearVnet(); + }, + }); + + Ext.apply(me, { + layout: 'border', + items: [vnetview_panel, mac_vrf_panel], + }); + + me.callParent(); + }, +}); + +Ext.define('MacVrfRoute', { + extend: 'Ext.data.Model', + fields: ['ip', 'metric', 'nexthops', 'protocol'], +}); + +Ext.define('PVE.sdn.EvpnZoneMacVrfGridPanel', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNEvpnZoneMacVrfGridPanel', + + title: gettext('MAC-VRF'), + + stateful: true, + stateId: 'grid-sdn-mac-vrf', + + columns: [ + { + text: gettext('IP'), + flex: 1, + sortable: true, + dataIndex: 'ip', + }, + { + text: gettext('MAC-Address'), + flex: 1, + sortable: true, + dataIndex: 'mac', + }, + { + text: gettext('Nexthop'), + flex: 1, + dataIndex: 'nexthop', + }, + ], + + clearVnet: function () { + let me = this; + + me.getStore().removeAll(); + }, + + setVnet: function (vnet, node) { + let me = this; + + let store = me.getStore(); + + store.getProxy().setUrl(`/api2/json/nodes/${node}/sdn/vnets/${vnet}/mac-vrf`); + store.load(); + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'MacVrfRoute', + proxy: { + type: 'proxmox', + reader: { + type: 'json', + rootProperty: 'data', + }, + }, + sorters: [ + { + property: 'ip', + direction: 'ASC', + }, + { + property: 'mac', + direction: 'ASC', + }, + { + property: 'nexthop', + direction: 'ASC', + }, + ], + }); + + Ext.apply(me, { + store, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.FirewallPanel', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNFirewall', + + title: 'VNet', + + onlineHelp: 'pvesdn_firewall_integration', + + initComponent: function () { + let me = this; + + let tabPanel = Ext.create('Ext.TabPanel', { + fullscreen: true, + region: 'center', + border: false, + split: true, + disabled: true, + flex: 2, + items: [ + { + xtype: 'pveFirewallRules', + title: gettext('Rules'), + list_refs_url: '/cluster/firewall/refs', + firewall_type: 'vnet', + }, + { + xtype: 'pveFirewallOptions', + title: gettext('Options'), + fwtype: 'vnet', + }, + ], + }); + + let vnetPanel = Ext.createWidget('pveSDNFirewallVnetView', { + title: 'VNets', + region: 'west', + border: false, + split: true, + forceFit: true, + flex: 1, + tabPanel, + }); + + Ext.apply(me, { + layout: 'border', + items: [vnetPanel, tabPanel], + }); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.FirewallVnetView', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNFirewallVnetView', + + stateful: true, + stateId: 'grid-sdn-vnet-firewall', + + tabPanel: undefined, + + emptyText: gettext('No VNet configured.'), + + getRulesPanel: function () { + let me = this; + return me.tabPanel.items.getAt(0); + }, + + getOptionsPanel: function () { + let me = this; + return me.tabPanel.items.getAt(1); + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-vnet', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/vnets', + }, + sorters: { + property: ['zone', 'vnet'], + direction: 'ASC', + }, + }); + + let reload = () => store.load(); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + Ext.apply(me, { + store: store, + reloadStore: reload, + selModel: sm, + viewConfig: { + trackOver: false, + }, + columns: [ + { + header: 'ID', + flex: 1, + dataIndex: 'vnet', + }, + { + header: gettext('Zone'), + flex: 1, + dataIndex: 'zone', + renderer: Ext.htmlEncode, + }, + { + header: gettext('Alias'), + flex: 1, + dataIndex: 'alias', + renderer: Ext.htmlEncode, + }, + ], + listeners: { + activate: reload, + show: reload, + select: function (_sm, rec) { + me.tabPanel.setDisabled(false); + + me.getRulesPanel().setBaseUrl(`/cluster/sdn/vnets/${rec.id}/firewall/rules`); + me.getOptionsPanel().setBaseUrl( + `/cluster/sdn/vnets/${rec.id}/firewall/options`, + ); + }, + }, + }); + store.load(); + me.callParent(); + }, +}); +Ext.define('PVE.sdn.ZoneView', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveSDNZoneView'], + + onlineHelp: 'pvesdn_config_zone', + emptyText: gettext('No zone configured.'), + + stateful: true, + stateId: 'grid-sdn-zone', + + createSDNEditWindow: function (type, sid) { + let schema = PVE.Utils.sdnzoneSchema[type]; + if (!schema || !schema.ipanel) { + throw 'no editor registered for zone type: ' + type; + } + + Ext.create('PVE.sdn.zones.BaseEdit', { + paneltype: 'PVE.sdn.zones.' + schema.ipanel, + type: type, + zone: sid, + autoShow: true, + listeners: { + destroy: this.reloadStore, + }, + }); + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-zone', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/zones?pending=1', + }, + sorters: { + property: 'zone', + direction: 'ASC', + }, + }); + + let reload = function () { + store.load(); + }; + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let type = rec.data.type, + zone = rec.data.zone; + + me.createSDNEditWindow(type, zone); + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/cluster/sdn/zones/', + callback: reload, + }); + + let set_button_status = function () { + var rec = me.selModel.getSelection()[0]; + + if (!rec || rec.data.state === 'deleted') { + edit_btn.disable(); + remove_btn.disable(); + } + }; + + // else we cannot dynamically generate the add menu handlers + let addHandleGenerator = function (type) { + return function () { + me.createSDNEditWindow(type); + }; + }; + let addMenuItems = []; + for (const [type, zone] of Object.entries(PVE.Utils.sdnzoneSchema)) { + if (zone.hideAdd) { + continue; + } + addMenuItems.push({ + text: PVE.Utils.format_sdnzone_type(type), + iconCls: 'fa fa-fw fa-' + zone.faIcon, + handler: addHandleGenerator(type), + }); + } + + Ext.apply(me, { + store: store, + reloadStore: reload, + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + items: addMenuItems, + }), + }, + remove_btn, + edit_btn, + ], + columns: [ + { + header: 'ID', + width: 100, + dataIndex: 'zone', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'zone', 1); + }, + }, + { + header: gettext('Type'), + width: 100, + dataIndex: 'type', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'type', 1); + }, + }, + { + header: 'MTU', + width: 50, + dataIndex: 'mtu', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'mtu'); + }, + }, + { + header: 'IPAM', + flex: 3, + dataIndex: 'ipam', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'ipam'); + }, + }, + { + header: gettext('Domain'), + flex: 3, + dataIndex: 'dnszone', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'dnszone'); + }, + }, + { + header: gettext('DNS'), + flex: 3, + dataIndex: 'dns', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'dns'); + }, + }, + { + header: gettext('Reverse DNS'), + flex: 3, + dataIndex: 'reversedns', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'reversedns'); + }, + }, + { + header: gettext('Nodes'), + flex: 3, + dataIndex: 'nodes', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending(rec, value, 'nodes'); + }, + }, + { + header: gettext('State'), + width: 100, + dataIndex: 'state', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + ], + listeners: { + activate: reload, + itemdblclick: run_editor, + selectionchange: set_button_status, + }, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.IpamEditInputPanel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + isCreate: false, + + onGetValues: function (values) { + let _me = this; + + if (!values.vmid) { + delete values.vmid; + } + + return values; + }, + + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'vmid', + fieldLabel: 'VMID', + allowBlank: false, + editable: false, + cbind: { + hidden: '{isCreate}', + }, + }, + { + xtype: 'pmxDisplayEditField', + name: 'mac', + fieldLabel: 'MAC', + allowBlank: false, + cbind: { + editable: '{isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'ip', + fieldLabel: gettext('IP Address'), + allowBlank: false, + }, + ], +}); + +Ext.define('PVE.sdn.IpamEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('DHCP Mapping'), + width: 350, + + isCreate: false, + mapping: {}, + + url: '/cluster/sdn/vnets', + + submitUrl: function (url, values) { + return `${url}/${values.vnet}/ips`; + }, + + initComponent: function () { + var me = this; + + me.method = me.isCreate ? 'POST' : 'PUT'; + + let ipanel = Ext.create('PVE.sdn.IpamEditInputPanel', { + isCreate: me.isCreate, + }); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + + ipanel.setValues(me.mapping); + }, +}); +Ext.define('PVE.sdn.Options', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNOptions', + + title: 'Options', + + layout: { + type: 'vbox', + align: 'stretch', + }, + + onlineHelp: 'pvesdn_config_controllers', + + items: [ + { + xtype: 'pveSDNControllerView', + title: gettext('Controllers'), + flex: 1, + padding: '0 0 20 0', + border: 0, + }, + { + xtype: 'pveSDNIpamView', + title: 'IPAM', + flex: 1, + padding: '0 0 20 0', + border: 0, + }, + { + xtype: 'pveSDNDnsView', + title: 'DNS', + flex: 1, + border: 0, + }, + ], +}); +Ext.define('PVE.panel.SDNControllerBase', { + extend: 'Proxmox.panel.InputPanel', + + type: '', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + delete values.delete; + } else { + delete values.controller; + + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined || value === '') { + delete values[key]; + + if (values.delete) { + if (Array.isArray(values.delete)) { + values.delete.push(key); + } else { + values.delete = [values.delete, key]; + } + } else { + values.delete = [key]; + } + } + } + } + + return values; + }, +}); + +Ext.define('PVE.sdn.controllers.BaseEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + me.isCreate = !me.controllerid; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/sdn/controllers'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/cluster/sdn/controllers/' + me.controllerid; + me.method = 'PUT'; + } + + var ipanel = Ext.create(me.paneltype, { + type: me.type, + isCreate: me.isCreate, + controllerid: me.controllerid, + }); + + Ext.apply(me, { + subject: PVE.Utils.format_sdncontroller_type(me.type), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + var ctypes = values.content || ''; + + values.content = ctypes.split(','); + + if (values.nodes) { + values.nodes = values.nodes.split(','); + } + values.enable = values.disable ? 0 : 1; + + ipanel.setValues(values); + }, + }); + } + }, +}); +Ext.define('PVE.sdn.controllers.EvpnInputPanel', { + extend: 'PVE.panel.SDNControllerBase', + + onlineHelp: 'pvesdn_controller_plugin_evpn', + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'controller', + maxLength: 8, + value: me.controllerid || '', + fieldLabel: 'ID', + allowBlank: false, + }, + { + xtype: 'proxmoxintegerfield', + name: 'asn', + minValue: 1, + maxValue: 4294967295, + value: 65000, + fieldLabel: 'ASN #', + allowBlank: false, + }, + { + xtype: 'proxmoxNetworkSelector', + name: 'fabric', + type: 'fabric', + valueField: 'iface', + displayField: 'iface', + fieldLabel: 'SDN Fabric', + allowBlank: true, + deleteEmpty: true, + skipEmptyText: true, + autoSelect: false, + emptyText: gettext('used as underlay network'), + nodename: 'localhost', + listConfig: { + width: 600, + columns: [ + { + header: gettext('Fabric'), + width: 90, + dataIndex: 'iface', + }, + { + header: gettext('CIDR'), + dataIndex: 'cidr', + hideable: false, + flex: 1, + }, + ], + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'peers', + fieldLabel: gettext('Peers'), + allowBlank: true, + deleteEmpty: true, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.controllers.BgpInputPanel', { + extend: 'PVE.panel.SDNControllerBase', + + onlineHelp: 'pvesdn_controller_plugin_evpn', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + values.controller = 'bgp' + values.node; + } else { + delete values.controller; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'pveNodeSelector', + name: 'node', + fieldLabel: gettext('Node'), + multiSelect: false, + autoSelect: false, + allowBlank: false, + }, + { + xtype: 'proxmoxintegerfield', + name: 'asn', + minValue: 1, + maxValue: 4294967295, + value: 65000, + fieldLabel: 'ASN #', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'peers', + fieldLabel: gettext('Peers'), + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'ebgp', + uncheckedValue: 0, + checked: false, + fieldLabel: 'EBGP', + }, + ]; + + me.advancedItems = [ + { + xtype: 'textfield', + name: 'loopback', + fieldLabel: gettext('Loopback Interface'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'ebgp-multihop', + minValue: 1, + maxValue: 100, + fieldLabel: 'ebgp-multihop', + allowBlank: true, + }, + { + xtype: 'proxmoxcheckbox', + name: 'bgp-multipath-as-path-relax', + uncheckedValue: 0, + checked: false, + fieldLabel: 'bgp-multipath-as-path-relax', + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.controllers.IsisInputPanel', { + extend: 'PVE.panel.SDNControllerBase', + + onlineHelp: 'pvesdn_controller_plugin_evpn', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + values.controller = 'isis' + values.node; + } else { + delete values.controller; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'pveNodeSelector', + name: 'node', + fieldLabel: gettext('Node'), + multiSelect: false, + autoSelect: false, + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'isis-domain', + fieldLabel: 'Domain', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'isis-net', + fieldLabel: 'Network entity title', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'isis-ifaces', + fieldLabel: gettext('Interfaces'), + allowBlank: false, + }, + ]; + + me.advancedItems = [ + { + xtype: 'textfield', + name: 'loopback', + fieldLabel: gettext('Loopback Interface'), + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.IpamView', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveSDNIpamView'], + + stateful: true, + stateId: 'grid-sdn-ipam', + + createSDNEditWindow: function (type, sid) { + let schema = PVE.Utils.sdnipamSchema[type]; + if (!schema || !schema.ipanel) { + throw 'no editor registered for ipam type: ' + type; + } + + Ext.create('PVE.sdn.ipams.BaseEdit', { + paneltype: 'PVE.sdn.ipams.' + schema.ipanel, + type: type, + ipam: sid, + autoShow: true, + listeners: { + destroy: this.reloadStore, + }, + }); + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-ipam', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/ipams', + }, + sorters: { + property: 'ipam', + direction: 'ASC', + }, + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let type = rec.data.type, + ipam = rec.data.ipam; + me.createSDNEditWindow(type, ipam); + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/cluster/sdn/ipams/', + callback: () => store.load(), + }); + + // else we cannot dynamically generate the add menu handlers + let addHandleGenerator = function (type) { + return function () { + me.createSDNEditWindow(type); + }; + }; + let addMenuItems = []; + for (const [type, ipam] of Object.entries(PVE.Utils.sdnipamSchema)) { + if (ipam.hideAdd) { + continue; + } + addMenuItems.push({ + text: PVE.Utils.format_sdnipam_type(type), + iconCls: 'fa fa-fw fa-' + ipam.faIcon, + handler: addHandleGenerator(type), + }); + } + + Ext.apply(me, { + store: store, + reloadStore: () => store.load(), + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + items: addMenuItems, + }), + }, + remove_btn, + edit_btn, + ], + columns: [ + { + header: 'ID', + flex: 2, + dataIndex: 'ipam', + renderer: Ext.htmlEncode, + }, + { + header: gettext('Type'), + flex: 1, + dataIndex: 'type', + renderer: PVE.Utils.format_sdnipam_type, + }, + { + header: 'url', + flex: 1, + dataIndex: 'url', + renderer: Ext.htmlEncode, + }, + ], + listeners: { + activate: () => store.load(), + itemdblclick: run_editor, + }, + }); + + store.load(); + me.callParent(); + }, +}); +Ext.define('PVE.panel.SDNIpamBase', { + extend: 'Proxmox.panel.InputPanel', + + type: '', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.ipam; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.callParent(); + }, +}); + +Ext.define('PVE.sdn.ipams.BaseEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + me.isCreate = !me.ipam; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/sdn/ipams'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/cluster/sdn/ipams/' + me.ipam; + me.method = 'PUT'; + } + + var ipanel = Ext.create(me.paneltype, { + type: me.type, + isCreate: me.isCreate, + ipam: me.ipam, + }); + + Ext.apply(me, { + subject: PVE.Utils.format_sdnipam_type(me.type), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + var ctypes = values.content || ''; + + values.content = ctypes.split(','); + + if (values.nodes) { + values.nodes = values.nodes.split(','); + } + values.enable = values.disable ? 0 : 1; + + ipanel.setValues(values); + }, + }); + } + }, +}); +Ext.define('PVE.sdn.ipams.NetboxInputPanel', { + extend: 'PVE.panel.SDNIpamBase', + + onlineHelp: 'pvesdn_ipam_plugin_netbox', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.ipam; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'ipam', + maxLength: 10, + value: me.zone || '', + fieldLabel: 'ID', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'token', + fieldLabel: gettext('Token'), + allowBlank: false, + }, + ]; + + me.column2 = [ + { + xtype: 'textfield', + name: 'url', + fieldLabel: gettext('URL'), + allowBlank: false, + }, + ]; + + me.columnB = [ + { + xtype: 'pmxFingerprintField', + name: 'fingerprint', + value: me.isCreate ? null : undefined, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.ipams.PVEIpamInputPanel', { + extend: 'PVE.panel.SDNIpamBase', + + onlineHelp: 'pvesdn_ipam_plugin_pveipam', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.ipam; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'ipam', + maxLength: 10, + value: me.zone || '', + fieldLabel: 'ID', + allowBlank: false, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.ipams.PhpIpamInputPanel', { + extend: 'PVE.panel.SDNIpamBase', + + onlineHelp: 'pvesdn_ipam_plugin_phpipam', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.ipam; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'ipam', + maxLength: 10, + value: me.zone || '', + fieldLabel: 'ID', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'token', + fieldLabel: gettext('Token'), + allowBlank: false, + }, + ]; + me.column2 = [ + { + xtype: 'textfield', + name: 'url', + fieldLabel: gettext('URL'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'section', + fieldLabel: gettext('Section'), + allowBlank: false, + }, + ]; + + me.columnB = [ + { + xtype: 'pmxFingerprintField', + name: 'fingerprint', + value: me.isCreate ? null : undefined, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.DnsView', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveSDNDnsView'], + + stateful: true, + stateId: 'grid-sdn-dns', + + createSDNEditWindow: function (type, sid) { + let schema = PVE.Utils.sdndnsSchema[type]; + if (!schema || !schema.ipanel) { + throw 'no editor registered for dns type: ' + type; + } + + Ext.create('PVE.sdn.dns.BaseEdit', { + paneltype: 'PVE.sdn.dns.' + schema.ipanel, + type: type, + dns: sid, + autoShow: true, + listeners: { + destroy: this.reloadStore, + }, + }); + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-dns', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/dns', + }, + sorters: { + property: 'dns', + direction: 'ASC', + }, + }); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let run_editor = function () { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let type = rec.data.type, + dns = rec.data.dns; + + me.createSDNEditWindow(type, dns); + }; + + let edit_btn = new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: run_editor, + }); + + let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/cluster/sdn/dns/', + callback: () => store.load(), + }); + + // else we cannot dynamically generate the add menu handlers + let addHandleGenerator = function (type) { + return function () { + me.createSDNEditWindow(type); + }; + }; + let addMenuItems = []; + for (const [type, dns] of Object.entries(PVE.Utils.sdndnsSchema)) { + if (dns.hideAdd) { + continue; + } + addMenuItems.push({ + text: PVE.Utils.format_sdndns_type(type), + iconCls: 'fa fa-fw fa-' + dns.faIcon, + handler: addHandleGenerator(type), + }); + } + + Ext.apply(me, { + store: store, + reloadStore: () => store.load(), + selModel: sm, + viewConfig: { + trackOver: false, + }, + tbar: [ + { + text: gettext('Add'), + menu: new Ext.menu.Menu({ + items: addMenuItems, + }), + }, + remove_btn, + edit_btn, + ], + columns: [ + { + header: 'ID', + flex: 2, + dataIndex: 'dns', + renderer: Ext.htmlEncode, + }, + { + header: gettext('Type'), + flex: 1, + dataIndex: 'type', + renderer: PVE.Utils.format_sdndns_type, + }, + { + header: 'url', + flex: 1, + dataIndex: 'url', + renderer: Ext.htmlEncode, + }, + ], + listeners: { + activate: () => store.load(), + itemdblclick: run_editor, + }, + }); + + store.load(); + me.callParent(); + }, +}); +Ext.define('PVE.panel.SDNDnsBase', { + extend: 'Proxmox.panel.InputPanel', + + type: '', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.dns; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.callParent(); + }, +}); + +Ext.define('PVE.sdn.dns.BaseEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + var me = this; + + me.isCreate = !me.dns; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/sdn/dns'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/cluster/sdn/dns/' + me.dns; + me.method = 'PUT'; + } + + var ipanel = Ext.create(me.paneltype, { + type: me.type, + isCreate: me.isCreate, + dns: me.dns, + }); + + Ext.apply(me, { + subject: PVE.Utils.format_sdndns_type(me.type), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + var ctypes = values.content || ''; + + values.content = ctypes.split(','); + + if (values.nodes) { + values.nodes = values.nodes.split(','); + } + values.enable = values.disable ? 0 : 1; + + ipanel.setValues(values); + }, + }); + } + }, +}); +Ext.define('PVE.sdn.dns.PowerdnsInputPanel', { + extend: 'PVE.panel.SDNDnsBase', + + onlineHelp: 'pvesdn_dns_plugin_powerdns', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.dns; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'dns', + maxLength: 10, + value: me.dns || '', + fieldLabel: 'ID', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'key', + fieldLabel: gettext('API Key'), + allowBlank: false, + }, + ]; + me.column2 = [ + { + xtype: 'textfield', + name: 'url', + fieldLabel: 'URL', + allowBlank: false, + }, + { + xtype: 'proxmoxintegerfield', + name: 'ttl', + fieldLabel: 'TTL', + allowBlank: true, + }, + ]; + me.columnB = [ + { + xtype: 'pmxFingerprintField', + name: 'fingerprint', + value: me.isCreate ? null : undefined, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.panel.SDNZoneBase', { + extend: 'Proxmox.panel.InputPanel', + + type: '', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.zone; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.items.unshift({ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'zone', + maxLength: 8, + value: me.zone || '', + fieldLabel: 'ID', + allowBlank: false, + }); + + me.items.push( + { + xtype: 'proxmoxintegerfield', + name: 'mtu', + minValue: 100, + maxValue: 65000, + fieldLabel: 'MTU', + allowBlank: true, + emptyText: 'auto', + deleteEmpty: !me.isCreate, + }, + { + xtype: 'pveNodeSelector', + name: 'nodes', + fieldLabel: gettext('Nodes'), + emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')', + multiSelect: true, + autoSelect: false, + }, + { + xtype: 'pveSDNIpamSelector', + fieldLabel: gettext('IPAM'), + name: 'ipam', + value: me.ipam || 'pve', + allowBlank: false, + }, + ); + + me.advancedItems = me.advancedItems ?? []; + + me.advancedItems.unshift( + { + xtype: 'pveSDNDnsSelector', + fieldLabel: gettext('DNS Server'), + name: 'dns', + value: '', + allowBlank: true, + }, + { + xtype: 'pveSDNDnsSelector', + fieldLabel: gettext('Reverse DNS Server'), + name: 'reversedns', + value: '', + allowBlank: true, + }, + { + xtype: 'proxmoxtextfield', + name: 'dnszone', + skipEmptyText: true, + fieldLabel: gettext('DNS Zone'), + allowBlank: true, + deleteEmpty: !me.isCreate, + }, + ); + + me.callParent(); + }, +}); + +Ext.define('PVE.sdn.zones.BaseEdit', { + extend: 'Proxmox.window.Edit', + + width: 400, + + initComponent: function () { + var me = this; + + me.isCreate = !me.zone; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/sdn/zones'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/cluster/sdn/zones/' + me.zone; + me.method = 'PUT'; + } + + var ipanel = Ext.create(me.paneltype, { + type: me.type, + isCreate: me.isCreate, + zone: me.zone, + }); + + Ext.apply(me, { + subject: PVE.Utils.format_sdnzone_type(me.type), + isAdd: true, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + var values = response.result.data; + var ctypes = values.content || ''; + + values.content = ctypes.split(','); + + if (values.nodes) { + values.nodes = values.nodes.split(','); + } + + if (values.exitnodes) { + values.exitnodes = values.exitnodes.split(','); + } + + values.enable = values.disable ? 0 : 1; + + ipanel.setValues(values); + }, + }); + } + }, +}); +Ext.define('PVE.sdn.zones.EvpnInputPanel', { + extend: 'PVE.panel.SDNZoneBase', + + onlineHelp: 'pvesdn_zone_plugin_evpn', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'pveSDNControllerSelector', + fieldLabel: gettext('Controller'), + name: 'controller', + value: '', + allowBlank: false, + }, + { + xtype: 'proxmoxintegerfield', + name: 'vrf-vxlan', + minValue: 1, + maxValue: 16000000, + fieldLabel: 'VRF-VXLAN Tag', + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'mac', + fieldLabel: gettext('VNet MAC Address'), + vtype: 'MacAddress', + allowBlank: true, + emptyText: 'auto', + deleteEmpty: !me.isCreate, + }, + { + xtype: 'pveNodeSelector', + name: 'exitnodes', + fieldLabel: gettext('Exit Nodes'), + multiSelect: true, + autoSelect: false, + }, + { + xtype: 'pveNodeSelector', + name: 'exitnodes-primary', + fieldLabel: gettext('Primary Exit Node'), + multiSelect: false, + autoSelect: false, + skipEmptyText: true, + deleteEmpty: !me.isCreate, + }, + { + xtype: 'proxmoxcheckbox', + name: 'exitnodes-local-routing', + uncheckedValue: null, + checked: false, + fieldLabel: gettext('Exit Nodes Local Routing'), + deleteEmpty: !me.isCreate, + }, + { + xtype: 'proxmoxcheckbox', + name: 'advertise-subnets', + uncheckedValue: null, + checked: false, + fieldLabel: gettext('Advertise Subnets'), + deleteEmpty: !me.isCreate, + }, + { + xtype: 'proxmoxcheckbox', + name: 'disable-arp-nd-suppression', + uncheckedValue: null, + checked: false, + fieldLabel: gettext('Disable ARP-nd Suppression'), + deleteEmpty: !me.isCreate, + }, + { + xtype: 'proxmoxtextfield', + name: 'rt-import', + fieldLabel: gettext('Route Target Import'), + allowBlank: true, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.zones.QinQInputPanel', { + extend: 'PVE.panel.SDNZoneBase', + + onlineHelp: 'pvesdn_zone_plugin_qinq', + + onGetValues: function (values) { + let me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.sdn; + } + + return values; + }, + + initComponent: function () { + let me = this; + + me.items = [ + { + xtype: 'textfield', + name: 'bridge', + fieldLabel: 'Bridge', + allowBlank: false, + vtype: 'BridgeName', + minLength: 1, + maxLength: 10, + }, + { + xtype: 'proxmoxintegerfield', + name: 'tag', + minValue: 0, + maxValue: 4096, + fieldLabel: gettext('Service VLAN'), + allowBlank: false, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'vlan-protocol', + fieldLabel: gettext('Service VLAN Protocol'), + allowBlank: true, + value: '802.1q', + comboItems: [ + ['802.1q', '802.1q'], + ['802.1ad', '802.1ad'], + ], + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.zones.SimpleInputPanel', { + extend: 'PVE.panel.SDNZoneBase', + + onlineHelp: 'pvesdn_zone_plugin_simple', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.zone; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.items = []; + me.advancedItems = [ + { + xtype: 'proxmoxcheckbox', + name: 'dhcp', + inputValue: 'dnsmasq', + uncheckedValue: null, + checked: false, + fieldLabel: gettext('automatic DHCP'), + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.zones.VlanInputPanel', { + extend: 'PVE.panel.SDNZoneBase', + + onlineHelp: 'pvesdn_zone_plugin_vlan', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.zone; + } + + return values; + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'textfield', + name: 'bridge', + fieldLabel: 'Bridge', + allowBlank: false, + vtype: 'BridgeName', + minLength: 1, + maxLength: 10, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.zones.VxlanInputPanel', { + extend: 'PVE.panel.SDNZoneBase', + + onlineHelp: 'pvesdn_zone_plugin_vxlan', + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + delete values.delete; + } else { + delete values.zone; + + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined || value === '') { + delete values[key]; + + if (values.delete) { + if (Array.isArray(values.delete)) { + values.delete.push(key); + } else { + values.delete = [values.delete, key]; + } + } else { + values.delete = [key]; + } + } + } + } + + delete values.mode; + + return values; + }, + + initComponent: function () { + var me = this; + + me.items = [ + { + xtype: 'proxmoxtextfield', + name: 'peers', + fieldLabel: gettext('Peer Address List'), + allowBlank: true, + deleteEmpty: true, + }, + { + xtype: 'proxmoxNetworkSelector', + name: 'fabric', + type: 'fabric', + valueField: 'iface', + displayField: 'iface', + fieldLabel: 'SDN Fabric', + skipEmptyText: true, + allowBlank: true, + deleteEmpty: true, + autoSelect: false, + emptyText: gettext('used as underlay network'), + nodename: 'localhost', + listConfig: { + width: 600, + columns: [ + { + // TRANSLATORS: As in "Network Fabric": https://en.wikipedia.org/wiki/Switched_fabric + header: gettext('Fabric'), + width: 90, + dataIndex: 'iface', + }, + { + header: gettext('CIDR'), + dataIndex: 'cidr', + hideable: false, + flex: 1, + }, + ], + }, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.Fabric.TreeModel', { + extend: 'Ext.data.TreeModel', + idProperty: 'tree_id', +}); + +Ext.define('PVE.sdn.Fabric.View', { + extend: 'Ext.tree.Panel', + + xtype: 'pveSDNFabricView', + + onlineHelp: 'pvesdn_config_fabrics', + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Name'), + dataIndex: 'node_id', + width: 200, + renderer: function (value, metaData, rec) { + if (rec.data.type === 'fabric') { + return PVE.Utils.render_sdn_pending(rec, rec.data.id, 'id'); + } + + return PVE.Utils.render_sdn_pending(rec, value, 'node_id'); + }, + }, + { + text: gettext('Protocol'), + dataIndex: 'protocol', + width: 100, + renderer: function (value, metaData, rec) { + if (rec.data.type === 'fabric') { + const PROTOCOL_DISPLAY_NAMES = { + openfabric: 'OpenFabric', + ospf: 'OSPF', + }; + const displayValue = PROTOCOL_DISPLAY_NAMES[value]; + if (rec.data.state === undefined || rec.data.state === null) { + return Ext.htmlEncode(displayValue); + } + if (rec.data.state === 'deleted') { + if (value === undefined) { + return ' '; + } else { + let encoded = Ext.htmlEncode(displayValue); + return `${encoded}`; + } + } + return Ext.htmlEncode(displayValue); + } + + return ''; + }, + }, + { + text: gettext('IPv4'), + dataIndex: 'ip', + width: 150, + renderer: function (value, metaData, rec) { + if (rec.data.type === 'fabric') { + return PVE.Utils.render_sdn_pending(rec, rec.data.ip_prefix, 'ip_prefix'); + } + + return PVE.Utils.render_sdn_pending(rec, value, 'ip'); + }, + }, + { + text: gettext('IPv6'), + dataIndex: 'ip6', + width: 150, + renderer: function (value, metaData, rec) { + if (rec.data.type === 'fabric') { + return PVE.Utils.render_sdn_pending(rec, rec.data.ip6_prefix, 'ip6_prefix'); + } + + return PVE.Utils.render_sdn_pending(rec, value, 'ip6'); + }, + }, + { + header: gettext('Interfaces'), + width: 200, + dataIndex: 'interface', + renderer: function (value, metaData, rec) { + const interfaces = rec.data.pending?.interfaces || rec.data.interfaces || []; + + let names = interfaces.map((iface) => { + const properties = Proxmox.Utils.parsePropertyString(iface); + return properties.name; + }); + + names.sort(); + const displayValue = Ext.htmlEncode(names.join(', ')); + if (rec.data.state === 'deleted') { + return `${displayValue}`; + } + return displayValue; + }, + }, + { + text: gettext('Action'), + xtype: 'actioncolumn', + dataIndex: 'text', + width: 100, + items: [ + { + handler: 'addActionTreeColumn', + getTip: (_v, _m, _rec) => gettext('Add Node'), + getClass: (_v, _m, { data }) => { + if (data.type === 'fabric') { + return 'fa fa-plus-circle'; + } + + return 'pmx-hidden'; + }, + isActionDisabled: (_v, _r, _c, _i, { data }) => data.type !== 'fabric', + }, + { + tooltip: gettext('Edit'), + handler: 'editAction', + getClass: (_v, _m, { data }) => { + // the fabric type (openfabric, ospf, etc.) cannot be edited + if (data.type && data.state !== 'deleted') { + return 'fa fa-pencil fa-fw'; + } + + return 'pmx-hidden'; + }, + isActionDisabled: (_v, _r, _c, _i, { data }) => !data.type, + }, + { + tooltip: gettext('Delete'), + handler: 'deleteAction', + getClass: (_v, _m, { data }) => { + // the fabric type (openfabric, ospf, etc.) cannot be deleted + if (data.type && data.state !== 'deleted') { + return 'fa critical fa-trash-o'; + } + + return 'pmx-hidden'; + }, + isActionDisabled: (_v, _r, _c, _i, { data }) => !data.type, + }, + ], + }, + { + header: gettext('State'), + width: 100, + dataIndex: 'state', + renderer: function (value, metaData, rec) { + return PVE.Utils.render_sdn_pending_state(rec, value); + }, + }, + ], + + store: { + sorters: ['tree_id'], + model: 'PVE.sdn.Fabric.TreeModel', + }, + + layout: 'fit', + rootVisible: false, + animate: false, + + initComponent: function () { + let me = this; + + let addNodeButton = new Proxmox.button.Button({ + text: gettext('Add Node'), + handler: 'addActionTbar', + disabled: true, + }); + + let setAddNodeButtonStatus = function () { + let selection = me.view.getSelection(); + + if (selection.length === 0) { + return; + } + + let enabled = selection[0].data.type === 'fabric'; + addNodeButton.setDisabled(!enabled); + }; + + Ext.apply(me, { + tbar: [ + { + text: gettext('Add Fabric'), + menu: [ + { + text: 'OpenFabric', + handler: 'addOpenfabric', + }, + { + text: 'OSPF', + handler: 'addOspf', + }, + ], + }, + addNodeButton, + { + xtype: 'proxmoxButton', + text: gettext('Reload'), + handler: function () { + const view = this.up('pveSDNFabricView'); + view.getController().reload(); + }, + }, + ], + listeners: { + selectionchange: setAddNodeButtonStatus, + }, + }); + + me.callParent(); + }, + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function (successCallback) { + let me = this; + + Proxmox.Utils.API2Request({ + url: `/cluster/sdn/fabrics/all?pending=1`, + method: 'GET', + success: function (response, opts) { + let fabrics = {}; + + for (const fabric of response.result.data.fabrics) { + let mergedFabric = { + expanded: true, + type: 'fabric', + iconCls: 'fa fa-road x-fa-treepanel', + children: [], + ...fabric, + ...fabric.pending, + }; + + mergedFabric.tree_id = mergedFabric.id; + + fabrics[mergedFabric.id] = mergedFabric; + } + + for (const node of response.result.data.nodes) { + let mergedNode = { + type: 'node', + iconCls: 'fa fa-desktop x-fa-treepanel', + leaf: true, + ...node, + ...node.pending, + }; + + mergedNode.tree_id = `${mergedNode.fabric_id}_${mergedNode.node_id}`; + + fabrics[mergedNode.fabric_id].children.push(mergedNode); + } + + me.getView().setRootNode({ + name: '__root', + expanded: true, + children: Object.values(fabrics), + }); + + if (successCallback) { + successCallback(); + } + }, + }); + }, + + getFabricEditPanel: function (protocol) { + const FABRIC_PANELS = { + openfabric: 'PVE.sdn.Fabric.OpenFabric.Fabric.Edit', + ospf: 'PVE.sdn.Fabric.Ospf.Fabric.Edit', + }; + + return FABRIC_PANELS[protocol]; + }, + + getNodeEditPanel: function (protocol) { + const NODE_PANELS = { + openfabric: 'PVE.sdn.Fabric.OpenFabric.Node.Edit', + ospf: 'PVE.sdn.Fabric.Ospf.Node.Edit', + }; + + return NODE_PANELS[protocol]; + }, + + addOpenfabric: function () { + let me = this; + me.openFabricAddWindow('openfabric'); + }, + + addOspf: function () { + let me = this; + me.openFabricAddWindow('ospf'); + }, + + openFabricAddWindow: function (protocol) { + let me = this; + + let component = me.getFabricEditPanel(protocol); + + let window = Ext.create(component, { + autoShow: true, + autoLoad: false, + isCreate: true, + }); + + window.on('destroy', () => me.reload()); + }, + + addActionTreeColumn: function (_grid, _rI, _cI, _item, _e, rec) { + this.openNodeAddWindow(rec.data); + }, + + addActionTbar: function () { + let me = this; + + let selection = me.view.getSelection(); + + if (selection.length === 0) { + return; + } + + if (selection[0].data.type === 'fabric') { + me.openNodeAddWindow(selection[0].data); + } + }, + + openNodeAddWindow: function (fabric) { + let me = this; + + let component = me.getNodeEditPanel(fabric.protocol); + + let disallowedNodes = fabric.children + .filter((node) => !node.state || node.state !== 'deleted') + .map((node) => node.node_id); + + Ext.create(component, { + autoShow: true, + fabricId: fabric.id, + protocol: fabric.protocol, + disallowedNodes, + addAnotherCallback: () => { + let successCallback = () => { + let new_fabric = me + .getView() + .getStore() + .findRecord('tree_id', fabric.tree_id); + + me.openNodeAddWindow(new_fabric.data); + }; + + me.reload(successCallback); + }, + apiCallDone: (success, _response, _options) => { + if (success) { + me.reload(); + } + }, + }); + }, + + openFabricEditWindow: function (fabric) { + let me = this; + + let component = me.getFabricEditPanel(fabric.protocol); + + let window = Ext.create(component, { + autoShow: true, + fabricId: fabric.id, + }); + + window.on('destroy', () => me.reload()); + }, + + openNodeEditWindow: function (node) { + let me = this; + + let component = me.getNodeEditPanel(node.protocol); + + let window = Ext.create(component, { + autoShow: true, + fabricId: node.fabric_id, + nodeId: node.node_id, + protocol: node.protocol, + }); + + window.on('destroy', () => me.reload()); + }, + + editAction: function (_grid, _rI, _cI, _item, _e, rec) { + let me = this; + + if (rec.data.type === 'fabric') { + me.openFabricEditWindow(rec.data); + } else if (rec.data.type === 'node') { + me.openNodeEditWindow(rec.data); + } else { + console.warn(`unknown type ${rec.data.type}`); + } + }, + + handleDeleteAction: function (url, message) { + let me = this; + let view = me.getView(); + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: Ext.htmlEncode(message), + buttons: Ext.Msg.YESNO, + defaultFocus: 'no', + callback: function (btn) { + if (btn !== 'yes') { + return; + } + + Proxmox.Utils.API2Request({ + url, + method: 'DELETE', + waitMsgTarget: view, + failure: function (response, opts) { + Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus); + }, + callback: () => me.reload(), + }); + }, + }); + }, + + deleteAction: function (table, rI, cI, item, e, rec) { + let me = this; + + if (rec.data.type === 'fabric') { + let message = Ext.String.format( + gettext('Are you sure you want to remove the fabric "{0}"?'), + rec.data.id, + ); + + let url = `/cluster/sdn/fabrics/fabric/${rec.data.id}`; + + me.handleDeleteAction(url, message); + } else if (rec.data.type === 'node') { + let message = Ext.String.format( + gettext( + 'Are you sure you want to remove the node "{0}" from the fabric "{1}"?', + ), + rec.data.node_id, + rec.data.fabric_id, + ); + + let url = `/cluster/sdn/fabrics/node/${rec.data.fabric_id}/${rec.data.node_id}`; + + me.handleDeleteAction(url, message); + } else { + console.warn(`unknown type: ${rec.data.type}`); + } + }, + + init: function (view) { + let me = this; + me.reload(); + }, + }, +}); +Ext.define('PVE.sdn.FabricRoutesContentView', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNFabricRoutesContentView', + + columns: [ + { + header: gettext('Route'), + sortable: true, + dataIndex: 'route', + flex: 1, + }, + { + header: gettext('Via'), + sortable: true, + dataIndex: 'via', + renderer: (value) => { + if (Ext.isArray(value)) { + return value.join('
    '); + } + return value || ''; + }, + flex: 1, + }, + ], +}); + +Ext.define('PVE.sdn.FabricNeighborsContentView', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNFabricNeighborsContentView', + + columns: [ + { + header: gettext('Neighbor'), + sortable: true, + dataIndex: 'neighbor', + flex: 1, + }, + { + header: gettext('Status'), + sortable: true, + dataIndex: 'status', + flex: 0.5, + }, + { + header: gettext('Uptime'), + sortable: true, + dataIndex: 'uptime', + flex: 0.5, + }, + ], +}); + +Ext.define('PVE.sdn.FabricInterfacesContentView', { + extend: 'Ext.grid.GridPanel', + alias: 'widget.pveSDNFabricInterfacesContentView', + + columns: [ + { + header: gettext('Name'), + sortable: true, + dataIndex: 'name', + flex: 1, + }, + { + header: gettext('Type'), + sortable: true, + dataIndex: 'type', + flex: 1, + }, + { + header: gettext('State'), + sortable: true, + dataIndex: 'state', + flex: 1, + }, + ], +}); +Ext.define('Pve.sdn.Fabric', { + extend: 'Ext.data.Model', + idProperty: 'name', + fields: ['id', 'protocol', 'ip_prefix', 'ip6_prefix'], +}); + +Ext.define('Pve.sdn.Node', { + extend: 'Ext.data.Model', + idProperty: 'name', + fields: ['fabric_id', 'node_id', 'protocol', 'ip', 'ip6', 'area'], +}); + +Ext.define('Pve.sdn.Interface', { + extend: 'Ext.data.Model', + idProperty: 'name', + fields: ['name', 'ip', 'ip6', 'hello_interval', 'hello_multiplier', 'csnp_interval'], +}); +Ext.define('PVE.sdn.Fabric.InterfacePanel', { + extend: 'Ext.grid.Panel', + mixins: ['Ext.form.field.Field'], + + xtype: 'pveSDNFabricsInterfacePanel', + + nodeInterfaces: {}, + + selModel: { + mode: 'SIMPLE', + type: 'checkboxmodel', + }, + + commonColumns: [ + { + text: gettext('Status'), + dataIndex: 'status', + width: 30, + renderer: function (value, metaData, record) { + let me = this; + + let warning; + let nodeInterface = me.nodeInterfaces[record.data.name]; + + if (!nodeInterface) { + warning = gettext('Interface does not exist on node'); + } else if ( + (nodeInterface.ip && record.data.ip) || + (nodeInterface.ip6 && record.data.ip6) + ) { + warning = gettext( + 'Interface already has an address configured in /etc/network/interfaces', + ); + } else if (nodeInterface.ip || nodeInterface.ip6) { + warning = gettext( + 'Configure the IP in the fabric, instead of /etc/network/interfaces', + ); + } + + if (warning) { + metaData.tdAttr = `data-qtip="${Ext.htmlEncode(Ext.htmlEncode(warning))}"`; + return ``; + } + + return ''; + }, + }, + { + text: gettext('Name'), + dataIndex: 'name', + flex: 2, + }, + { + text: gettext('Type'), + dataIndex: 'type', + flex: 1, + }, + { + text: gettext('IP'), + xtype: 'widgetcolumn', + dataIndex: 'ip', + flex: 1, + widget: { + xtype: 'proxmoxtextfield', + isFormField: false, + bind: { + disabled: '{record.isDisabled}', + }, + }, + }, + ], + + additionalColumns: [], + + controller: { + onValueChange: function (field, value) { + let me = this; + + let record = field.getWidgetRecord(); + + if (!record) { + return; + } + + let column = field.getWidgetColumn(); + + record.set(column.dataIndex, value); + record.commit(); + + me.getView().checkChange(); + }, + + control: { + field: { + change: 'onValueChange', + }, + }, + }, + + listeners: { + selectionchange: function () { + this.checkChange(); + }, + }, + + initComponent: function () { + let me = this; + + Ext.apply(me, { + store: Ext.create('Ext.data.Store', { + model: 'Pve.sdn.Interface', + sorters: { + property: 'name', + direction: 'ASC', + }, + }), + columns: me.commonColumns.concat(me.additionalColumns), + }); + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore(), true); + me.initField(); + }, + + setNodeInterfaces: function (interfaces) { + let me = this; + + let nodeInterfaces = {}; + for (const iface of interfaces) { + nodeInterfaces[iface.name] = iface; + } + + me.nodeInterfaces = nodeInterfaces; + + // reset value when setting new available interfaces + me.setValue([]); + }, + + getValue: function () { + let me = this; + + return me.getSelection().map((rec) => { + let data = {}; + + for (const [key, value] of Object.entries(rec.data)) { + if (value === '' || value === undefined || value === null) { + continue; + } + + if (['type', 'isDisabled'].includes(key)) { + continue; + } + + data[key] = value; + } + + return PVE.Parser.printPropertyString(data); + }); + }, + + setValue: function (value) { + let me = this; + + let store = me.getStore(); + + let selection = me.getSelectionModel(); + selection.deselectAll(); + + let data = structuredClone(me.nodeInterfaces); + + for (const iface of Object.values(data)) { + iface.isDisabled = iface.ip || iface.ip6; + } + + let selected = []; + let fabricInterfaces = structuredClone(value); + + for (let iface of fabricInterfaces) { + iface = PVE.Parser.parsePropertyString(iface); + + selected.push(iface.name); + + // if the fabric configuration defines an interface that was + // previously disabled, re-enable the field to allow editing of the + // value set in the fabric - we show a warning as well if there is + // already an IP configured in /e/n/i + iface.isDisabled = false; + + if (Object.hasOwn(data, iface.name)) { + data[iface.name] = { + ...data[iface.name], + // fabric properties have precedence + ...iface, + }; + } else { + data[iface.name] = iface; + } + } + + store.setData(Object.values(data)); + + let selected_records = selected.map((name) => store.findRecord('name', name)); + selection.select(selected_records); + + me.resetOriginalValue(); + }, + + getSubmitData: function () { + let me = this; + + let name = me.getName(); + let value = me.getValue(); + + if (value.length === 0 && !me.isCreate) { + return { + delete: name, + }; + } + + return { + [name]: value, + }; + }, +}); +Ext.define('PVE.sdn.Fabric.Node.Edit', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + width: 800, + subject: gettext('Node'), + + isCreate: undefined, + + fabricId: undefined, + nodeId: undefined, + protocol: undefined, + + disallowedNodes: [], + + baseUrl: '/cluster/sdn/fabrics/node', + + items: [ + { + xtype: 'textfield', + name: 'digest', + hidden: true, + allowBlank: true, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('IPv4'), + labelWidth: 120, + name: 'ip', + allowBlank: true, + skipEmptyText: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + + additionalItems: [], + + addAnotherCallback: undefined, + + initComponent: function () { + let me = this; + + me.isCreate = me.nodeId === undefined; + me.autoLoad = !me.isCreate; + me.method = me.isCreate ? 'POST' : 'PUT'; + + if (!me.isCreate) { + me.url = `${me.baseUrl}/${me.fabricId}/${me.nodeId}`; + } else { + me.url = `${me.baseUrl}/${me.fabricId}`; + } + + me.nodeSelector = me.getNodeSelector(); + me.interfaceSelector = me.getInterfaceSelector(); + + me.items = [me.nodeSelector, ...me.items, ...me.additionalItems, me.interfaceSelector]; + + me.callParent(); + + if (me.isCreate && me.addAnotherCallback) { + let addAnotherBtn = Ext.create('Ext.Button', { + text: gettext('Create another'), + disabled: !me.isCreate, + handler: function () { + me.apiCallDone = (success, _response, _options) => { + if (success) { + me.addAnotherCallback(); + } + }; + + me.submit(); + }, + }); + + let form = me.formPanel.getForm(); + + let set_button_status = function () { + let valid = form.isValid(); + let dirty = form.isDirty(); + addAnotherBtn.setDisabled(!valid || !(dirty || me.isCreate)); + }; + + form.on('dirtychange', set_button_status); + form.on('validitychange', set_button_status); + + me.getDockedItems()[0].add(addAnotherBtn); + } + }, + + loadNode: async function () { + let me = this; + + if (me.isCreate) { + return {}; + } + + let req = await Proxmox.Async.api2({ + url: `/cluster/sdn/fabrics/node/${me.fabricId}/${me.nodeId}`, + method: 'GET', + }); + + return req.result.data; + }, + + loadNodeInterfaces: async function () { + let me = this; + + let req = await Proxmox.Async.api2({ + url: `/api2/extjs/nodes/${me.nodeId}/network`, + method: 'GET', + }); + + return req.result.data.map((iface) => ({ + name: iface.iface, + type: iface.type, + ip: iface.cidr, + ipv6: iface.cidr6, + })); + }, + + load: function () { + let me = this; + + me.setLoading('fetching node information'); + + Promise.all([me.loadNode(me.fabricId, me.nodeId), me.loadNodeInterfaces(me.nodeId)]) + .catch(Proxmox.Utils.alertResponseFailure) + .then(([node, nodeInterfaces]) => { + me.interfaceSelector.setNodeInterfaces(nodeInterfaces); + me.setValues(node); + }) + .finally(() => { + me.setLoading(false); + }); + }, + + getNodeSelector: function () { + let me = this; + + return Ext.create('PVE.form.NodeSelector', { + xtype: 'pveNodeSelector', + reference: 'nodeselector', + fieldLabel: gettext('Node'), + labelWidth: 120, + name: 'node_id', + allowBlank: false, + disabled: !me.isCreate, + disallowedNodes: me.disallowedNodes, + onlineValidator: me.isCreate, + autoSelect: me.isCreate, + listeners: { + change: function (f, value) { + if (me.isCreate) { + me.nodeId = value; + me.load(); + } + }, + }, + listConfig: { + columns: [ + { + header: gettext('Node'), + dataIndex: 'node', + sortable: true, + hideable: false, + flex: 1, + }, + ], + }, + store: { + fields: ['node'], + proxy: { + type: 'proxmox', + url: '/api2/json/nodes', + }, + sorters: [ + { + property: 'node', + direction: 'ASC', + }, + ], + listeners: { + load: function (store) { + if (store.count() === 0) { + Ext.Msg.alert( + gettext('Add Node'), + gettext('All available nodes are already part of the fabric'), + () => me.destroy(), + ); + } + }, + }, + }, + }); + }, + + getInterfacePanel: function (protocol) { + const INTERFACE_PANELS = { + openfabric: 'PVE.sdn.Fabric.OpenFabric.InterfacePanel', + ospf: 'PVE.sdn.Fabric.Ospf.InterfacePanel', + }; + + return INTERFACE_PANELS[protocol]; + }, + + getInterfaceSelector: function () { + let me = this; + + return Ext.create(me.getInterfacePanel(me.protocol), { + name: 'interfaces', + }); + }, +}); +Ext.define('PVE.sdn.Fabric.Fabric.Edit', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + width: 400, + + fabricId: undefined, + baseUrl: '/cluster/sdn/fabrics/fabric', + + items: [ + { + xtype: 'textfield', + name: 'digest', + hidden: true, + allowBlank: true, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Name'), + labelWidth: 120, + maxLength: 8, + name: 'id', + cbind: { + disabled: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('IPv4 Prefix'), + labelWidth: 120, + name: 'ip_prefix', + allowBlank: true, + skipEmptyText: true, + cbind: { + disabled: '{!isCreate}', + deleteEmpty: '{!isCreate}', + }, + }, + ], + + additionalItems: [], + + initComponent: function () { + let me = this; + + me.isCreate = me.fabricId === undefined; + me.autoLoad = !me.isCreate; + me.method = me.isCreate ? 'POST' : 'PUT'; + + if (!me.isCreate) { + me.url = `${me.baseUrl}/${me.fabricId}`; + } else { + me.url = me.baseUrl; + } + + me.items.push(...me.additionalItems); + + me.callParent(); + }, +}); +Ext.define('PVE.sdn.Fabric.OpenFabric.InterfacePanel', { + extend: 'PVE.sdn.Fabric.InterfacePanel', + + additionalColumns: [ + { + text: gettext('IPv6'), + xtype: 'widgetcolumn', + dataIndex: 'ip6', + flex: 1, + widget: { + xtype: 'proxmoxtextfield', + isFormField: false, + bind: { + disabled: '{record.isDisabled}', + }, + }, + }, + { + text: gettext('Hello Multiplier'), + xtype: 'widgetcolumn', + dataIndex: 'hello_multiplier', + flex: 1, + hidden: true, + widget: { + xtype: 'proxmoxintegerfield', + isFormField: false, + emptyText: '10', + bind: { + disabled: '{record.isDisabled}', + }, + }, + }, + ], +}); +Ext.define('PVE.sdn.Fabric.OpenFabric.Node.Edit', { + extend: 'PVE.sdn.Fabric.Node.Edit', + protocol: 'openfabric', + + extraRequestParams: { + protocol: 'openfabric', + }, + + additionalItems: [ + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('IPv6'), + labelWidth: 120, + name: 'ip6', + allowBlank: true, + skipEmptyText: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], +}); +Ext.define('PVE.sdn.Fabric.OpenFabric.Fabric.Edit', { + extend: 'PVE.sdn.Fabric.Fabric.Edit', + + subject: 'OpenFabric', + onlineHelp: 'pvesdn_openfabric_fabric', + + viewModel: { + data: { + showIpv6ForwardingHint: false, + }, + }, + + extraRequestParams: { + protocol: 'openfabric', + }, + + additionalItems: [ + { + xtype: 'displayfield', + value: 'To make IPv6 fabrics work, enable global IPv6 forwarding on all nodes. Click on the Help button for more details.', + bind: { + hidden: '{!showIpv6ForwardingHint}', + }, + userCls: 'pmx-hint', + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('IPv6 Prefix'), + labelWidth: 120, + name: 'ip6_prefix', + allowBlank: true, + skipEmptyText: true, + cbind: { + disabled: '{!isCreate}', + deleteEmpty: '{!isCreate}', + }, + listeners: { + change: function (textbox, value) { + let vm = textbox.up('window').getViewModel(); + vm.set('showIpv6ForwardingHint', !!value); + }, + }, + }, + { + xtype: 'proxmoxintegerfield', + // TRANSLATORS: See https://en.wikipedia.org/wiki/IS-IS#Packet_types + fieldLabel: gettext('Hello Interval'), + labelWidth: 120, + name: 'hello_interval', + allowBlank: true, + emptyText: '3', + skipEmptyText: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + // TRANSLATORS: Stands for Complete Sequence Number Packet, see + // https://datatracker.ietf.org/doc/html/draft-ietf-lsr-distoptflood#name-flooding-failures + fieldLabel: gettext('CSNP Interval'), + labelWidth: 120, + name: 'csnp_interval', + allowBlank: true, + emptyText: '10', + skipEmptyText: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], +}); +Ext.define('PVE.sdn.Fabric.Ospf.InterfacePanel', { + extend: 'PVE.sdn.Fabric.InterfacePanel', +}); +Ext.define('PVE.sdn.Fabric.Ospf.Node.Edit', { + extend: 'PVE.sdn.Fabric.Node.Edit', + protocol: 'ospf', + + extraRequestParams: { + protocol: 'ospf', + }, +}); +Ext.define('PVE.sdn.Fabric.Ospf.Fabric.Edit', { + extend: 'PVE.sdn.Fabric.Fabric.Edit', + + subject: 'OSPF', + onlineHelp: 'pvesdn_ospf_fabric', + + extraRequestParams: { + protocol: 'ospf', + }, + + additionalItems: [ + { + xtype: 'textfield', + fieldLabel: gettext('Area'), + labelWidth: 120, + name: 'area', + emptyText: '0', + allowBlank: false, + }, + ], +}); +Ext.define( + 'PVE.storage.ContentView', + { + extend: 'Ext.grid.GridPanel', + + alias: 'widget.pveStorageContentView', + + itemdblclick: Ext.emptyFn, + + viewConfig: { + trackOver: false, + loadMask: false, + }, + initComponent: function () { + var me = this; + + if (!me.nodename) { + me.nodename = me.pveSelNode.data.node; + if (!me.nodename) { + throw 'no node name specified'; + } + } + const nodename = me.nodename; + + if (!me.storage) { + me.storage = me.pveSelNode.data.storage; + if (!me.storage) { + throw 'no storage ID specified'; + } + } + const storage = me.storage; + + var content = me.content; + if (!content) { + throw 'no content type specified'; + } + + const baseurl = `/nodes/${nodename}/storage/${storage}/content`; + let store = (me.store = Ext.create('Ext.data.Store', { + model: 'pve-storage-content', + proxy: { + type: 'proxmox', + url: '/api2/json' + baseurl, + extraParams: { + content: content, + }, + }, + sorters: [ + (a, b) => + a.data.text + .toString() + .localeCompare(b.data.text.toString(), undefined, { numeric: true }), + ], + })); + + if (!me.sm) { + me.sm = Ext.create('Ext.selection.RowModel', {}); + } + let sm = me.sm; + + let reload = () => store.load(); + + Proxmox.Utils.monStoreErrors(me, store); + + let tbar = me.tbar ? [...me.tbar] : []; + if (me.useUploadButton) { + tbar.unshift( + { + xtype: 'button', + text: gettext('Upload'), + disabled: !me.enableUploadButton, + handler: function () { + Ext.create('PVE.window.UploadToStorage', { + nodename: nodename, + storage: storage, + content: content, + autoShow: true, + taskDone: () => reload(), + }); + }, + }, + { + xtype: 'button', + text: gettext('Download from URL'), + disabled: !me.enableDownloadUrlButton, + handler: function () { + Ext.create('PVE.window.DownloadUrlToStorage', { + nodename: nodename, + storage: storage, + content: content, + autoShow: true, + taskDone: () => reload(), + }); + }, + }, + '-', + ); + } + if (!me.useCustomRemoveButton) { + tbar.push({ + xtype: 'proxmoxStdRemoveButton', + selModel: sm, + enableFn: (rec) => !rec?.data?.protected, + delay: 5, + callback: () => reload(), + baseurl: baseurl + '/', + }); + } + tbar.push('->', gettext('Search') + ':', ' ', { + xtype: 'textfield', + width: 200, + enableKeyEvents: true, + emptyText: + content === 'backup' ? gettext('Name, Format, Notes') : gettext('Name, Format'), + listeners: { + keyup: { + buffer: 500, + fn: function (field) { + let needle = field.getValue().toLocaleLowerCase(); + store.clearFilter(true); + store.filter([ + { + filterFn: ({ data }) => + data.text?.toLocaleLowerCase().includes(needle) || + data.notes?.toLocaleLowerCase().includes(needle), + }, + ]); + }, + }, + change: function (field, newValue, oldValue) { + if (newValue !== this.originalValue) { + this.triggers.clear.setVisible(true); + } + }, + }, + triggers: { + clear: { + cls: 'pmx-clear-trigger', + weight: -1, + hidden: true, + handler: function () { + this.triggers.clear.setVisible(false); + this.setValue(this.originalValue); + store.clearFilter(); + }, + }, + }, + }); + + let availableColumns = { + name: { + header: gettext('Name'), + flex: 2, + sortable: true, + renderer: PVE.Utils.render_storage_content, + sorter: (a, b) => + a.data.text + .toString() + .localeCompare(b.data.text.toString(), undefined, { numeric: true }), + dataIndex: 'text', + }, + notes: { + header: gettext('Notes'), + flex: 1, + renderer: Ext.htmlEncode, + dataIndex: 'notes', + }, + protected: { + header: ``, + tooltip: gettext('Protected'), + width: 30, + renderer: (v) => + v ? `` : '', + sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0), + dataIndex: 'protected', + }, + date: { + header: gettext('Date'), + width: 150, + dataIndex: 'vdate', + }, + format: { + header: gettext('Format'), + width: 100, + dataIndex: 'format', + }, + size: { + header: gettext('Size'), + width: 100, + renderer: Proxmox.Utils.format_size, + dataIndex: 'size', + }, + }; + + let showColumns = me.showColumns || ['name', 'date', 'format', 'size']; + + Object.keys(availableColumns).forEach(function (key) { + if (!showColumns.includes(key)) { + delete availableColumns[key]; + } + }); + + if (me.extraColumns && typeof me.extraColumns === 'object') { + Object.assign(availableColumns, me.extraColumns); + } + const columns = Object.values(availableColumns); + + Ext.apply(me, { + store, + selModel: sm, + tbar, + columns, + listeners: { + activate: reload, + itemdblclick: (view, record) => me.itemdblclick(view, record), + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-storage-content', { + extend: 'Ext.data.Model', + fields: [ + 'volid', + 'content', + 'format', + 'size', + 'used', + 'vmid', + 'channel', + 'id', + 'lun', + 'notes', + 'verification', + { + name: 'text', + convert: function (value, record) { + // check for volid, because if you click on a grouping header, + // it calls convert (but with an empty volid) + if (value || record.data.volid === null) { + return value; + } + return PVE.Utils.render_storage_content(value, {}, record); + }, + }, + { + name: 'vdate', + convert: function (value, record) { + // check for volid, because if you click on a grouping header, + // it calls convert (but with an empty volid) + if (value || record.data.volid === null) { + return value; + } + let t = record.data.content; + if (t === 'backup') { + let v = record.data.volid; + let match = v.match(/(\d{4}_\d{2}_\d{2})-(\d{2}_\d{2}_\d{2})/); + if (match) { + let date = match[1].replace(/_/g, '-'); + let time = match[2].replace(/_/g, ':'); + return date + ' ' + time; + } + } + if (record.data.ctime) { + let ctime = new Date(record.data.ctime * 1000); + return Ext.Date.format(ctime, 'Y-m-d H:i:s'); + } + return ''; + }, + }, + ], + idProperty: 'volid', + }); + }, +); +Ext.define('PVE.storage.BackupView', { + extend: 'PVE.storage.ContentView', + + onlineHelp: 'chapter_vzdump', + + alias: 'widget.pveStorageBackupView', + + showColumns: ['name', 'notes', 'protected', 'date', 'format', 'size'], + + initComponent: function () { + let me = this; + + let nodename = (me.nodename = me.pveSelNode.data.node); + if (!nodename) { + throw 'no node name specified'; + } + + let storage = (me.storage = me.pveSelNode.data.storage); + if (!storage) { + throw 'no storage ID specified'; + } + + me.content = 'backup'; + + let sm = (me.sm = Ext.create('Ext.selection.RowModel', {})); + + let pruneButton = Ext.create('Proxmox.button.Button', { + text: gettext('Prune group'), + disabled: true, + selModel: sm, + setBackupGroup: function (backup) { + if (backup) { + let name = backup.text; + let vmid = backup.vmid; + let format = backup.format; + + let vmtype; + if (name.startsWith('vzdump-lxc-') || format === 'pbs-ct') { + vmtype = 'lxc'; + } else if (name.startsWith('vzdump-qemu-') || format === 'pbs-vm') { + vmtype = 'qemu'; + } + + if (vmid && vmtype) { + this.setText(gettext('Prune group') + ` ${vmtype}/${vmid}`); + this.vmid = vmid; + this.vmtype = vmtype; + this.setDisabled(false); + return; + } + } + this.setText(gettext('Prune group')); + this.vmid = null; + this.vmtype = null; + this.setDisabled(true); + }, + handler: function (b, e, rec) { + Ext.create('PVE.window.Prune', { + autoShow: true, + nodename, + storage, + backup_id: this.vmid, + backup_type: this.vmtype, + listeners: { + destroy: () => me.store.load(), + }, + }); + }, + }); + + me.on('selectionchange', function (model, srecords, eOpts) { + if (srecords.length === 1) { + pruneButton.setBackupGroup(srecords[0].data); + } else { + pruneButton.setBackupGroup(null); + } + }); + + let isPBS = me.pluginType === 'pbs'; + + me.tbar = [ + { + xtype: 'proxmoxButton', + text: gettext('Restore'), + selModel: sm, + disabled: true, + handler: function (b, e, rec) { + let vmtype; + if (PVE.Utils.volume_is_qemu_backup(rec.data)) { + vmtype = 'qemu'; + } else if (PVE.Utils.volume_is_lxc_backup(rec.data)) { + vmtype = 'lxc'; + } else { + return; + } + + Ext.create('PVE.window.Restore', { + autoShow: true, + nodename, + volid: rec.data.volid, + volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec), + vmtype, + isPBS, + listeners: { + destroy: () => me.store.load(), + }, + }); + }, + }, + ]; + if (isPBS) { + me.tbar.push({ + xtype: 'proxmoxButton', + text: gettext('File Restore'), + disabled: true, + selModel: sm, + handler: function (b, e, rec) { + let isVMArchive = PVE.Utils.volume_is_qemu_backup( + rec.data.volid, + rec.data.format, + ); + Ext.create('Proxmox.window.FileBrowser', { + title: gettext('File Restore') + ' - ' + rec.data.text, + listURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/list`, + downloadURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/download`, + extraParams: { + volume: rec.data.volid, + }, + archive: isVMArchive ? 'all' : undefined, + autoShow: true, + }); + }, + }); + } + me.tbar.push( + { + xtype: 'proxmoxButton', + text: gettext('Show Configuration'), + disabled: true, + selModel: sm, + handler: function (b, e, rec) { + Ext.create('PVE.window.BackupConfig', { + autoShow: true, + volume: rec.data.volid, + pveSelNode: me.pveSelNode, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit Notes'), + disabled: true, + selModel: sm, + handler: function (b, e, rec) { + let volid = rec.data.volid; + Ext.create('Proxmox.window.Edit', { + autoShow: true, + autoLoad: true, + width: 600, + height: 400, + resizable: true, + title: gettext('Notes'), + url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`, + layout: 'fit', + items: [ + { + xtype: 'textarea', + layout: 'fit', + name: 'notes', + height: '100%', + }, + ], + listeners: { + destroy: () => me.store.load(), + }, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Change Protection'), + disabled: true, + handler: function (button, event, record) { + const volid = record.data.volid; + Proxmox.Utils.API2Request({ + url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`, + method: 'PUT', + waitMsgTarget: me, + params: { protected: record.data.protected ? 0 : 1 }, + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + success: () => { + me.store.load({ + callback: () => sm.fireEvent('selectionchange', sm, [record]), + }); + }, + }); + }, + }, + '-', + pruneButton, + ); + + me.extraColumns = {}; + + if (isPBS) { + me.extraColumns.encrypted = { + header: gettext('Encrypted'), + dataIndex: 'encrypted', + renderer: PVE.Utils.render_backup_encryption, + sorter: { + property: 'encrypted', + transform: (encrypted) => (encrypted ? 1 : 0), + }, + }; + me.extraColumns.verification = { + // TRANSLATORS: The state of the verification task + header: gettext('Verify State'), + dataIndex: 'verification', + renderer: PVE.Utils.render_backup_verification, + sorter: { + property: 'verification', + transform: (value) => { + let state = value?.state ?? 'none'; + let order = PVE.Utils.verificationStateOrder; + return order[state] ?? order.__default__; + }, + }, + }; + } + + me.extraColumns.vmid = { + header: 'VMID', + dataIndex: 'vmid', + hidden: true, + sorter: (a, b) => (a.data.vmid ?? 0) - (b.data.vmid ?? 0), + }; + + me.callParent(); + + me.store.getSorters().clear(); + me.store.setSorters([ + { + property: 'vdate', + direction: 'DESC', + }, + ]); + }, +}); +Ext.define('PVE.panel.StorageBase', { + extend: 'Proxmox.panel.InputPanel', + controller: 'storageEdit', + + type: '', + + onGetValues: function (values) { + let me = this; + + if (me.isCreate) { + values.type = me.type; + } else { + delete values.storage; + } + + values.disable = values.enable ? 0 : 1; + delete values.enable; + + return values; + }, + + initComponent: function () { + let me = this; + + me.column1.unshift({ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'storage', + value: me.storageId || '', + fieldLabel: 'ID', + vtype: 'StorageId', + allowBlank: false, + }); + + me.column2 = me.column2 || []; + me.column2.unshift( + { + xtype: 'pveNodeSelector', + name: 'nodes', + reference: 'storageNodeRestriction', + disabled: me.storageId === 'local', + fieldLabel: gettext('Nodes'), + emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')', + multiSelect: true, + autoSelect: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'enable', + checked: true, + uncheckedValue: 0, + fieldLabel: gettext('Enable'), + }, + ); + + const addAdvancedWidget = (widget) => { + me.advancedColumn1 = me.advancedColumn1 || []; + me.advancedColumn2 = me.advancedColumn2 || []; + if (me.advancedColumn2.length < me.advancedColumn1.length) { + me.advancedColumn2.unshift(widget); + } else { + me.advancedColumn1.unshift(widget); + } + }; + + const qemuImgStorageTypes = ['dir', 'btrfs', 'nfs', 'cifs']; + + if (qemuImgStorageTypes.includes(me.type)) { + addAdvancedWidget({ + xtype: 'pvePreallocationSelector', + name: 'preallocation', + fieldLabel: gettext('Preallocation'), + allowBlank: false, + deleteEmpty: !me.isCreate, + value: '__default__', + }); + } + + const externalStorageManagedSnapshotSupport = ['dir', 'nfs', 'cifs', 'lvm']; + + if (externalStorageManagedSnapshotSupport.includes(me.type)) { + addAdvancedWidget({ + xtype: 'proxmoxcheckbox', + name: 'snapshot-as-volume-chain', + // TRANSLATORS: As in "a chain of volumes, each referencing the next one". + boxLabel: gettext('Allow Snapshots as Volume-Chain'), + deleteEmpty: !me.isCreate, + // can only allow to enable this on creation for storages that previously already + // supported qcow2 to avoid ambiguity with existing volumes. + disabled: !me.isCreate && me.type !== 'lvm', + checked: false, + }); + + me.advancedColumnB = me.advancedColumnB || []; + if (me.type === 'lvm') { + me.advancedColumnB.unshift({ + xtype: 'displayfield', + name: 'external-snapshot-hint-lvm', + userCls: 'pmx-hint', + value: gettext('Keep Snapshots as Volume-Chain enabled if qcow2 images exist!'), + }); + } + me.advancedColumnB.unshift({ + xtype: 'displayfield', + name: 'external-snapshot-hint', + userCls: 'pmx-hint', + value: gettext('Snapshots as Volume-Chain are a technology preview.'), + }); + } + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.BaseEdit', { + extend: 'Proxmox.window.Edit', + + apiCallDone: function (success, response, options) { + let me = this; + if (typeof me.ipanel.apiCallDone === 'function') { + me.ipanel.apiCallDone(success, response, options); + } + }, + + initComponent: function () { + let me = this; + + me.isCreate = !me.storageId; + + if (me.isCreate) { + me.url = '/api2/extjs/storage'; + me.method = 'POST'; + } else { + me.url = '/api2/extjs/storage/' + me.storageId; + me.method = 'PUT'; + } + + me.ipanel = Ext.create(me.paneltype, { + title: gettext('General'), + type: me.type, + isCreate: me.isCreate, + storageId: me.storageId, + }); + + Ext.apply(me, { + subject: PVE.Utils.format_storage_type(me.type), + isAdd: true, + bodyPadding: 0, + items: { + xtype: 'tabpanel', + region: 'center', + layout: 'fit', + bodyPadding: 10, + items: [ + me.ipanel, + { + xtype: 'pveBackupJobPrunePanel', + title: gettext('Backup Retention'), + hasMaxProtected: true, + isCreate: me.isCreate, + keepAllDefaultForCreate: true, + showPBSHint: me.ipanel.isPBS, + fallbackHintHtml: gettext( + "Without any keep option, the node's vzdump.conf or `keep-all` is used as fallback for backup jobs", + ), + }, + ], + }, + }); + + if (me.ipanel.extraTabs) { + me.ipanel.extraTabs.forEach((panel) => { + panel.isCreate = me.isCreate; + me.items.items.push(panel); + }); + } + + me.callParent(); + + if (!me.canDoBackups) { + // cannot mask now, not fully rendered until activated + me.down('pmxPruneInputPanel').needMask = true; + } + + if (!me.isCreate) { + me.load({ + success: function (response, options) { + let values = response.result.data; + let ctypes = values.content || ''; + + values.content = ctypes.split(','); + + if (values.nodes) { + values.nodes = values.nodes.split(','); + } + values.enable = values.disable ? 0 : 1; + if (values['prune-backups']) { + let retention = PVE.Parser.parsePropertyString(values['prune-backups']); + delete values['prune-backups']; + Object.assign(values, retention); + } + + me.query('inputpanel').forEach((panel) => { + panel.setValues(values); + }); + }, + }); + } + }, +}); +Ext.define('PVE.storage.Browser', { + extend: 'PVE.panel.Config', + alias: 'widget.PVE.storage.Browser', + + onlineHelp: 'chapter_storage', + + initComponent: function () { + let me = this; + + let nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + let storeid = me.pveSelNode.data.storage; + if (!storeid) { + throw 'no storage ID specified'; + } + + let storageInfo = PVE.data.ResourceStore.findRecord( + 'id', + `storage/${nodename}/${storeid}`, + 0, // startIndex + false, // anyMatch + true, // caseSensitive + true, // exactMatch + ); + let res = storageInfo.data; + let plugin = res.plugintype; + + let isEsxi = plugin === 'esxi'; + + me.items = !isEsxi + ? [ + { + title: gettext('Summary'), + xtype: 'pveStorageSummary', + iconCls: 'fa fa-book', + itemId: 'summary', + }, + ] + : []; + + let caps = Ext.state.Manager.get('GuiCap'); + + Ext.apply(me, { + title: Ext.String.format( + gettext('Storage {0} on node {1}'), + `'${storeid}'`, + `'${nodename}'`, + ), + hstateid: 'storagetab', + }); + + if ( + caps.storage['Datastore.Allocate'] || + caps.storage['Datastore.AllocateSpace'] || + caps.storage['Datastore.Audit'] + ) { + let contents = res.content.split(','); + + let enableUpload = !!caps.storage['Datastore.AllocateTemplate']; + let enableDownloadUrl = + enableUpload && + (!!(caps.nodes['Sys.Audit'] && caps.nodes['Sys.Modify']) || // for backward compat + !!caps.nodes['Sys.AccessNetwork']); // new explicit priv for querying (local) networks + + if (contents.includes('backup')) { + me.items.push({ + xtype: 'pveStorageBackupView', + title: gettext('Backups'), + iconCls: 'fa fa-floppy-o', + itemId: 'contentBackup', + pluginType: plugin, + }); + } + if (contents.includes('images')) { + me.items.push({ + xtype: 'pveStorageImageView', + title: gettext('VM Disks'), + iconCls: 'fa fa-hdd-o', + itemId: 'contentImages', + content: 'images', + pluginType: plugin, + }); + } + if (contents.includes('rootdir')) { + me.items.push({ + xtype: 'pveStorageImageView', + title: gettext('CT Volumes'), + iconCls: 'fa fa-hdd-o lxc', + itemId: 'contentRootdir', + content: 'rootdir', + pluginType: plugin, + }); + } + if (contents.includes('iso')) { + me.items.push({ + xtype: 'pveStorageContentView', + title: gettext('ISO Images'), + iconCls: 'pve-itype-treelist-item-icon-cdrom', + itemId: 'contentIso', + content: 'iso', + pluginType: plugin, + enableUploadButton: enableUpload, + enableDownloadUrlButton: enableDownloadUrl, + useUploadButton: true, + }); + } + if (contents.includes('vztmpl')) { + me.items.push({ + xtype: 'pveStorageTemplateView', + title: gettext('CT Templates'), + iconCls: 'fa fa-file-o lxc', + itemId: 'contentVztmpl', + pluginType: plugin, + enableUploadButton: enableUpload, + enableDownloadUrlButton: enableDownloadUrl, + useUploadButton: true, + }); + } + if (contents.includes('snippets')) { + me.items.push({ + xtype: 'pveStorageContentView', + title: gettext('Snippets'), + iconCls: 'fa fa-file-code-o', + itemId: 'contentSnippets', + content: 'snippets', + pluginType: plugin, + }); + } + if (contents.includes('import')) { + let isImportable = (format) => + ['ova', 'ovf', 'vmx', 'raw', 'qcow2', 'vmdk'].indexOf(format) !== -1; + let createGuestImportWindow = (selection) => { + if (!selection) { + return; + } + + let volumeName = selection.data.volid.replace(/^.*?:/, ''); + + if (['raw', 'vmdk', 'qcow2'].indexOf(selection.data.format) !== -1) { + Ext.create('PVE.qemu.HDImportEdit', { + selection: selection.data.volid, + nodename, + autoShow: true, + }); + } else { + Ext.create('PVE.window.GuestImport', { + storage: storeid, + volumeName, + nodename, + autoShow: true, + }); + } + }; + me.items.push({ + xtype: 'pveStorageContentView', + // each gettext needs to be in a separate line + title: isEsxi ? gettext('Virtual Guests') : gettext('Import'), + iconCls: isEsxi ? 'fa fa-desktop' : 'fa fa-cloud-download', + itemId: 'contentImport', + content: 'import', + useCustomRemoveButton: isEsxi, // hide default remove button for esxi + showColumns: isEsxi ? ['name', 'format'] : ['name', 'size', 'format'], + enableUploadButton: enableUpload && !isEsxi, + enableDownloadUrlButton: enableDownloadUrl && !isEsxi, + useUploadButton: !isEsxi, + itemdblclick: (view, record) => { + if (isImportable(record.data.format)) { + createGuestImportWindow(record); + } + }, + tbar: [ + { + xtype: 'proxmoxButton', + disabled: true, + text: gettext('Import'), + iconCls: 'fa fa-cloud-download', + enableFn: (rec) => isImportable(rec.data.format), + handler: function () { + let grid = this.up('pveStorageContentView'); + let selection = grid.getSelection()?.[0]; + + createGuestImportWindow(selection); + }, + }, + ], + pluginType: plugin, + }); + } + } + + if (caps.storage['Permissions.Modify']) { + me.items.push({ + xtype: 'pveACLView', + title: gettext('Permissions'), + iconCls: 'fa fa-unlock', + itemId: 'permissions', + path: `/storage/${storeid}`, + }); + } + + me.callParent(); + }, +}); +Ext.define('PVE.storage.CIFSScan', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveCIFSScan', + + queryParam: 'server', + + valueField: 'share', + displayField: 'share', + matchFieldWidth: false, + listConfig: { + loadingText: gettext('Scanning...'), + width: 350, + }, + doRawQuery: Ext.emptyFn, + + onTriggerClick: function () { + var me = this; + + if (!me.queryCaching || me.lastQuery !== me.cifsServer) { + me.store.removeAll(); + } + + var params = {}; + if (me.cifsUsername) { + params.username = me.cifsUsername; + } + if (me.cifsPassword) { + params.password = me.cifsPassword; + } + if (me.cifsDomain) { + params.domain = me.cifsDomain; + } + + me.store.getProxy().setExtraParams(params); + me.allQuery = me.cifsServer; + + me.callParent(); + }, + + resetProxy: function () { + let me = this; + me.lastQuery = null; + if (!me.readOnly && !me.disabled) { + if (me.isExpanded) { + me.collapse(); + } + } + }, + + setServer: function (server) { + if (this.cifsServer !== server) { + this.cifsServer = server; + this.resetProxy(); + } + }, + setUsername: function (username) { + if (this.cifsUsername !== username) { + this.cifsUsername = username; + this.resetProxy(); + } + }, + setPassword: function (password) { + if (this.cifsPassword !== password) { + this.cifsPassword = password; + this.resetProxy(); + } + }, + setDomain: function (domain) { + if (this.cifsDomain !== domain) { + this.cifsDomain = domain; + this.resetProxy(); + } + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + + let store = Ext.create('Ext.data.Store', { + fields: ['description', 'share'], + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/scan/cifs', + }, + }); + store.sort('share', 'ASC'); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + + let picker = me.getPicker(); + // don't use monStoreErrors directly, it doesn't copes well with comboboxes + picker.mon(store, 'beforeload', function (s, operation, eOpts) { + picker.unmask(); + delete picker.minHeight; + }); + picker.mon(store.proxy, 'afterload', function (proxy, request, success) { + if (success) { + Proxmox.Utils.setErrorMask(picker, false); + return; + } + let error = request._operation.getError(); + let msg = Proxmox.Utils.getResponseErrorMessage(error); + if (msg) { + picker.minHeight = 100; + } + Proxmox.Utils.setErrorMask(picker, msg); + }); + }, +}); + +Ext.define('PVE.storage.CIFSInputPanel', { + extend: 'PVE.panel.StorageBase', + + onlineHelp: 'storage_cifs', + + onGetValues: function (values) { + let me = this; + + if (values.password?.length === 0) { + delete values.password; + } + if (values.username?.length === 0) { + delete values.username; + } + if (values.subdir?.length === 0) { + delete values.subdir; + } + + return me.callParent([values]); + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'server', + value: '', + fieldLabel: gettext('Server'), + allowBlank: false, + listeners: { + change: function (f, value) { + if (me.isCreate) { + let exportField = me.down('field[name=share]'); + exportField.setServer(value); + } + }, + }, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'username', + value: '', + fieldLabel: gettext('Username'), + emptyText: gettext('Guest user'), + listeners: { + change: function (f, value) { + if (!me.isCreate) { + return; + } + var exportField = me.down('field[name=share]'); + exportField.setUsername(value); + }, + }, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + inputType: 'password', + name: 'password', + value: me.isCreate ? '' : '********', + emptyText: me.isCreate ? gettext('None') : '', + fieldLabel: gettext('Password'), + minLength: 1, + listeners: { + change: function (f, value) { + let exportField = me.down('field[name=share]'); + exportField.setPassword(value); + }, + }, + }, + { + xtype: me.isCreate ? 'pveCIFSScan' : 'displayfield', + name: 'share', + value: '', + fieldLabel: 'Share', + allowBlank: false, + }, + ]; + + me.column2 = [ + { + xtype: 'pveContentTypeSelector', + name: 'content', + value: 'images', + multiSelect: true, + fieldLabel: gettext('Content'), + allowBlank: false, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'domain', + value: me.isCreate ? '' : undefined, + fieldLabel: gettext('Domain'), + allowBlank: true, + listeners: { + change: function (f, value) { + if (me.isCreate) { + let exportField = me.down('field[name=share]'); + exportField.setDomain(value); + } + }, + }, + }, + { + xtype: 'pmxDisplayEditField', + editable: me.isCreate, + name: 'subdir', + fieldLabel: gettext('Subdirectory'), + allowBlank: true, + emptyText: gettext('/some/path'), + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.CephFSInputPanel', { + extend: 'PVE.panel.StorageBase', + controller: 'cephstorage', + + onlineHelp: 'storage_cephfs', + + viewModel: { + type: 'cephstorage', + }, + + setValues: function (values) { + if (values.monhost) { + this.viewModel.set('pveceph', false); + this.lookupReference('pvecephRef').setValue(false); + this.lookupReference('pvecephRef').resetOriginalValue(); + } + this.callParent([values]); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + me.type = 'cephfs'; + + me.column1 = []; + + me.column1.push( + { + xtype: 'textfield', + name: 'monhost', + vtype: 'HostList', + value: '', + bind: { + disabled: '{pveceph}', + submitValue: '{!pveceph}', + hidden: '{pveceph}', + }, + fieldLabel: 'Monitor(s)', + allowBlank: false, + }, + { + xtype: 'displayfield', + reference: 'monhost', + bind: { + disabled: '{!pveceph}', + hidden: '{!pveceph}', + }, + value: '', + fieldLabel: 'Monitor(s)', + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'username', + value: 'admin', + bind: { + disabled: '{pveceph}', + submitValue: '{!pveceph}', + }, + fieldLabel: gettext('User name'), + allowBlank: true, + }, + ); + + if (me.isCreate) { + me.column1.push( + { + xtype: 'pveCephFSSelector', + nodename: me.nodename, + name: 'fs-name', + bind: { + disabled: '{!pveceph}', + submitValue: '{pveceph}', + hidden: '{!pveceph}', + }, + fieldLabel: gettext('FS Name'), + allowBlank: false, + }, + { + xtype: 'textfield', + nodename: me.nodename, + name: 'fs-name', + bind: { + disabled: '{pveceph}', + submitValue: '{!pveceph}', + hidden: '{pveceph}', + }, + fieldLabel: gettext('FS Name'), + }, + ); + } + + me.column2 = [ + { + xtype: 'pveContentTypeSelector', + cts: ['backup', 'iso', 'vztmpl', 'snippets', 'import'], + fieldLabel: gettext('Content'), + name: 'content', + value: 'backup', + multiSelect: true, + allowBlank: false, + }, + ]; + + me.columnB = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'keyring', + fieldLabel: gettext('Secret Key'), + value: me.isCreate ? '' : '***********', + allowBlank: false, + bind: { + hidden: '{pveceph}', + disabled: '{pveceph}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'pveceph', + reference: 'pvecephRef', + bind: { + disabled: '{!pvecephPossible}', + value: '{pveceph}', + }, + checked: true, + uncheckedValue: 0, + submitValue: false, + hidden: !me.isCreate, + boxLabel: gettext('Use Proxmox VE managed hyper-converged cephFS'), + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.DirInputPanel', { + extend: 'PVE.panel.StorageBase', + + onlineHelp: 'storage_directory', + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'path', + value: '', + fieldLabel: gettext('Directory'), + allowBlank: false, + }, + { + xtype: 'pveContentTypeSelector', + name: 'content', + value: 'images', + multiSelect: true, + fieldLabel: gettext('Content'), + allowBlank: false, + }, + ]; + + me.column2 = [ + { + xtype: 'proxmoxcheckbox', + name: 'shared', + uncheckedValue: 0, + fieldLabel: gettext('Shared'), + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Enable if the underlying file system is already shared between nodes.', + ), + }, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.ImageView', { + extend: 'PVE.storage.ContentView', + + alias: 'widget.pveStorageImageView', + + initComponent: function () { + var me = this; + + var nodename = (me.nodename = me.pveSelNode.data.node); + if (!me.nodename) { + throw 'no node name specified'; + } + + var storage = (me.storage = me.pveSelNode.data.storage); + if (!me.storage) { + throw 'no storage ID specified'; + } + + if (!me.content || (me.content !== 'images' && me.content !== 'rootdir')) { + throw "content needs to be either 'images' or 'rootdir'"; + } + + var sm = (me.sm = Ext.create('Ext.selection.RowModel', {})); + + var reload = function () { + me.store.load(); + }; + + me.tbar = [ + { + xtype: 'proxmoxButton', + selModel: sm, + text: gettext('Remove'), + disabled: true, + handler: function (btn, event, rec) { + let url = `/nodes/${nodename}/storage/${storage}/content/${rec.data.volid}`; + var vmid = rec.data.vmid; + + var store = PVE.data.ResourceStore; + + if (vmid && store.findVMID(vmid)) { + let guest_node = store.guestNode(vmid); + let storage_path = 'storage/' + nodename + '/' + storage; + + // allow to delete local backed images if a VMID exists on another node. + if (store.storageIsShared(storage_path) || guest_node === nodename) { + let msg = Ext.String.format( + gettext("Cannot remove image, a guest with VMID '{0}' exists!"), + vmid, + ); + msg += + '
    ' + + gettext("You can delete the image from the guest's hardware pane"); + + Ext.Msg.show({ + title: gettext('Cannot remove disk image.'), + icon: Ext.Msg.ERROR, + msg: msg, + }); + return; + } + } + var win = Ext.create('Proxmox.window.SafeDestroy', { + title: Ext.String.format(gettext("Destroy '{0}'"), rec.data.volid), + showProgress: true, + url: url, + item: { type: 'Image', id: vmid }, + taskName: 'unknownimgdel', + }).show(); + win.on('destroy', reload); + }, + }, + ]; + me.useCustomRemoveButton = true; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.IScsiScan', { + extend: 'PVE.form.ComboBoxSetStoreNode', + alias: 'widget.pveIScsiScan', + + queryParam: 'portal', + valueField: 'target', + displayField: 'target', + matchFieldWidth: false, + allowBlank: false, + + listConfig: { + width: 350, + columns: [ + { + dataIndex: 'target', + flex: 1, + }, + ], + emptyText: PVE.Utils.renderNotFound(gettext('iSCSI Target')), + }, + + config: { + apiSuffix: '/scan/iscsi', + }, + + showNodeSelector: true, + + reload: function () { + let me = this; + if (!me.isDisabled()) { + me.getStore().load(); + } + }, + + setPortal: function (portal) { + let me = this; + me.portal = portal; + me.getStore().getProxy().setExtraParams({ portal }); + me.reload(); + }, + + setNodeName: function (value) { + let me = this; + me.callParent([value]); + me.reload(); + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + + let store = Ext.create('Ext.data.Store', { + fields: ['target', 'portal'], + proxy: { + type: 'proxmox', + url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, + }, + }); + store.sort('target', 'ASC'); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.IScsiInputPanel', { + extend: 'PVE.panel.StorageBase', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'storage_open_iscsi', + + onGetValues: function (values) { + let me = this; + + values.content = values.luns ? 'images' : 'none'; + delete values.luns; + + return me.callParent([values]); + }, + + setValues: function (values) { + values.luns = values.content.indexOf('images') !== -1; + this.callParent([values]); + }, + + column1: [ + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{isCreate}', + }, + + name: 'portal', + value: '', + fieldLabel: 'Portal', + allowBlank: false, + + editConfig: { + listeners: { + change: { + fn: function (f, value) { + let panel = this.up('inputpanel'); + let exportField = panel.lookup('iScsiTargetScan'); + if (exportField) { + exportField.setDisabled(!value); + exportField.setPortal(value); + exportField.setValue(''); + } + }, + buffer: 500, + }, + }, + }, + }, + { + cbind: { + xtype: (get) => (get('isCreate') ? 'pveIScsiScan' : 'displayfield'), + readOnly: '{!isCreate}', + disabled: '{isCreate}', + }, + + name: 'target', + value: '', + fieldLabel: gettext('Target'), + allowBlank: false, + reference: 'iScsiTargetScan', + listeners: { + nodechanged: function (value) { + this.up('inputpanel').lookup('storageNodeRestriction').setValue(value); + }, + }, + }, + ], + + column2: [ + { + xtype: 'checkbox', + name: 'luns', + checked: true, + fieldLabel: gettext('Use LUNs directly'), + }, + ], +}); +Ext.define('PVE.storage.VgSelector', { + extend: 'PVE.form.ComboBoxSetStoreNode', + alias: 'widget.pveVgSelector', + valueField: 'vg', + displayField: 'vg', + queryMode: 'local', + editable: false, + + listConfig: { + columns: [ + { + dataIndex: 'vg', + flex: 1, + }, + ], + emptyText: PVE.Utils.renderNotFound('VGs'), + }, + + config: { + apiSuffix: '/scan/lvm', + }, + + showNodeSelector: true, + + setNodeName: function (value) { + let me = this; + me.callParent([value]); + me.getStore().load(); + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + + let store = Ext.create('Ext.data.Store', { + autoLoad: {}, // true, + fields: ['vg', 'size', 'free'], + proxy: { + type: 'proxmox', + url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, + }, + }); + + store.sort('vg', 'ASC'); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.BaseStorageSelector', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveBaseStorageSelector', + + existingGroupsText: gettext('Existing volume groups'), + queryMode: 'local', + editable: false, + value: '', + valueField: 'storage', + displayField: 'text', + initComponent: function () { + let me = this; + + let store = Ext.create('Ext.data.Store', { + autoLoad: { + addRecords: true, + params: { + type: 'iscsi', + }, + }, + fields: [ + 'storage', + 'type', + 'content', + { + name: 'text', + convert: function (value, record) { + if (record.data.storage) { + return record.data.storage + ' (iSCSI)'; + } else { + return me.existingGroupsText; + } + }, + }, + ], + proxy: { + type: 'proxmox', + url: '/api2/json/storage/', + }, + }); + + store.loadData([{ storage: '' }], true); + + store.sort('storage', 'ASC'); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.LunSelector', { + extend: 'PVE.form.FileSelector', + alias: 'widget.pveStorageLunSelector', + + nodename: 'localhost', + storageContent: 'images', + allowBlank: false, + + initComponent: function () { + let me = this; + + if (!PVE.Utils.isStandaloneNode()) { + me.errorHeight = 140; + Ext.apply(me.listConfig ?? {}, { + tbar: { + xtype: 'toolbar', + items: [ + { + xtype: 'pveStorageScanNodeSelector', + autoSelect: false, + fieldLabel: gettext('Node to scan'), + listeners: { + change: (_field, value) => me.setNodename(value), + }, + }, + ], + }, + emptyText: me.listConfig?.emptyText ?? PVE.Utils.renderNotFound(gettext('Volume')), + }); + } + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.LVMInputPanel', { + extend: 'PVE.panel.StorageBase', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'storage_lvm', + + column1: [ + { + xtype: 'pveBaseStorageSelector', + name: 'basesel', + fieldLabel: gettext('Base storage'), + cbind: { + disabled: '{!isCreate}', + hidden: '{!isCreate}', + }, + submitValue: false, + listeners: { + change: function (f, value) { + let me = this; + let vgField = me.up('inputpanel').lookup('volumeGroupSelector'); + let vgNameField = me.up('inputpanel').lookup('vgName'); + let baseField = me.up('inputpanel').lookup('lunSelector'); + + vgField.setVisible(!value); + vgField.setDisabled(!!value); + + baseField.setVisible(!!value); + baseField.setDisabled(!value); + baseField.setStorage(value); + + vgNameField.setVisible(!!value); + vgNameField.setDisabled(!value); + }, + }, + }, + { + xtype: 'pveStorageLunSelector', + name: 'base', + fieldLabel: gettext('Base volume'), + reference: 'lunSelector', + hidden: true, + disabled: true, + }, + { + xtype: 'pveVgSelector', + name: 'vgname', + fieldLabel: gettext('Volume group'), + reference: 'volumeGroupSelector', + cbind: { + disabled: '{!isCreate}', + hidden: '{!isCreate}', + }, + allowBlank: false, + listeners: { + nodechanged: function (value) { + this.up('inputpanel').lookup('storageNodeRestriction').setValue(value); + }, + }, + }, + { + name: 'vgname', + fieldLabel: gettext('Volume group'), + reference: 'vgName', + cbind: { + xtype: (get) => (get('isCreate') ? 'textfield' : 'displayfield'), + hidden: '{isCreate}', + disabled: '{isCreate}', + }, + value: '', + allowBlank: false, + }, + { + xtype: 'pveContentTypeSelector', + cts: ['images', 'rootdir'], + fieldLabel: gettext('Content'), + name: 'content', + value: ['images', 'rootdir'], + multiSelect: true, + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'proxmoxcheckbox', + name: 'shared', + uncheckedValue: 0, + fieldLabel: gettext('Shared'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Enable if the LVM is located on a shared LUN.'), + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'saferemove', + uncheckedValue: 0, + fieldLabel: gettext('Wipe Removed Volumes'), + }, + ], +}); +Ext.define('PVE.storage.TPoolSelector', { + extend: 'PVE.form.ComboBoxSetStoreNode', + alias: 'widget.pveTPSelector', + + queryParam: 'vg', + valueField: 'lv', + displayField: 'lv', + editable: false, + allowBlank: false, + + listConfig: { + emptyText: PVE.Utils.renderNotFound('Thin-Pool'), + columns: [ + { + dataIndex: 'lv', + flex: 1, + }, + ], + }, + + config: { + apiSuffix: '/scan/lvmthin', + }, + + reload: function () { + let me = this; + if (!me.isDisabled()) { + me.getStore().load(); + } + }, + + setVG: function (myvg) { + let me = this; + me.vg = myvg; + me.getStore().getProxy().setExtraParams({ vg: myvg }); + me.reload(); + }, + + setNodeName: function (value) { + let me = this; + me.callParent([value]); + me.reload(); + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + + let store = Ext.create('Ext.data.Store', { + fields: ['lv'], + proxy: { + type: 'proxmox', + url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, + }, + }); + + store.sort('lv', 'ASC'); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.BaseVGSelector', { + extend: 'PVE.form.ComboBoxSetStoreNode', + alias: 'widget.pveBaseVGSelector', + + valueField: 'vg', + displayField: 'vg', + queryMode: 'local', + editable: false, + allowBlank: false, + + listConfig: { + columns: [ + { + dataIndex: 'vg', + flex: 1, + }, + ], + }, + + showNodeSelector: true, + + config: { + apiSuffix: '/scan/lvm', + }, + + setNodeName: function (value) { + let me = this; + me.callParent([value]); + me.getStore().load(); + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + + let store = Ext.create('Ext.data.Store', { + autoLoad: {}, + fields: ['vg', 'size', 'free'], + proxy: { + type: 'proxmox', + url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, + }, + }); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.LvmThinInputPanel', { + extend: 'PVE.panel.StorageBase', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'storage_lvmthin', + + column1: [ + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{isCreate}', + }, + + name: 'vgname', + fieldLabel: gettext('Volume group'), + + editConfig: { + xtype: 'pveBaseVGSelector', + listeners: { + nodechanged: function (value) { + let panel = this.up('inputpanel'); + panel.lookup('thinPoolSelector').setNodeName(value); + panel.lookup('storageNodeRestriction').setValue(value); + }, + change: function (f, value) { + let vgField = this.up('inputpanel').lookup('thinPoolSelector'); + if (vgField && !f.isDisabled()) { + vgField.setDisabled(!value); + vgField.setVG(value); + vgField.setValue(''); + } + }, + }, + }, + }, + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{isCreate}', + }, + + name: 'thinpool', + fieldLabel: gettext('Thin Pool'), + allowBlank: false, + + editConfig: { + xtype: 'pveTPSelector', + reference: 'thinPoolSelector', + disabled: true, + }, + }, + { + xtype: 'pveContentTypeSelector', + cts: ['images', 'rootdir'], + fieldLabel: gettext('Content'), + name: 'content', + value: ['images', 'rootdir'], + multiSelect: true, + allowBlank: false, + }, + ], +}); +Ext.define('PVE.storage.BTRFSInputPanel', { + extend: 'PVE.panel.StorageBase', + + onlineHelp: 'storage_btrfs', + + initComponent: function () { + let me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'path', + value: '', + fieldLabel: gettext('Path'), + allowBlank: false, + }, + { + xtype: 'pveContentTypeSelector', + name: 'content', + value: ['images', 'rootdir'], + multiSelect: true, + fieldLabel: gettext('Content'), + allowBlank: false, + }, + ]; + + me.columnB = [ + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: `BTRFS integration is currently a technology preview.`, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.NFSScan', { + extend: 'Ext.form.field.ComboBox', + alias: 'widget.pveNFSScan', + + queryParam: 'server', + + valueField: 'path', + displayField: 'path', + matchFieldWidth: false, + listConfig: { + loadingText: gettext('Scanning...'), + width: 350, + }, + doRawQuery: function () { + // do nothing + }, + + onTriggerClick: function () { + var me = this; + + if (!me.queryCaching || me.lastQuery !== me.nfsServer) { + me.store.removeAll(); + } + + me.allQuery = me.nfsServer; + + me.callParent(); + }, + + setServer: function (server) { + var me = this; + + me.nfsServer = server; + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + + var store = Ext.create('Ext.data.Store', { + fields: ['path', 'options'], + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/scan/nfs', + }, + }); + + store.sort('path', 'ASC'); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.NFSInputPanel', { + extend: 'PVE.panel.StorageBase', + + onlineHelp: 'storage_nfs', + + options: [], + + onGetValues: function (values) { + var me = this; + + var i; + var res = []; + for (i = 0; i < me.options.length; i++) { + let item = me.options[i]; + if (!item.match(/^vers=(.*)$/)) { + res.push(item); + } + } + if (values.nfsversion && values.nfsversion !== '__default__') { + res.push('vers=' + values.nfsversion); + } + delete values.nfsversion; + values.options = res.join(','); + if (values.options === '') { + delete values.options; + if (!me.isCreate) { + values.delete = 'options'; + } + } + + return me.callParent([values]); + }, + + setValues: function (values) { + var me = this; + if (values.options) { + me.options = values.options.split(','); + me.options.forEach(function (item) { + var match = item.match(/^vers=(.*)$/); + if (match) { + values.nfsversion = match[1]; + } + }); + } + return me.callParent([values]); + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'server', + value: '', + fieldLabel: gettext('Server'), + allowBlank: false, + listeners: { + change: function (f, value) { + if (me.isCreate) { + let exportField = me.down('field[name=export]'); + exportField.setServer(value); + exportField.setValue(''); + } + }, + }, + }, + { + xtype: me.isCreate ? 'pveNFSScan' : 'displayfield', + name: 'export', + value: '', + fieldLabel: 'Export', + allowBlank: false, + }, + { + xtype: 'pveContentTypeSelector', + name: 'content', + value: 'images', + multiSelect: true, + fieldLabel: gettext('Content'), + allowBlank: false, + }, + ]; + + me.advancedColumn2 = [ + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('NFS Version'), + name: 'nfsversion', + value: '__default__', + deleteEmpty: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['3', '3'], + ['4', '4'], + ['4.1', '4.1'], + ['4.2', '4.2'], + ], + }, + ]; + + me.callParent(); + }, +}); +/*global QRCode*/ +Ext.define('PVE.Storage.PBSKeyShow', { + extend: 'Ext.window.Window', + xtype: 'pvePBSKeyShow', + mixins: ['Proxmox.Mixin.CBind'], + + width: 600, + modal: true, + resizable: false, + title: gettext('Important: Save your Encryption Key'), + + // avoid that esc closes this by mistake, force user to more manual action + onEsc: Ext.emptyFn, + closable: false, + + items: [ + { + xtype: 'form', + layout: { + type: 'vbox', + align: 'stretch', + }, + bodyPadding: 10, + border: false, + defaults: { + anchor: '100%', + border: false, + padding: '10 0 0 0', + }, + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Key'), + labelWidth: 80, + inputId: 'encryption-key-value', + cbind: { + value: '{key}', + }, + editable: false, + }, + { + xtype: 'component', + html: + gettext( + 'Keep your encryption key safe, but easily accessible for disaster recovery.', + ) + + '
    ' + + gettext('We recommend the following safe-keeping strategy:'), + }, + { + xtyp: 'container', + layout: 'hbox', + items: [ + { + xtype: 'component', + html: '1. ' + gettext('Save the key in your password manager.'), + flex: 1, + }, + { + xtype: 'button', + text: gettext('Copy Key'), + iconCls: 'fa fa-clipboard x-btn-icon-el-default-toolbar-small', + cls: 'x-btn-default-toolbar-small proxmox-inline-button', + width: 110, + handler: function (b) { + document.getElementById('encryption-key-value').select(); + document.execCommand('copy'); + }, + }, + ], + }, + { + xtype: 'container', + layout: 'hbox', + items: [ + { + xtype: 'component', + html: + '2. ' + + gettext( + 'Download the key to a USB (pen) drive, placed in secure vault.', + ), + flex: 1, + }, + { + xtype: 'button', + text: gettext('Download'), + iconCls: 'fa fa-download x-btn-icon-el-default-toolbar-small', + cls: 'x-btn-default-toolbar-small proxmox-inline-button', + width: 110, + handler: function (b) { + let win = this.up('window'); + + let pveID = PVE.ClusterName || window.location.hostname; + let name = `pve-${pveID}-storage-${win.sid}.enc`; + + let hiddenElement = document.createElement('a'); + hiddenElement.href = 'data:attachment/text,' + encodeURI(win.key); + hiddenElement.target = '_blank'; + hiddenElement.download = name; + hiddenElement.click(); + }, + }, + ], + }, + { + xtype: 'container', + layout: 'hbox', + items: [ + { + xtype: 'component', + html: + '3. ' + + gettext('Print as paperkey, laminated and placed in secure vault.'), + flex: 1, + }, + { + xtype: 'button', + text: gettext('Print Key'), + iconCls: 'fa fa-print x-btn-icon-el-default-toolbar-small', + cls: 'x-btn-default-toolbar-small proxmox-inline-button', + width: 110, + handler: function (b) { + let win = this.up('window'); + win.paperkey(win.key); + }, + }, + ], + }, + ], + }, + { + xtype: 'component', + border: false, + padding: '10 10 10 10', + userCls: 'pmx-hint', + html: gettext( + 'Please save the encryption key - losing it will render any backup created with it unusable', + ), + }, + ], + buttons: [ + { + text: gettext('Close'), + handler: function (b) { + let win = this.up('window'); + win.close(); + }, + }, + ], + paperkey: function (keyString) { + let me = this; + + const key = JSON.parse(keyString); + + const qrwidth = 500; + let qrdiv = document.createElement('div'); + let qrcode = new QRCode(qrdiv, { + width: qrwidth, + height: qrwidth, + correctLevel: QRCode.CorrectLevel.H, + }); + qrcode.makeCode(keyString); + + let shortKeyFP = ''; + if (key.fingerprint) { + shortKeyFP = PVE.Utils.render_pbs_fingerprint(key.fingerprint); + } + + let printFrame = document.createElement('iframe'); + Object.assign(printFrame.style, { + position: 'fixed', + right: '0', + bottom: '0', + width: '0', + height: '0', + border: '0', + }); + const prettifiedKey = JSON.stringify(key, null, 2); + const keyQrBase64 = qrdiv.children[0].toDataURL('image/png'); + const html = ` +

    Encryption Key - Storage '${me.sid}' (${shortKeyFP})

    +

    +-----BEGIN PROXMOX BACKUP KEY----- +${prettifiedKey} +-----END PROXMOX BACKUP KEY-----

    +
    + `; + + printFrame.src = 'data:text/html;base64,' + btoa(html); + document.body.appendChild(printFrame); + me.on('destroy', () => document.body.removeChild(printFrame)); + }, +}); + +Ext.define('PVE.panel.PBSEncryptionKeyTab', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pvePBSEncryptionKeyTab', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'storage_pbs_encryption', + + onGetValues: function (form) { + let values = {}; + if (form.cryptMode === 'upload') { + values['encryption-key'] = form['crypt-key-upload']; + } else if (form.cryptMode === 'autogenerate') { + values['encryption-key'] = 'autogen'; + } else if (form.cryptMode === 'none') { + if (!this.isCreate) { + values.delete = ['encryption-key']; + } + } + return values; + }, + + setValues: function (values) { + let me = this; + let vm = me.getViewModel(); + + let cryptKeyInfo = values['encryption-key']; + if (cryptKeyInfo) { + let icon = ' '; + if (cryptKeyInfo.match(/^[a-fA-F0-9]{2}:/)) { + // new style fingerprint + let shortKeyFP = PVE.Utils.render_pbs_fingerprint(cryptKeyInfo); + values['crypt-key-fp'] = + icon + `${gettext('Active')} - ${gettext('Fingerprint')} ${shortKeyFP}`; + } else { + // old key without FP + values['crypt-key-fp'] = icon + gettext('Active'); + } + values.cryptMode = 'keep'; + values['crypt-allow-edit'] = false; + } else { + values['crypt-key-fp'] = gettext('None'); + let cryptModeNone = me.down('radiofield[inputValue=none]'); + cryptModeNone.setBoxLabel(gettext('Do not encrypt backups')); + values.cryptMode = 'none'; + values['crypt-allow-edit'] = true; + } + vm.set('keepCryptVisible', !!cryptKeyInfo); + vm.set('allowEdit', !cryptKeyInfo); + + me.callParent([values]); + }, + + viewModel: { + data: { + allowEdit: true, + keepCryptVisible: false, + }, + formulas: { + showDangerousHint: (get) => { + let allowEdit = get('allowEdit'); + return get('keepCryptVisible') && allowEdit; + }, + }, + }, + + items: [ + { + xtype: 'displayfield', + name: 'crypt-key-fp', + fieldLabel: gettext('Encryption Key'), + padding: '2 0', + }, + { + xtype: 'checkbox', + name: 'crypt-allow-edit', + boxLabel: gettext('Edit existing encryption key (dangerous!)'), + hidden: true, + submitValue: false, + isDirty: () => false, + bind: { + hidden: '{!keepCryptVisible}', + value: '{allowEdit}', + }, + }, + { + xtype: 'radiofield', + name: 'cryptMode', + inputValue: 'keep', + boxLabel: gettext('Keep encryption key'), + padding: '0 0 0 25', + cbind: { + hidden: '{isCreate}', + }, + bind: { + hidden: '{!keepCryptVisible}', + disabled: '{!allowEdit}', + }, + }, + { + xtype: 'radiofield', + name: 'cryptMode', + inputValue: 'none', + checked: true, + padding: '0 0 0 25', + cbind: { + disabled: '{!isCreate}', + checked: '{isCreate}', + boxLabel: (get) => + get('isCreate') + ? gettext('Do not encrypt backups') + : gettext('Delete existing encryption key'), + }, + bind: { + disabled: '{!allowEdit}', + }, + }, + { + xtype: 'radiofield', + name: 'cryptMode', + inputValue: 'autogenerate', + boxLabel: gettext('Auto-generate a client encryption key'), + padding: '0 0 0 25', + cbind: { + disabled: '{!isCreate}', + }, + bind: { + disabled: '{!allowEdit}', + }, + }, + { + xtype: 'radiofield', + name: 'cryptMode', + inputValue: 'upload', + boxLabel: gettext('Upload an existing client encryption key'), + padding: '0 0 0 25', + cbind: { + disabled: '{!isCreate}', + }, + bind: { + disabled: '{!allowEdit}', + }, + listeners: { + change: function (f, value) { + let panel = this.up('inputpanel'); + if (!panel.rendered) { + return; + } + let uploadKeyField = panel.down('field[name=crypt-key-upload]'); + uploadKeyField.setDisabled(!value); + uploadKeyField.setHidden(!value); + + let uploadKeyButton = panel.down('filebutton[name=crypt-upload-button]'); + uploadKeyButton.setDisabled(!value); + uploadKeyButton.setHidden(!value); + + if (value) { + uploadKeyField.validate(); + } else { + uploadKeyField.reset(); + } + }, + }, + }, + { + xtype: 'fieldcontainer', + layout: 'hbox', + items: [ + { + xtype: 'proxmoxtextfield', + name: 'crypt-key-upload', + fieldLabel: gettext('Key'), + value: '', + disabled: true, + hidden: true, + allowBlank: false, + labelAlign: 'right', + flex: 1, + emptyText: gettext('You can drag-and-drop a key file here.'), + validator: function (value) { + if (value.length) { + let key; + try { + key = JSON.parse(value); + } catch (e) { + return 'Failed to parse key - ' + e; + } + if (key.data === undefined) { + return 'Does not seems like a valid Proxmox Backup key!'; + } + } + return true; + }, + afterRender: function () { + if (!window.FileReader) { + // No FileReader support in this browser + return; + } + let cancel = function (ev) { + ev = ev.event; + if (ev.preventDefault) { + ev.preventDefault(); + } + }; + this.inputEl.on('dragover', cancel); + this.inputEl.on('dragenter', cancel); + this.inputEl.on('drop', (ev) => { + cancel(ev); + let files = ev.event.dataTransfer.files; + PVE.Utils.loadTextFromFile(files[0], (v) => this.setValue(v)); + }); + }, + }, + { + xtype: 'filebutton', + name: 'crypt-upload-button', + iconCls: 'fa fa-fw fa-folder-open-o x-btn-icon-el-default-toolbar-small', + cls: 'x-btn-default-toolbar-small proxmox-inline-button', + margin: '0 0 0 4', + disabled: true, + hidden: true, + listeners: { + change: function (btn, e, value) { + let ev = e.event; + let field = btn.up().down('proxmoxtextfield[name=crypt-key-upload]'); + PVE.Utils.loadTextFromFile(ev.target.files[0], (v) => + field.setValue(v), + ); + btn.reset(); + }, + }, + }, + ], + }, + { + xtype: 'component', + border: false, + padding: '5 2', + userCls: 'pmx-hint', + html: // `${ngettext('Warning', 'Warnings', 1)}: ` + + ` ` + + gettext( + 'Deleting or replacing the encryption key will break restoring backups created with it!', + ), + hidden: true, + bind: { + hidden: '{!showDangerousHint}', + }, + }, + ], +}); + +Ext.define('PVE.storage.PBSInputPanel', { + extend: 'PVE.panel.StorageBase', + + onlineHelp: 'storage_pbs', + + apiCallDone: function (success, response, options) { + let res = response.result.data; + if (!(res && res.config && res.config['encryption-key'])) { + return; + } + let key = res.config['encryption-key']; + Ext.create('PVE.Storage.PBSKeyShow', { + autoShow: true, + sid: res.storage, + key: key, + }); + }, + + isPBS: true, // HACK + + extraTabs: [ + { + xtype: 'pvePBSEncryptionKeyTab', + title: gettext('Encryption'), + }, + ], + + setValues: function (values) { + let me = this; + + let server = values.server; + if (values.port !== undefined) { + if (Proxmox.Utils.IP6_match.test(server)) { + server = `[${server}]`; + } + server += `:${values.port}`; + } + values.hostport = server; + + return me.callParent([values]); + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield', + fieldLabel: gettext('Server'), + allowBlank: false, + name: 'hostport', + submitValue: false, + vtype: 'HostPort', + listeners: { + change: function (field, newvalue) { + let server = newvalue; + let port; + + let match = Proxmox.Utils.HostPort_match.exec(newvalue); + if (match === null) { + match = Proxmox.Utils.HostPortBrackets_match.exec(newvalue); + if (match === null) { + match = Proxmox.Utils.IP6_dotnotation_match.exec(newvalue); + } + } + + if (match !== null) { + server = match[1]; + if (match[2] !== undefined) { + port = match[2]; + } + } + + field.up('inputpanel').down('field[name=server]').setValue(server); + field.up('inputpanel').down('field[name=port]').setValue(port); + }, + }, + }, + { + xtype: 'proxmoxtextfield', + hidden: true, + name: 'server', + submitValue: me.isCreate, // it is fixed + }, + { + xtype: 'proxmoxtextfield', + hidden: true, + deleteEmpty: !me.isCreate, + name: 'port', + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'username', + value: '', + emptyText: gettext('Example') + ': admin@pbs', + fieldLabel: gettext('Username'), + regex: /\S+@\w+/, + regexText: gettext('Example') + ': admin@pbs', + allowBlank: false, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + inputType: 'password', + name: 'password', + value: me.isCreate ? '' : '********', + emptyText: me.isCreate ? gettext('None') : '', + fieldLabel: gettext('Password'), + allowBlank: false, + }, + ]; + + me.column2 = [ + { + xtype: 'displayfield', + name: 'content', + value: 'backup', + submitValue: true, + fieldLabel: gettext('Content'), + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'datastore', + value: '', + fieldLabel: 'Datastore', + allowBlank: false, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'namespace', + value: '', + emptyText: gettext('Root'), + fieldLabel: gettext('Namespace'), + allowBlank: true, + }, + ]; + + me.columnB = [ + { + xtype: 'pmxFingerprintField', + name: 'fingerprint', + value: me.isCreate ? null : undefined, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.Ceph.Model', { + extend: 'Ext.app.ViewModel', + alias: 'viewmodel.cephstorage', + + data: { + pveceph: true, + pvecephPossible: true, + namespacePresent: false, + }, +}); + +Ext.define('PVE.storage.Ceph.Controller', { + extend: 'PVE.controller.StorageEdit', + alias: 'controller.cephstorage', + + control: { + '#': { + afterrender: 'queryMonitors', + }, + 'textfield[name=username]': { + disable: 'resetField', + }, + 'displayfield[name=monhost]': { + enable: 'queryMonitors', + }, + 'textfield[name=monhost]': { + disable: 'resetField', + enable: 'resetField', + }, + 'textfield[name=namespace]': { + change: 'updateNamespaceHint', + }, + }, + resetField: function (field) { + field.reset(); + }, + updateNamespaceHint: function (field, newVal, oldVal) { + this.getViewModel().set('namespacePresent', newVal); + }, + queryMonitors: function (field, newVal, oldVal) { + // we get called with two signatures, the above one for a field + // change event and the afterrender from the view, this check only + // can be true for the field change one and omit the API request if + // pveceph got unchecked - as it's not needed there. + if (field && !newVal && oldVal) { + return; + } + var view = this.getView(); + var vm = this.getViewModel(); + if (!(view.isCreate || vm.get('pveceph'))) { + return; // only query on create or if editing a pveceph store + } + + var monhostField = this.lookupReference('monhost'); + + Proxmox.Utils.API2Request({ + url: '/api2/json/nodes/localhost/ceph/mon', + method: 'GET', + scope: this, + callback: function (options, success, response) { + var data = response.result.data; + if (response.status === 200) { + if (data.length > 0) { + let monhost = Ext.Array.pluck(data, 'name').sort().join(','); + monhostField.setValue(monhost); + monhostField.resetOriginalValue(); + if (view.isCreate) { + vm.set('pvecephPossible', true); + } + } else { + vm.set('pveceph', false); + } + } else { + vm.set('pveceph', false); + vm.set('pvecephPossible', false); + } + }, + }); + }, +}); + +Ext.define('PVE.storage.RBDInputPanel', { + extend: 'PVE.panel.StorageBase', + controller: 'cephstorage', + + onlineHelp: 'ceph_rados_block_devices', + + viewModel: { + type: 'cephstorage', + }, + + setValues: function (values) { + if (values.monhost) { + this.viewModel.set('pveceph', false); + this.lookupReference('pvecephRef').setValue(false); + this.lookupReference('pvecephRef').resetOriginalValue(); + } + if (values.namespace) { + this.getViewModel().set('namespacePresent', true); + } + this.callParent([values]); + }, + + initComponent: function () { + var me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + me.type = 'rbd'; + + me.column1 = []; + + if (me.isCreate) { + me.column1.push( + { + xtype: 'pveCephPoolSelector', + nodename: me.nodename, + name: 'pool', + bind: { + disabled: '{!pveceph}', + submitValue: '{pveceph}', + hidden: '{!pveceph}', + }, + fieldLabel: gettext('Pool'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'pool', + value: 'rbd', + bind: { + disabled: '{pveceph}', + submitValue: '{!pveceph}', + hidden: '{pveceph}', + }, + fieldLabel: gettext('Pool'), + allowBlank: false, + }, + ); + } else { + me.column1.push({ + xtype: 'displayfield', + nodename: me.nodename, + name: 'pool', + fieldLabel: gettext('Pool'), + renderer: Ext.htmlEncode, + allowBlank: false, + }); + } + + me.column1.push( + { + xtype: 'textfield', + name: 'monhost', + vtype: 'HostList', + bind: { + disabled: '{pveceph}', + submitValue: '{!pveceph}', + hidden: '{pveceph}', + }, + value: '', + fieldLabel: 'Monitor(s)', + allowBlank: false, + }, + { + xtype: 'displayfield', + reference: 'monhost', + bind: { + disabled: '{!pveceph}', + hidden: '{!pveceph}', + }, + value: '', + fieldLabel: 'Monitor(s)', + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'username', + bind: { + disabled: '{pveceph}', + submitValue: '{!pveceph}', + }, + value: 'admin', + fieldLabel: gettext('User name'), + allowBlank: true, + }, + ); + + me.column2 = [ + { + xtype: 'pveContentTypeSelector', + cts: ['images', 'rootdir'], + fieldLabel: gettext('Content'), + name: 'content', + value: ['images'], + multiSelect: true, + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'krbd', + uncheckedValue: 0, + fieldLabel: 'KRBD', + }, + ]; + + me.columnB = [ + { + xtype: me.isCreate ? 'textarea' : 'displayfield', + name: 'keyring', + fieldLabel: 'Keyring', + value: me.isCreate ? '' : '***********', + allowBlank: false, + bind: { + hidden: '{pveceph}', + disabled: '{pveceph}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'pveceph', + reference: 'pvecephRef', + bind: { + disabled: '{!pvecephPossible}', + value: '{pveceph}', + }, + checked: true, + uncheckedValue: 0, + submitValue: false, + hidden: !me.isCreate, + boxLabel: gettext('Use Proxmox VE managed hyper-converged ceph pool'), + }, + ]; + + me.advancedColumn1 = [ + { + xtype: 'pmxDisplayEditField', + editable: me.isCreate, + name: 'namespace', + value: '', + fieldLabel: gettext('Namespace'), + allowBlank: true, + }, + ]; + me.advancedColumn2 = [ + { + xtype: 'displayfield', + name: 'namespace-hint', + userCls: 'pmx-hint', + value: gettext('RBD namespaces must be created manually!'), + bind: { + hidden: '{!namespacePresent}', + }, + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.StatusView', { + extend: 'Proxmox.panel.StatusView', + alias: 'widget.pveStorageStatusView', + + height: 230, + title: gettext('Status'), + + layout: { + type: 'vbox', + align: 'stretch', + }, + + defaults: { + xtype: 'pmxInfoWidget', + padding: '0 30 5 30', + }, + items: [ + { + xtype: 'box', + height: 30, + }, + { + itemId: 'enabled', + title: gettext('Enabled'), + printBar: false, + textField: 'disabled', + renderer: Proxmox.Utils.format_neg_boolean, + }, + { + itemId: 'active', + title: gettext('Active'), + printBar: false, + textField: 'active', + renderer: Proxmox.Utils.format_boolean, + }, + { + itemId: 'content', + title: gettext('Content'), + printBar: false, + textField: 'content', + renderer: PVE.Utils.format_content_types, + }, + { + itemId: 'type', + title: gettext('Type'), + printBar: false, + textField: 'type', + renderer: PVE.Utils.format_storage_type, + }, + { + xtype: 'box', + height: 10, + }, + { + itemId: 'usage', + title: gettext('Usage'), + valueField: 'used', + maxField: 'total', + renderer: (val, max) => { + if (max === undefined) { + return val; + } + return Proxmox.Utils.render_size_usage(val, max, true); + }, + }, + ], + + updateTitle: function () { + // nothing + }, +}); +Ext.define('PVE.storage.Summary', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveStorageSummary', + scrollable: true, + bodyPadding: 5, + tbar: [ + '->', + { + xtype: 'proxmoxRRDTypeSelector', + }, + ], + layout: { + type: 'column', + }, + defaults: { + padding: 5, + columnWidth: 1, + }, + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + var storage = me.pveSelNode.data.storage; + if (!storage) { + throw 'no storage ID specified'; + } + + var rstore = Ext.create('Proxmox.data.ObjectStore', { + url: '/api2/json/nodes/' + nodename + '/storage/' + storage + '/status', + interval: 1000, + }); + + var rrdstore = Ext.create('Proxmox.data.RRDStore', { + rrdurl: '/api2/json/nodes/' + nodename + '/storage/' + storage + '/rrddata', + model: 'pve-rrd-storage', + }); + + Ext.apply(me, { + items: [ + { + xtype: 'pveStorageStatusView', + pveSelNode: me.pveSelNode, + rstore: rstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Usage'), + fields: ['total', 'used'], + fieldTitles: ['Total Size', 'Used Size'], + store: rrdstore, + unit: 'bytes', + }, + ], + listeners: { + activate: function () { + rstore.startUpdate(); + rrdstore.startUpdate(); + }, + destroy: function () { + rstore.stopUpdate(); + rrdstore.stopUpdate(); + }, + }, + }); + + me.callParent(); + }, +}); +Ext.define( + 'PVE.grid.TemplateSelector', + { + extend: 'Ext.grid.GridPanel', + + alias: 'widget.pveTemplateSelector', + + stateful: true, + stateId: 'grid-template-selector', + viewConfig: { + trackOver: false, + }, + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + var baseurl = '/nodes/' + me.nodename + '/aplinfo'; + var store = new Ext.data.Store({ + model: 'pve-aplinfo', + groupField: 'section', + proxy: { + type: 'proxmox', + url: '/api2/json' + baseurl, + }, + }); + + var sm = Ext.create('Ext.selection.RowModel', {}); + + var groupingFeature = Ext.create('Ext.grid.feature.Grouping', { + groupHeaderTpl: + '{[ "Section: " + values.name ]} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})', + }); + + var reload = function () { + store.load(); + }; + + Proxmox.Utils.monStoreErrors(me, store); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + '->', + gettext('Search'), + { + xtype: 'textfield', + width: 200, + enableKeyEvents: true, + listeners: { + buffer: 500, + keyup: function (field) { + var value = field.getValue().toLowerCase(); + store.clearFilter(true); + store.filterBy(function (rec) { + return ( + rec.data.package.toLowerCase().indexOf(value) !== -1 || + rec.data.headline.toLowerCase().indexOf(value) !== -1 + ); + }); + }, + }, + }, + ], + features: [groupingFeature], + columns: [ + { + header: gettext('Type'), + width: 80, + dataIndex: 'type', + }, + { + header: gettext('Package'), + flex: 1, + dataIndex: 'package', + }, + { + header: gettext('Version'), + width: 80, + dataIndex: 'version', + }, + { + header: gettext('Description'), + flex: 1.5, + renderer: Ext.String.htmlEncode, + dataIndex: 'headline', + }, + ], + listeners: { + afterRender: reload, + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-aplinfo', { + extend: 'Ext.data.Model', + fields: [ + 'template', + 'type', + 'package', + 'version', + 'headline', + 'infopage', + 'description', + 'os', + 'section', + ], + idProperty: 'template', + }); + }, +); + +Ext.define('PVE.storage.TemplateDownload', { + extend: 'Ext.window.Window', + alias: 'widget.pveTemplateDownload', + + modal: true, + title: gettext('Templates'), + layout: 'fit', + width: 900, + height: 600, + initComponent: function () { + var me = this; + + var grid = Ext.create('PVE.grid.TemplateSelector', { + border: false, + scrollable: true, + nodename: me.nodename, + }); + + var sm = grid.getSelectionModel(); + + var submitBtn = Ext.create('Proxmox.button.Button', { + text: gettext('Download'), + disabled: true, + selModel: sm, + handler: function (button, event, rec) { + Proxmox.Utils.API2Request({ + url: '/nodes/' + me.nodename + '/aplinfo', + params: { + storage: me.storage, + template: rec.data.template, + }, + method: 'POST', + failure: function (response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, options) { + var upid = response.result.data; + + Ext.create('Proxmox.window.TaskViewer', { + upid: upid, + listeners: { + destroy: me.reloadGrid, + }, + }).show(); + + me.close(); + }, + }); + }, + }); + + Ext.apply(me, { + items: grid, + buttons: [submitBtn], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.OciRegistryPull', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveOciRegistryPull', + mixins: ['Proxmox.Mixin.CBind'], + + method: 'POST', + + showTaskViewer: true, + + title: gettext('Pull from OCI Registry'), + submitText: gettext('Download'), + width: 450, + + cbind: { + url: '/nodes/{nodename}/storage/{storage}/oci-registry-pull', + }, + + controller: { + xclass: 'Ext.app.ViewController', + + onReferenceChange: function (field, value) { + let me = this; + let view = me.getView(); + let tagField = view.down('[name=tag]'); + tagField.setComboItems([]); + let matches = me.parseReference(value); + if (matches) { + let ref = matches[0]; + let tag = matches[1]; + + if (tag) { + field.setValue(ref); + tagField.setValue(tag); + tagField.focus(); + } else { + tagField.clearValue(); + } + } + }, + + parseReference: function (value) { + const re = new RegExp( + '^((?:[a-zA-Z\\d](?:[a-zA-Z\\d-]*[a-zA-Z\\d])?(?:\\.(?:[a-zA-Z\\d]' + + '(?:[a-zA-Z\\d-]*[a-zA-Z\\d])?))*(?::\\d+)?/)?[a-z\\d]+(?:(?:[._]|__|-+)' + + '[a-z\\d]+)*(?:/[a-z\\d]+(?:(?:[._]|__|-+)[a-z\\d]+)*)*)' + + '(?::(\\w[\\w.-]{0,127}))?$', + ); + let matches = value.match(re); + if (matches) { + let ref = matches[1]; + let tag = matches[2]; + return [ref, tag]; + } + return undefined; + }, + + queryTags: function (field) { + let me = this; + let view = me.getView(); + let refField = view.down('[name=reference]'); + let reference = refField.value.trim(); + let tagField = view.down('[name=tag]'); + + Proxmox.Utils.API2Request({ + url: `/nodes/${view.nodename}/query-oci-repo-tags`, + method: 'GET', + params: { + reference, + }, + waitMsgTarget: view, + failure: (res) => { + Ext.MessageBox.alert(gettext('Error'), res.htmlStatus); + }, + success: function (res, opt) { + let tags = res.result.data; + tagField.clearValue(); + tagField.setComboItems(tags.map((tag) => [tag, Ext.htmlEncode(tag)])); + }, + }); + }, + }, + + items: [ + { + xtype: 'inputpanel', + border: false, + onGetValues: function (values) { + values.reference = values.reference + ':' + values.tag; + delete values.tag; + if (!values.filename) { + delete values.filename; + } + return values; + }, + items: [ + { + xtype: 'fieldcontainer', + layout: 'hbox', + fieldLabel: gettext('Reference'), + items: [ + { + xtype: 'textfield', + name: 'reference', + allowBlank: false, + emptyText: 'registry.example.org/name', + flex: 1, + listeners: { + change: 'onReferenceChange', + }, + validator: function (value) { + let me = this; + let controller = me.up('pveOciRegistryPull').getController(); + if (controller.parseReference(value)) { + return true; + } + return gettext('Invalid OCI Registry Reference'); + }, + }, + { + xtype: 'button', + name: 'check', + text: gettext('Query Tags'), + margin: '0 0 0 5', + listeners: { + click: 'queryTags', + }, + }, + ], + }, + { + xtype: 'proxmoxKVComboBox', + name: 'tag', + allowBlank: false, + // TRANSLATORS: As in a version of an OCI container, e.g. debian:latest + emptyText: gettext("for example 'latest'"), + fieldLabel: gettext('Tag'), + forceSelection: false, + editable: true, + typeAhead: true, + comboItems: [], + }, + { + xtype: 'textfield', + name: 'filename', + emptyText: '_', + fieldLabel: gettext('File name'), + }, + ], + }, + ], + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no node name specified'; + } + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.TemplateView', { + extend: 'PVE.storage.ContentView', + + alias: 'widget.pveStorageTemplateView', + + initComponent: function () { + var me = this; + + var nodename = (me.nodename = me.pveSelNode.data.node); + if (!nodename) { + throw 'no node name specified'; + } + + var storage = (me.storage = me.pveSelNode.data.storage); + if (!storage) { + throw 'no storage ID specified'; + } + + me.content = 'vztmpl'; + + var reload = function () { + me.store.load(); + }; + + var templateButton = Ext.create('Proxmox.button.Button', { + itemId: 'tmpl-btn', + text: gettext('Templates'), + handler: function () { + var win = Ext.create('PVE.storage.TemplateDownload', { + nodename: nodename, + storage: storage, + reloadGrid: reload, + }); + win.show(); + }, + }); + + var pullOciImageButton = Ext.create('Proxmox.button.Button', { + itemId: 'pull-oci-img-btn', + text: gettext('Pull from OCI Registry'), + handler: function () { + var win = Ext.create('PVE.storage.OciRegistryPull', { + nodename: nodename, + storage: storage, + taskDone: () => reload(), + }); + win.show(); + }, + }); + + me.tbar = [templateButton, pullOciImageButton]; + me.useUploadButton = true; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.ZFSInputPanel', { + extend: 'PVE.panel.StorageBase', + + viewModel: { + parent: null, + data: { + isLIO: false, + isComstar: true, + hasWriteCacheOption: true, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + 'field[name=iscsiprovider]': { + change: 'changeISCSIProvider', + }, + }, + changeISCSIProvider: function (f, newVal, oldVal) { + var vm = this.getViewModel(); + vm.set('isLIO', newVal === 'LIO'); + vm.set('isComstar', newVal === 'comstar'); + vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'istgt'); + }, + }, + + onGetValues: function (values) { + var me = this; + + if (me.isCreate) { + values.content = 'images'; + } + + values.nowritecache = values.writecache ? 0 : 1; + delete values.writecache; + + return me.callParent([values]); + }, + + setValues: function (values) { + values.writecache = values.nowritecache ? 0 : 1; + this.callParent([values]); + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'portal', + value: '', + fieldLabel: gettext('Portal'), + allowBlank: false, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'pool', + value: '', + fieldLabel: gettext('Pool'), + allowBlank: false, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'blocksize', + value: '4k', + fieldLabel: gettext('Block Size'), + allowBlank: false, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'target', + value: '', + fieldLabel: gettext('Target'), + allowBlank: false, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'comstar_tg', + value: '', + fieldLabel: gettext('Target group'), + bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, + allowBlank: true, + }, + ]; + + me.column2 = [ + { + xtype: me.isCreate ? 'pveiScsiProviderSelector' : 'displayfield', + name: 'iscsiprovider', + value: 'comstar', + fieldLabel: gettext('iSCSI Provider'), + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'sparse', + checked: false, + uncheckedValue: 0, + fieldLabel: gettext('Thin provision'), + }, + { + xtype: 'proxmoxcheckbox', + name: 'writecache', + checked: true, + bind: me.isCreate + ? { disabled: '{!hasWriteCacheOption}' } + : { hidden: '{!hasWriteCacheOption}' }, + uncheckedValue: 0, + fieldLabel: gettext('Write cache'), + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'comstar_hg', + value: '', + bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, + fieldLabel: gettext('Host group'), + allowBlank: true, + }, + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'lio_tpg', + value: '', + bind: me.isCreate ? { disabled: '{!isLIO}' } : { hidden: '{!isLIO}' }, + allowBlank: false, + fieldLabel: gettext('Target portal group'), + }, + ]; + + me.callParent(); + }, +}); +Ext.define('PVE.storage.ZFSPoolSelector', { + extend: 'PVE.form.ComboBoxSetStoreNode', + alias: 'widget.pveZFSPoolSelector', + valueField: 'pool', + displayField: 'pool', + queryMode: 'local', + editable: false, + allowBlank: false, + + listConfig: { + columns: [ + { + dataIndex: 'pool', + flex: 1, + }, + ], + emptyText: PVE.Utils.renderNotFound(gettext('ZFS Pool')), + }, + + config: { + apiSuffix: '/scan/zfs', + }, + + showNodeSelector: true, + + setNodeName: function (value) { + let me = this; + me.callParent([value]); + me.getStore().load(); + }, + + initComponent: function () { + let me = this; + + if (!me.nodename) { + me.nodename = 'localhost'; + } + + let store = Ext.create('Ext.data.Store', { + autoLoad: {}, // true, + fields: ['pool', 'size', 'free'], + proxy: { + type: 'proxmox', + url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, + }, + }); + store.sort('pool', 'ASC'); + + Ext.apply(me, { + store: store, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.storage.ZFSPoolInputPanel', { + extend: 'PVE.panel.StorageBase', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'storage_zfspool', + + column1: [ + { + xtype: 'pmxDisplayEditField', + cbind: { + editable: '{isCreate}', + }, + + name: 'pool', + fieldLabel: gettext('ZFS Pool'), + allowBlank: false, + + editConfig: { + xtype: 'pveZFSPoolSelector', + reference: 'zfsPoolSelector', + listeners: { + nodechanged: function (value) { + this.up('inputpanel').lookup('storageNodeRestriction').setValue(value); + }, + }, + }, + }, + { + xtype: 'pveContentTypeSelector', + cts: ['images', 'rootdir'], + fieldLabel: gettext('Content'), + name: 'content', + value: ['images', 'rootdir'], + multiSelect: true, + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'proxmoxcheckbox', + name: 'sparse', + checked: false, + uncheckedValue: 0, + fieldLabel: gettext('Thin provision'), + }, + { + xtype: 'textfield', + name: 'blocksize', + emptyText: '16k', + fieldLabel: gettext('Block Size'), + allowBlank: true, + }, + ], +}); +Ext.define('PVE.storage.ESXIInputPanel', { + extend: 'PVE.panel.StorageBase', + + setValues: function (values) { + let me = this; + + let server = values.server; + if (values.port !== undefined) { + if (Proxmox.Utils.IP6_match.test(server)) { + server = `[${server}]`; + } + server += `:${values.port}`; + } + values.server = server; + + return me.callParent([values]); + }, + + onGetValues: function (values) { + let me = this; + + if (values.password?.length === 0) { + delete values.password; + } + if (values.username?.length === 0) { + delete values.username; + } + + if (me.isCreate) { + let serverPortMatch = Proxmox.Utils.HostPort_match.exec(values.server); + if (serverPortMatch === null) { + serverPortMatch = Proxmox.Utils.HostPortBrackets_match.exec(values.server); + if (serverPortMatch === null) { + serverPortMatch = Proxmox.Utils.IP6_dotnotation_match.exec(values.server); + } + } + + if (serverPortMatch !== null) { + values.server = serverPortMatch[1]; + if (serverPortMatch[2] !== undefined) { + values.port = serverPortMatch[2]; + } + } + } + + return me.callParent([values]); + }, + + initComponent: function () { + var me = this; + + me.column1 = [ + { + xtype: 'pmxDisplayEditField', + name: 'server', + fieldLabel: gettext('Server'), + editable: me.isCreate, + emptyText: gettext('IP address or hostname'), + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'username', + fieldLabel: gettext('Username'), + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'password', + fieldLabel: gettext('Password'), + inputType: 'password', + emptyText: gettext('Unchanged'), + minLength: 1, + allowBlank: !me.isCreate, + }, + ]; + + me.column2 = [ + { + xtype: 'proxmoxcheckbox', + name: 'skip-cert-verification', + fieldLabel: gettext('Skip Certificate Verification'), + value: false, + uncheckedValue: 0, + defaultValue: 0, + deleteDefaultValue: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); +/* + * Workspace base class + * + * popup login window when auth fails (call onLogin handler) + * update (re-login) ticket every 15 minutes + * + */ + +Ext.define('PVE.Workspace', { + extend: 'Ext.container.Viewport', + + title: 'Proxmox Virtual Environment', + + loginData: null, // Data from last login call + + response401count: 0, + + onLogin: function (loginData) { + // override me + }, + + // private + updateLoginData: function (loginData) { + let me = this; + me.loginData = loginData; + Proxmox.Utils.setAuthData(loginData); + + PVE.ClusterName = loginData.clustername; + + if (loginData.cap) { + Ext.state.Manager.set('GuiCap', loginData.cap); + } + me.response401count = 0; + + me.onLogin(loginData); + }, + + // private + showLogin: function () { + let me = this; + + Proxmox.Utils.authClear(); + Ext.state.Manager.clear('GuiCap'); + Proxmox.UserName = null; + me.loginData = null; + + if (!me.login) { + me.login = Ext.create('PVE.window.LoginWindow', { + handler: function (data) { + me.login = null; + me.updateLoginData(data); + Proxmox.Utils.checked_command(Ext.emptyFn); // display subscription status + }, + }); + } + me.onLogin(null); + me.login.show(); + }, + + initComponent: function () { + let me = this; + + Ext.tip.QuickTipManager.init(); + + // fixme: what about other errors + Ext.Ajax.on('requestexception', function (conn, response, options) { + if ( + (response.status === 401 || response.status === '401') && + !PVE.Utils.silenceAuthFailures + ) { + // auth failure + // don't immediately show as logged out to cope better with some big + // upgrades, which may temporarily produce a false positive 401 err + me.response401count++; + if (me.response401count > 5) { + me.showLogin(); + } + } + }); + + me.callParent(); + + if (!Proxmox.Utils.authOK()) { + me.showLogin(); + } else if (me.loginData) { + me.onLogin(me.loginData); + } + + Ext.TaskManager.start({ + run: function () { + let ticket = Proxmox.Utils.authOK(); + if (!ticket || !Proxmox.UserName) { + return; + } + + Ext.Ajax.request({ + params: { + username: Proxmox.UserName, + password: ticket, + }, + url: '/api2/json/access/ticket', + method: 'POST', + success: function (response, opts) { + let obj = Ext.decode(response.responseText); + me.updateLoginData(obj.data); + }, + }); + }, + interval: 15 * 60 * 1000, + }); + }, +}); + +Ext.define('PVE.StdWorkspace', { + extend: 'PVE.Workspace', + + alias: ['widget.pveStdWorkspace'], + + // private + setContent: function (comp) { + let me = this; + + let view = me.child('#content'); + let layout = view.getLayout(); + let current = layout.getActiveItem(); + + if (comp) { + Proxmox.Utils.setErrorMask(view, false); + comp.border = false; + view.add(comp); + if (current !== null && layout.getNext()) { + layout.next(); + let task = Ext.create('Ext.util.DelayedTask', function () { + view.remove(current); + }); + task.delay(10); + } + } else { + view.removeAll(); // helper for cleaning the content when logging out + } + }, + + selectById: function (nodeid) { + let me = this; + me.down('pveResourceTree').selectById(nodeid); + }, + + onLogin: function (loginData) { + let me = this; + + me.updateUserInfo(); + + if (loginData) { + PVE.data.ResourceStore.startUpdate(); + + Proxmox.Utils.API2Request({ + url: '/version', + method: 'GET', + success: function (response) { + PVE.VersionInfo = response.result.data; + me.updateVersionInfo(); + }, + }); + + PVE.UIOptions.update(); + + Proxmox.Utils.API2Request({ + url: '/cluster/sdn', + method: 'GET', + success: function (response) { + PVE.SDNInfo = response.result.data; + }, + failure: function (response) { + PVE.SDNInfo = null; + let ui = Ext.ComponentQuery.query('treelistitem[text="SDN"]')[0]; + if (ui) { + ui.addCls('x-hidden-display'); + } + }, + }); + + Proxmox.Utils.API2Request({ + url: '/access/domains', + method: 'GET', + success: function (response) { + let [_username, realm] = Proxmox.Utils.parse_userid(Proxmox.UserName); + response.result.data.forEach((domain) => { + if (domain.realm === realm) { + let schema = PVE.Utils.authSchema[domain.type]; + if (schema) { + me.query('#tfaitem')[0].setHidden(!schema.tfa); + me.query('#passworditem')[0].setHidden(!schema.pwchange); + } + } + }); + }, + }); + } + }, + + updateUserInfo: function () { + let me = this; + let ui = me.query('#userinfo')[0]; + ui.setText(Ext.String.htmlEncode(Proxmox.UserName || '')); + ui.updateLayout(); + }, + + updateVersionInfo: function () { + let me = this; + + let ui = me.query('#versioninfo')[0]; + + if (PVE.VersionInfo) { + let version = PVE.VersionInfo.version; + ui.update('Virtual Environment ' + version); + } else { + ui.update('Virtual Environment'); + } + ui.updateLayout(); + }, + + initComponent: function () { + let me = this; + + Ext.History.init(); + + let appState = Ext.create('PVE.StateProvider'); + Ext.state.Manager.setProvider(appState); + + let selview = Ext.create('PVE.form.ViewSelector', { + flex: 1, + padding: '0 5 0 0', + }); + + let rtree = Ext.createWidget('pveResourceTree', { + viewFilter: selview.getViewFilter(), + flex: 1, + selModel: { + selType: 'treemodel', + listeners: { + selectionchange: function (sm, selected) { + if (selected.length <= 0) { + return; + } + let treeNode = selected[0]; + let treeTypeToClass = { + root: 'PVE.dc.Config', + node: 'PVE.node.Config', + qemu: 'PVE.qemu.Config', + lxc: 'pveLXCConfig', + storage: 'PVE.storage.Browser', + sdn: 'PVE.sdn.Browser', + network: 'PVE.network.Browser', + pool: 'pvePoolConfig', + tag: 'pveTagConfig', + }; + PVE.curSelectedNode = treeNode; + me.setContent({ + xtype: + treeTypeToClass[treeNode.data.type || 'root'] || 'pvePanelConfig', + showSearch: + treeNode.data.id === 'root' || + Ext.isDefined(treeNode.data.groupbyid), + pveSelNode: treeNode, + workspace: me, + viewFilter: selview.getViewFilter(), + }); + }, + }, + }, + }); + + selview.on('select', function (combo, records) { + if (records) { + let view = combo.getViewFilter(); + rtree.setViewFilter(view); + } + }); + + let caps = appState.get('GuiCap'); + + let createVM = Ext.createWidget('button', { + pack: 'end', + margin: '3 5 0 0', + baseCls: 'x-btn', + iconCls: 'fa fa-desktop', + text: gettext('Create VM'), + disabled: !caps.vms['VM.Allocate'], + handler: function () { + let wiz = Ext.create('PVE.qemu.CreateWizard', {}); + wiz.show(); + }, + }); + + let createCT = Ext.createWidget('button', { + pack: 'end', + margin: '3 5 0 0', + baseCls: 'x-btn', + iconCls: 'fa fa-cube', + text: gettext('Create CT'), + disabled: !caps.vms['VM.Allocate'], + handler: function () { + let wiz = Ext.create('PVE.lxc.CreateWizard', {}); + wiz.show(); + }, + }); + + appState.on('statechange', function (sp, key, value) { + if (key === 'GuiCap' && value) { + caps = value; + createVM.setDisabled(!caps.vms['VM.Allocate']); + createCT.setDisabled(!caps.vms['VM.Allocate']); + } else if (key === '_stripBeta') { + let betaLink = me.query('#betalink')?.[0]; + if (betaLink) { + betaLink.setHidden(true); + } + + const indexOfTilde = PVE.VersionInfo?.version.indexOf('~') ?? ''; + if (indexOfTilde !== -1) { + PVE.VersionInfo.version = PVE.VersionInfo.version.substring(0, indexOfTilde); + me.updateVersionInfo(); + } + } + }); + + Ext.apply(me, { + layout: { type: 'border' }, + border: false, + items: [ + { + region: 'north', + title: gettext('Header'), // for ARIA + header: false, // avoid rendering the title + layout: { + type: 'hbox', + align: 'middle', + }, + baseCls: 'x-plain', + defaults: { + baseCls: 'x-plain', + }, + border: false, + margin: '2 0 2 5', + items: [ + { + xtype: 'proxmoxLogoSvg', + prefix: 'pwt', + }, + { + minWidth: 150, + id: 'versioninfo', + html: 'Virtual Environment', + padding: '0 5', + style: { + 'font-size': '16px', + 'line-height': '20px', + }, + }, + { + flex: 2, + }, + { + xtype: 'pveGlobalSearchField', + tree: rtree, + minWidth: 150, + maxWidth: 600, + flex: 3, + }, + { + flex: 2, + }, + { + xtype: 'proxmoxHelpButton', + hidden: false, + baseCls: 'x-btn', + iconCls: 'fa fa-book x-btn-icon-el-default-toolbar-small ', + listenToGlobalEvent: false, + onlineHelp: 'pve_documentation_index', + text: gettext('Documentation'), + margin: '0 5 0 0', + }, + createVM, + createCT, + { + pack: 'end', + margin: '0 5 0 0', + id: 'userinfo', + xtype: 'button', + baseCls: 'x-btn', + style: { + // proxmox dark grey p light grey as border + backgroundColor: '#464d4d', + borderColor: '#ABBABA', + }, + iconCls: 'fa fa-user', + menu: [ + { + iconCls: 'fa fa-gear', + text: gettext('My Settings'), + handler: function () { + var win = Ext.create('PVE.window.Settings'); + win.show(); + }, + }, + { + text: gettext('Password'), + itemId: 'passworditem', + iconCls: 'fa fa-fw fa-key', + handler: function () { + var win = Ext.create('Proxmox.window.PasswordEdit', { + userid: Proxmox.UserName, + confirmCurrentPassword: Proxmox.UserName !== 'root@pam', + minLength: 8, + }); + win.show(); + }, + }, + { + text: 'TFA', + itemId: 'tfaitem', + iconCls: 'fa fa-fw fa-lock', + handler: function (btn, event, rec) { + Ext.state.Manager.getProvider().set( + 'dctab', + { value: 'tfa' }, + true, + ); + me.selectById('root'); + }, + }, + { + iconCls: 'fa fa-paint-brush', + text: gettext('Color Theme'), + handler: function () { + Ext.create('Proxmox.window.ThemeEditWindow').show(); + }, + }, + { + iconCls: 'fa fa-language', + text: gettext('Language'), + handler: function () { + Ext.create('Proxmox.window.LanguageEditWindow').show(); + }, + }, + '-', + { + iconCls: 'fa fa-fw fa-sign-out', + text: gettext('Logout'), + handler: function () { + PVE.data.ResourceStore.loadData([], false); + me.showLogin(); + me.setContent(null); + var rt = me.down('pveResourceTree'); + PVE.ClusterName = undefined; + rt.clearTree(); + + // empty the stores of the StatusPanel child items + var statusPanels = + Ext.ComponentQuery.query('pveStatusPanel grid'); + Ext.Array.forEach(statusPanels, function (comp) { + if (comp.getStore()) { + comp.getStore().loadData([], false); + } + }); + }, + }, + ], + }, + ], + }, + { + region: 'center', + stateful: true, + stateId: 'pvecenter', + minWidth: 100, + minHeight: 100, + id: 'content', + xtype: 'container', + layout: { type: 'card' }, + border: false, + margin: '0 5 0 0', + items: [], + }, + { + region: 'west', + stateful: true, + stateId: 'pvewest', + itemId: 'west', + xtype: 'container', + border: false, + layout: { type: 'vbox', align: 'stretch' }, + margin: '0 0 0 5', + split: true, + width: 300, + items: [ + { + xtype: 'container', + layout: 'hbox', + padding: '0 0 5 0', + items: [ + selview, + { + xtype: 'button', + cls: 'x-btn-default-toolbar-small', + iconCls: 'fa fa-fw fa-gear x-btn-icon-el-default-toolbar-small', + handler: () => { + Ext.create('PVE.window.TreeSettingsEdit', { + autoShow: true, + apiCallDone: () => PVE.UIOptions.fireUIConfigChanged(), + }); + }, + }, + ], + }, + rtree, + ], + listeners: { + resize: function (panel, width, height) { + var viewWidth = me.getSize().width; + if (width > viewWidth - 100 && viewWidth > 150) { + panel.setWidth(viewWidth - 100); + } + }, + }, + }, + { + xtype: 'pveStatusPanel', + stateful: true, + stateId: 'pvesouth', + itemId: 'south', + region: 'south', + margin: '0 5 5 5', + title: gettext('Logs'), + collapsible: true, + header: false, + height: 200, + split: true, + listeners: { + resize: function (panel, width, height) { + var viewHeight = me.getSize().height; + if (height > viewHeight - 150 && viewHeight > 200) { + panel.setHeight(viewHeight - 150); + } + }, + }, + }, + ], + }); + + me.callParent(); + + me.updateUserInfo(); + + // on resize, center all modal windows + Ext.on('resize', function () { + let modalWindows = Ext.ComponentQuery.query('window[modal]'); + if (modalWindows.length > 0) { + modalWindows.forEach((win) => win.alignTo(me, 'c-c')); + } + }); + + let tagSelectors = []; + ['circle', 'dense'].forEach((style) => { + ['dark', 'light'].forEach((variant) => { + let selector = `.proxmox-tags-${style} :not(.proxmox-tags-full) > .proxmox-tag-${variant}`; + tagSelectors.push(selector); + }); + }); + + Ext.create('Ext.tip.ToolTip', { + target: me.el, + delegate: tagSelectors.join(', '), + trackMouse: true, + renderTo: Ext.getBody(), + border: 0, + minWidth: 0, + padding: 0, + bodyBorder: 0, + bodyPadding: 0, + dismissDelay: 0, + userCls: 'pmx-tag-tooltip', + shadow: false, + listeners: { + beforeshow: function (tip) { + let tag = Ext.htmlEncode(tip.triggerElement.innerHTML); + let tagEl = Proxmox.Utils.getTagElement(tag, PVE.UIOptions.tagOverrides); + tip.update(`${tagEl}`); + }, + }, + }); + }, +}); From db653e4c4b8894c59a44ffa60632036142a0c46b Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 19:44:20 +0100 Subject: [PATCH 13/48] Add UPS support. Refactor file namings --- GPUcollecter.pm | 267 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 247 insertions(+), 20 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 119ccdc..a656552 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -42,9 +42,11 @@ my $pve_mod_working_dir = '/run/pveproxy/pve-mod'; my $stats_dir = $pve_mod_working_dir; my $state_file = "$pve_mod_working_dir/stats.json"; my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; -my $lock_file = "$pve_mod_working_dir/pve-mod-worker.lock"; -my $pve_mod_worker_lock = "$pve_mod_working_dir/pve-mod-pve_mod_worker.lock"; -my $startup_lock = $lock_file . ".startup"; +my $ups_state_file = "$pve_mod_working_dir/ups.json"; + +my $collectors_pid_file = "$pve_mod_working_dir/collectors.pids"; # List of collector PIDs +my $pve_mod_worker_lock = "$pve_mod_working_dir/pve-mod-pve_mod_worker.lock"; # PVE Mod worker lock +my $startup_lock = "$pve_mod_working_dir/startup.lock"; # Exclusive startup lock my $last_snapshot = {}; my $last_mtime = 0; my $is_collector_parent = 0; # Flag to track if this process started collectors @@ -54,9 +56,15 @@ my $COLLECTOR_TIMEOUT = 10; # Stop collectors x seconds after last get_graphic my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) my $nvidia_gpu_enabled = 1; # Set to 1 to enable NVIDIA GPU support (not yet implemented) +my $ups_enabled = 1; # Set to 1 to enable UPS support my $pve_mod_worker_pid; my $pve_mod_worker_running = 0; +# UPS Configuration +my $ups_device = { + ups_name => 'ups@192.168.3.2', # Format: upsname[@hostname[:port]] +}; + # ============================================================================ # Intel GPU Support # ============================================================================ @@ -603,7 +611,7 @@ sub _get_temperature_sensors { }; $sensorsData = JSON->new->pretty->encode($enhanced_data); - + return $sensorsData; } @@ -985,11 +993,136 @@ sub _cpu_model_by_package { return "unknown"; } +# ============================================================================ +# UPS Support +# ============================================================================ + +sub _collector_for_ups { + my ($device) = @_; + + $0 = "pve-mod-ups-collector"; + _debug(__LINE__, "UPS collector started"); + + # Set up signal handlers for graceful shutdown + my $shutdown = 0; + $SIG{TERM} = sub { + _debug(__LINE__, "UPS collector received SIGTERM"); + $shutdown = 1; + }; + $SIG{INT} = sub { + _debug(__LINE__, "UPS collector received SIGINT"); + $shutdown = 1; + }; + while (!$shutdown) { + my $upsData = _get_ups_status($device->{ups_name}); + + # Write to ups state file + eval { + open my $ofh, '>', $ups_state_file or die "Failed to open $ups_state_file: $!"; + print $ofh $upsData; + close $ofh; + _debug(__LINE__, "Wrote ups data to $ups_state_file"); + }; + if ($@) { + _debug(__LINE__, "Error writing ups data: $@"); + } + + sleep 1 unless $shutdown; + } + _debug(__LINE__, "UPS collector shutting down"); + exit 0; +} + +sub _get_ups_status { + my ($ups_name) = @_; + + # upsc upsname[@hostname[:port]] + my $cmd = "upsc $ups_name 2>/dev/null"; + + _debug(__LINE__, "Running command: $cmd"); + + # Execute command and capture output + my $output = `$cmd`; + + _debug(__LINE__, "upsc command output collected"); + + my $exit_code = $? >> 8; + + _debug(__LINE__, "upsc command exited with code $exit_code"); + + if ($exit_code != 0) { + _debug(__LINE__, "upsc command failed with exit code $exit_code"); + return encode_json({ error => "Failed to execute upsc for $ups_name" }); + } + + _debug(__LINE__, "upsc command executed successfully"); + + # Convert upsc output to nested hash structure + my $ups_data = _parse_upsc_output($output); + + _debug(__LINE__, "Parsed upsc output for $ups_name"); + + # Check if we got any data + unless (keys %$ups_data) { + _debug(__LINE__, "No data received from upsc for $ups_name"); + return encode_json({ error => "No data from UPS $ups_name" }); + } + + # Wrap in UPS name structure + my $result = { + $ups_name => $ups_data + }; + + _debug(__LINE__, "Successfully parsed UPS data for $ups_name"); + + # Return as pretty JSON + return JSON->new->pretty->canonical->encode($result); +} + +sub _parse_upsc_output { + my ($output) = @_; + + my $ups_data = {}; + + _debug(__LINE__, "Parsing upsc output"); + + eval { + foreach my $line (split /\n/, $output) { + # Skip empty lines and SSL init message + next if $line =~ /^\s*$/; + next if $line =~ /^Init SSL/; + + # Parse key-value pairs (format: "key: value") + if ($line =~ /^([^:]+):\s*(.*)$/) { + my ($key, $value) = ($1, $2); + + # Trim whitespace + $key =~ s/^\s+|\s+$//g; + $value =~ s/^\s+|\s+$//g; + + # Store as flat key-value pairs (no nesting) + # Convert numeric values to numbers, keep strings as strings + if ($value =~ /^-?\d+\.?\d*$/) { + $ups_data->{$key} = $value + 0; + } else { + $ups_data->{$key} = $value; + } + } + } + }; + if ($@) { + _debug(__LINE__, "Error parsing upsc output: $@"); + } + + _debug(__LINE__, "Completed parsing upsc output"); + + return $ups_data; +} + # ============================================================================ # Supporting functions # ============================================================================ -# Check if a process is alive sub _is_process_alive { my ($pid) = @_; return -d "/proc/$pid"; @@ -1226,6 +1359,38 @@ sub get_sensors_stats { return $sensors_data; } +sub get_ups_stats { + _debug(__LINE__, "get_ups_stats called"); + + # Start PVE Mod + _pve_mod_starter(); + + unless (-f $ups_state_file) { + _debug(__LINE__, "UPS state file does not exist: $ups_state_file"); + return {}; + } + + my $ups_data; + eval { + open my $fh, '<', $ups_state_file or die "Failed to open $ups_state_file: $!"; + local $/; + my $json = <$fh>; + close($fh); + $ups_data = $json; + _debug(__LINE__, "Read UPS data, JSON length: " . length($json) . " bytes"); + _debug(__LINE__, "Read UPS data from $ups_state_file"); + }; + if ($@) { + _debug(__LINE__, "Failed to read/parse UPS data: $@"); + return {}; + } + + # Notify pve_mod_worker of activity + _notify_pve_mod_worker(); + + return $ups_data; +} + # ============================================================================ # Main Collector # ============================================================================ @@ -1241,7 +1406,7 @@ sub _start_graphics_collectors { } # Read existing collectors from lock file - my %existing_collectors = _read_collector_lock($lock_file); + my %existing_collectors = _read_collector_lock($collectors_pid_file); # Generalized device collector management for future AMD/NVIDIA support my @all_devices; @@ -1307,7 +1472,7 @@ sub _start_graphics_collectors { my $device_name = $device->{card} // $device->{name} // "device$i"; # Check if collector already running - my $existing_pid = _is_collector_running($device_name, $lock_file); + my $existing_pid = _is_collector_running($device_name, $collectors_pid_file); if ($existing_pid) { _debug(__LINE__, "Collector for $type $device_name already running with PID $existing_pid"); push @collector_entries, [$existing_pid, $device_name, $type]; @@ -1322,7 +1487,7 @@ sub _start_graphics_collectors { } # Write all collector PIDs to lock file - unless (_write_collector_lock($lock_file, @collector_entries)) { + unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { _debug(__LINE__, "Failed to write lock file, terminating collectors"); foreach my $entry (@collector_entries) { kill 'TERM', $entry->[0]; @@ -1363,7 +1528,7 @@ sub _start_sensors_collector { } # Check if already running - my $existing_pid = _is_collector_running('sensors', $lock_file); + my $existing_pid = _is_collector_running('sensors', $collectors_pid_file); if ($existing_pid) { _debug(__LINE__, "Sensors collector already running with PID $existing_pid"); return; @@ -1375,7 +1540,7 @@ sub _start_sensors_collector { if ($pid) { # Read existing entries my @collector_entries; - my %existing = _read_collector_lock($lock_file); + my %existing = _read_collector_lock($collectors_pid_file); foreach my $name (keys %existing) { push @collector_entries, [$existing{$name}->{pid}, $name, $existing{$name}->{type}]; } @@ -1384,7 +1549,7 @@ sub _start_sensors_collector { push @collector_entries, [$pid, 'sensors', 'sensors']; # Write updated lock file - unless (_write_collector_lock($lock_file, @collector_entries)) { + unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { _debug(__LINE__, "Failed to update lock file, terminating sensors collector"); kill 'TERM', $pid; return; @@ -1400,6 +1565,59 @@ sub _start_sensors_collector { } } +sub _start_ups_collector { + + if ($ups_enabled == 0) { + _debug(__LINE__, "UPS support not enabled, skipping collector startup"); + return; + } + + _debug(__LINE__, "Starting UPS collector"); + + # Check if UPS is configured + unless (defined $ups_device && $ups_device->{ups_name}) { + _debug(__LINE__, "No UPS configured, skipping collector startup"); + return; + } + + # Check if already running + my $existing_pid = _is_collector_running('ups', $collectors_pid_file); + if ($existing_pid) { + _debug(__LINE__, "UPS collector already running with PID $existing_pid"); + return; + } + + # Start the collector + my $pid = _start_child_collector('ups', \&_collector_for_ups, $ups_device); + + if ($pid) { + # Read existing entries + my @collector_entries; + my %existing = _read_collector_lock($collectors_pid_file); + foreach my $name (keys %existing) { + push @collector_entries, [$existing{$name}->{pid}, $name, $existing{$name}->{type}]; + } + + # Add UPS entry + push @collector_entries, [$pid, 'ups', 'ups']; + + # Write updated lock file + unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { + _debug(__LINE__, "Failed to update lock file, terminating UPS collector"); + kill 'TERM', $pid; + return; + } + + # Verify it's alive + sleep 0.1; + if (kill(0, $pid)) { + _debug(__LINE__, "Verified UPS collector (PID $pid) is alive"); + } else { + _debug(__LINE__, "WARNING - UPS collector (PID $pid) died immediately!"); + } + } +} + # ============================================================================ # PVE Mod Worker # ============================================================================ @@ -1418,12 +1636,22 @@ sub _pve_mod_starter { # Ensure directory exists _ensure_pve_mod_directory_exists(); - # Try to acquire startup lock FIRST (prevents race conditions) + # Try to get the lock _debug(__LINE__, "Trying to acquire startup lock: $startup_lock"); my $startup_fh = _acquire_exclusive_lock($startup_lock, 'startup lock'); return unless $startup_fh; + + # SECOND CHECK (after lock) - verify nothing changed while waiting + if (_is_pve_mod_worker_running()) { + _debug(__LINE__, "Worker started by another process while we waited for lock"); + close($startup_fh); + unlink($startup_lock); + return "already running"; + } + + # Now we KNOW we're the only one starting things print $startup_fh "$$\n"; - close($startup_fh); + $startup_fh->flush(); _debug(__LINE__, "Wrote PID, $$, to startup lock"); # Start sensors collector first @@ -1432,6 +1660,9 @@ sub _pve_mod_starter { # Start graphics collectors _start_graphics_collectors(); + # Start UPS collector + _start_ups_collector(); + _debug(__LINE__, "All collectors started"); # Start pve mod worker @@ -1444,10 +1675,6 @@ sub _pve_mod_starter { _debug(__LINE__, "pve_mod_worker started successfully, returning"); } -# ============================================================================ -# PVE Mod Worker -# ============================================================================ - sub _pve_mod_worker { _debug(__LINE__, "_pve_mod_worker called"); @@ -1577,7 +1804,7 @@ sub _stop_collectors { # Read current PIDs from lock file my @pids; - if (open my $lock_fh, '<', $lock_file) { + if (open my $lock_fh, '<', $collectors_pid_file) { while (my $line = <$lock_fh>) { chomp $line; # Extract PID from "PID card type" format @@ -1625,8 +1852,8 @@ sub _stop_collectors { if (-f $state_file) { unlink $state_file or _debug(__LINE__, "Failed to remove $state_file: $!"); } - if (-f $lock_file) { - unlink $lock_file or _debug(__LINE__, "Failed to remove $lock_file: $!"); + if (-f $collectors_pid_file) { + unlink $collectors_pid_file or _debug(__LINE__, "Failed to remove $collectors_pid_file: $!"); } # Remove pve mod worker directory and all files if it exists From 8b2609e336141c69e4e1ebb67053b2ba6e739c9a Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 19:57:33 +0100 Subject: [PATCH 14/48] fix naming of pve mod worker --- GPUcollecter.pm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index a656552..cf196a3 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -45,7 +45,7 @@ my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; my $ups_state_file = "$pve_mod_working_dir/ups.json"; my $collectors_pid_file = "$pve_mod_working_dir/collectors.pids"; # List of collector PIDs -my $pve_mod_worker_lock = "$pve_mod_working_dir/pve-mod-pve_mod_worker.lock"; # PVE Mod worker lock +my $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; # PVE Mod worker lock my $startup_lock = "$pve_mod_working_dir/startup.lock"; # Exclusive startup lock my $last_snapshot = {}; my $last_mtime = 0; @@ -1694,6 +1694,7 @@ sub _pve_mod_worker { if ($pve_mod_worker_pid == 0) { # Child process - run the pve_mod_worker + $0 = "pve_mod_worker_controller"; _debug(__LINE__, "Child process forked, calling _pve_mod_keep_alive"); _pve_mod_keep_alive(); exit(0); # Should never reach here From d0069f33e4c87cb9561a3b8d26ed6ba967ddb8af Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 21:30:39 +0100 Subject: [PATCH 15/48] fix uniquenss of nvida and pid matching of collector processes --- GPUcollecter.pm | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index cf196a3..5b20613 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -413,7 +413,7 @@ sub parse_nvidia_gpu_line { sub collector_for_nvidia_device { my ($device) = @_; - $0 = "pve-mod-gpu-nvidia-collector"; + $0 = "pve-mod-gpu-nvidia-collector: $device->{index}"; _debug(__LINE__, "NVIDIA collector started (stub implementation)"); # Set up signal handlers for graceful shutdown @@ -476,7 +476,8 @@ sub _write_collector_lock { foreach my $entry (@collector_entries) { my ($pid, $name, $type) = @$entry; - print $lock_fh "$pid $name $type\n"; + # Quote the name to preserve spaces + print $lock_fh "$pid \"$name\" $type\n"; } close($lock_fh); @@ -493,7 +494,8 @@ sub _read_collector_lock { if (open my $lock_fh, '<', $lock_path) { while (my $line = <$lock_fh>) { chomp $line; - if ($line =~ /^(\d+)\s+(\S+)\s+(\S+)/) { + # Match: PID "name with spaces" type + if ($line =~ /^(\d+)\s+"([^"]+)"\s+(\S+)/) { $collectors{$2} = { pid => $1, type => $3 }; } } From fb1cd2794be63a3bd52aacded87bcdb07b59ea14 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 21:40:57 +0100 Subject: [PATCH 16/48] adjustable data pull interval --- GPUcollecter.pm | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 5b20613..ec53d88 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -52,6 +52,7 @@ my $last_mtime = 0; my $is_collector_parent = 0; # Flag to track if this process started collectors my $last_get_graphic_stats_time = 0; # Track when get_graphic_stats was last called my $COLLECTOR_TIMEOUT = 10; # Stop collectors x seconds after last get_graphic_stats call +my $data_pull_interval = 1; # Interval in seconds between data pulls my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) @@ -200,7 +201,8 @@ sub _collector_for_intel_device { # Run intel_gpu_top once and keep reading from it _debug(__LINE__, "About to open pipe to intel_gpu_top"); - $intel_gpu_top_pid = open(my $fh, '-|', "intel_gpu_top -d $drm_dev -s 1000 -l 2>&1"); + my $intel_pull_interval = $data_pull_interval * 1000; # in milliseconds + $intel_gpu_top_pid = open(my $fh, '-|', "intel_gpu_top -d $drm_dev -s $intel_pull_interval -l 2>&1"); unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { _debug(__LINE__, "Failed to run intel_gpu_top for $drm_dev: $!"); @@ -430,7 +432,7 @@ sub collector_for_nvidia_device { # TODO: Implement actual NVIDIA monitoring while (!$shutdown) { _debug(__LINE__, "NVIDIA collector running (stub)"); - sleep 1; + sleep $data_pull_interval; } _debug(__LINE__, "NVIDIA collector shutting down"); @@ -567,7 +569,7 @@ sub _collector_for_temperature_sensors { _debug(__LINE__, "Error writing temperature sensor data: $@"); } - sleep 1 unless $shutdown; + sleep $data_pull_interval unless $shutdown; } _debug(__LINE__, "Temperature sensor collector shutting down"); @@ -1029,7 +1031,7 @@ sub _collector_for_ups { _debug(__LINE__, "Error writing ups data: $@"); } - sleep 1 unless $shutdown; + sleep $data_pull_interval unless $shutdown; } _debug(__LINE__, "UPS collector shutting down"); exit 0; From a559fa5fa28f06f567a9ac6feb2722247ff5e042 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 22:05:49 +0100 Subject: [PATCH 17/48] refactor collector start --- GPUcollecter.pm | 177 ++++++++++++++++-------------------------------- 1 file changed, 57 insertions(+), 120 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index ec53d88..8872819 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -1399,6 +1399,54 @@ sub get_ups_stats { # Main Collector # ============================================================================ +sub _start_collector { + my ($collector_name, $collector_type, $collector_sub, $device) = @_; + + _debug(__LINE__, "Starting $collector_type collector: $collector_name"); + + # Check if already running + my $existing_pid = _is_collector_running($collector_name, $collectors_pid_file); + if ($existing_pid) { + _debug(__LINE__, "$collector_type collector '$collector_name' already running with PID $existing_pid"); + return $existing_pid; + } + + # Start the collector + my $pid = _start_child_collector($collector_name, $collector_sub, $device); + + unless ($pid) { + _debug(__LINE__, "Failed to start $collector_type collector '$collector_name'"); + return undef; + } + + # Read existing entries from lock file + my @collector_entries; + my %existing = _read_collector_lock($collectors_pid_file); + foreach my $name (keys %existing) { + push @collector_entries, [$existing{$name}->{pid}, $name, $existing{$name}->{type}]; + } + + # Add new collector entry + push @collector_entries, [$pid, $collector_name, $collector_type]; + + # Write updated lock file + unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { + _debug(__LINE__, "Failed to update lock file, terminating $collector_type collector"); + kill 'TERM', $pid; + return undef; + } + + # Verify it's alive + sleep 0.1; + if (kill(0, $pid)) { + _debug(__LINE__, "Verified $collector_type collector '$collector_name' (PID $pid) is alive"); + return $pid; + } else { + _debug(__LINE__, "WARNING - $collector_type collector '$collector_name' (PID $pid) died immediately!"); + return undef; + } +} + sub _start_graphics_collectors { if ($intel_gpu_enabled == 0 && $amd_gpu_enabled == 0 && $nvidia_gpu_enabled == 0) { @@ -1408,9 +1456,6 @@ sub _start_graphics_collectors { else { _debug(__LINE__, "Starting graphics collectors"); } - - # Read existing collectors from lock file - my %existing_collectors = _read_collector_lock($collectors_pid_file); # Generalized device collector management for future AMD/NVIDIA support my @all_devices; @@ -1467,59 +1512,19 @@ sub _start_graphics_collectors { _debug(__LINE__, "Finished detecting devices. Total collectors to manage: " . scalar(@all_devices)); - my @collector_entries; - + # Start each graphics collector using unified function + my $started_count = 0; for (my $i = 0; $i < @all_devices; $i++) { my $device = $all_devices[$i]; my $type = $all_types[$i]; my $collector_sub = $all_collector_subs[$i]; my $device_name = $device->{card} // $device->{name} // "device$i"; - # Check if collector already running - my $existing_pid = _is_collector_running($device_name, $collectors_pid_file); - if ($existing_pid) { - _debug(__LINE__, "Collector for $type $device_name already running with PID $existing_pid"); - push @collector_entries, [$existing_pid, $device_name, $type]; - next; - } - - # Start new collector - my $pid = _start_child_collector($device_name, $collector_sub, $device); - if ($pid) { - push @collector_entries, [$pid, $device_name, $type]; - } + my $pid = _start_collector($device_name, $type, $collector_sub, $device); + $started_count++ if $pid; } - - # Write all collector PIDs to lock file - unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { - _debug(__LINE__, "Failed to write lock file, terminating collectors"); - foreach my $entry (@collector_entries) { - kill 'TERM', $entry->[0]; - } - return; - } - - # Wait briefly to ensure collectors are running - sleep 0.1; - # Verify collectors are alive - my $any_alive = 0; - foreach my $entry (@collector_entries) { - my ($pid, $name, $type) = @$entry; - if (kill(0, $pid)) { - $any_alive = 1; - _debug(__LINE__, "Verified $type collector $name (PID $pid) is alive"); - } else { - _debug(__LINE__, "WARNING - $type collector $name (PID $pid) died immediately!"); - } - } - - unless ($any_alive) { - _debug(__LINE__, "ERROR - No collectors alive after fork!"); - return; - } - - _debug(__LINE__, "All graphics collectors started successfully"); + _debug(__LINE__, "Started/verified $started_count graphics collector(s)"); } sub _start_sensors_collector { @@ -1531,42 +1536,8 @@ sub _start_sensors_collector { return; } - # Check if already running - my $existing_pid = _is_collector_running('sensors', $collectors_pid_file); - if ($existing_pid) { - _debug(__LINE__, "Sensors collector already running with PID $existing_pid"); - return; - } - - # Start the collector - my $pid = _start_child_collector('sensors', \&_collector_for_temperature_sensors, { name => 'sensors' }); - - if ($pid) { - # Read existing entries - my @collector_entries; - my %existing = _read_collector_lock($collectors_pid_file); - foreach my $name (keys %existing) { - push @collector_entries, [$existing{$name}->{pid}, $name, $existing{$name}->{type}]; - } - - # Add sensors entry - push @collector_entries, [$pid, 'sensors', 'sensors']; - - # Write updated lock file - unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { - _debug(__LINE__, "Failed to update lock file, terminating sensors collector"); - kill 'TERM', $pid; - return; - } - - # Verify it's alive - sleep 0.1; - if (kill(0, $pid)) { - _debug(__LINE__, "Verified sensors collector (PID $pid) is alive"); - } else { - _debug(__LINE__, "WARNING - Sensors collector (PID $pid) died immediately!"); - } - } + # Use unified collector startup + _start_collector('sensors', 'sensors', \&_collector_for_temperature_sensors, { name => 'sensors' }); } sub _start_ups_collector { @@ -1584,42 +1555,8 @@ sub _start_ups_collector { return; } - # Check if already running - my $existing_pid = _is_collector_running('ups', $collectors_pid_file); - if ($existing_pid) { - _debug(__LINE__, "UPS collector already running with PID $existing_pid"); - return; - } - - # Start the collector - my $pid = _start_child_collector('ups', \&_collector_for_ups, $ups_device); - - if ($pid) { - # Read existing entries - my @collector_entries; - my %existing = _read_collector_lock($collectors_pid_file); - foreach my $name (keys %existing) { - push @collector_entries, [$existing{$name}->{pid}, $name, $existing{$name}->{type}]; - } - - # Add UPS entry - push @collector_entries, [$pid, 'ups', 'ups']; - - # Write updated lock file - unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { - _debug(__LINE__, "Failed to update lock file, terminating UPS collector"); - kill 'TERM', $pid; - return; - } - - # Verify it's alive - sleep 0.1; - if (kill(0, $pid)) { - _debug(__LINE__, "Verified UPS collector (PID $pid) is alive"); - } else { - _debug(__LINE__, "WARNING - UPS collector (PID $pid) died immediately!"); - } - } + # Use unified collector startup + _start_collector('ups', 'ups', \&_collector_for_ups, $ups_device); } # ============================================================================ From 447f352823ac0987225207e0b808b4a15a1dc6f3 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 22:41:58 +0100 Subject: [PATCH 18/48] name process type --- GPUcollecter.pm | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 8872819..1c7638e 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -66,6 +66,11 @@ my $ups_device = { ups_name => 'ups@192.168.3.2', # Format: upsname[@hostname[:port]] }; +# ============================================================================ +# Code starts here +# ============================================================================ +my $process_type = 'main'; # 'main', 'worker', or 'collector' + # ============================================================================ # Intel GPU Support # ============================================================================ @@ -176,6 +181,7 @@ sub _get_intel_gpu_devices { sub _collector_for_intel_device { my ($device) = @_; + $process_type = 'collector'; $0 = "pve-mod-gpu-intel-collector: $device->{card}"; my $drm_dev = "drm:/dev/dri/$device->{card}"; @@ -414,6 +420,7 @@ sub parse_nvidia_gpu_line { sub collector_for_nvidia_device { my ($device) = @_; + $process_type = 'collector'; $0 = "pve-mod-gpu-nvidia-collector: $device->{index}"; _debug(__LINE__, "NVIDIA collector started (stub implementation)"); @@ -531,8 +538,9 @@ sub _is_collector_running { sub _collector_for_temperature_sensors { my ($device) = @_; - + $process_type = 'collector'; $0 = "pve-mod-sensors-collector"; + _debug(__LINE__, "Temperature sensor collector started"); # return if lm-sensors is not installed @@ -1003,7 +1011,7 @@ sub _cpu_model_by_package { sub _collector_for_ups { my ($device) = @_; - + $process_type = 'collector'; $0 = "pve-mod-ups-collector"; _debug(__LINE__, "UPS collector started"); @@ -1690,6 +1698,7 @@ sub _notify_pve_mod_worker { } sub _pve_mod_keep_alive { + $process_type = 'worker'; _debug(__LINE__, "pve_mod_worker process started with PID $$"); my $last_activity = time(); @@ -1767,12 +1776,14 @@ sub _stop_collectors { _debug(__LINE__, "Cleanup complete_2"); # Wait up to 5 seconds for graceful shutdown - my $timeout = 5; + my $timeout = 30; my $start = time(); while (time() - $start < $timeout) { my $any_alive = 0; foreach my $pid (@pids) { + _debug(__LINE__, "Checking if collector process $pid is still alive"); if (kill(0, $pid)) { + _debug(__LINE__, "Collector process $pid is still alive"); $any_alive = 1; last; } @@ -1807,6 +1818,17 @@ sub _stop_collectors { _debug(__LINE__, "Cleanup complete_5"); } -END { _stop_collectors() } +END { + if ($process_type eq 'worker') { + _debug(__LINE__, "PVE Mod Worker END block: cleaning up"); + _stop_collectors(); + } elsif ($process_type eq 'collector') { + _debug(__LINE__, "Collector ($0) END block: no cleanup needed"); + # Collectors just exit, no cleanup needed + } else { + _debug(__LINE__, "Main process END block: no cleanup needed"); + # Main pveproxy process doesn't cleanup + } +} 1; From 5a8ecfaceddc6b8c56fa07928319f1bf53041973 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 22:57:02 +0100 Subject: [PATCH 19/48] Refactor so that **`pve_mod_worker` alone spawns, tracks, and manages all collector processes**, eliminating PID files and external checks, while API calls only read the worker-managed state. --- GPUcollecter.pm | 184 +++++++++++++++++------------------------------- 1 file changed, 64 insertions(+), 120 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 1c7638e..302932a 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -44,12 +44,13 @@ my $state_file = "$pve_mod_working_dir/stats.json"; my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; my $ups_state_file = "$pve_mod_working_dir/ups.json"; -my $collectors_pid_file = "$pve_mod_working_dir/collectors.pids"; # List of collector PIDs my $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; # PVE Mod worker lock my $startup_lock = "$pve_mod_working_dir/startup.lock"; # Exclusive startup lock my $last_snapshot = {}; my $last_mtime = 0; -my $is_collector_parent = 0; # Flag to track if this process started collectors + +# Collector registry - only populated in worker process +my %collectors = (); # key: device/card name, value: PID my $last_get_graphic_stats_time = 0; # Track when get_graphic_stats was last called my $COLLECTOR_TIMEOUT = 10; # Stop collectors x seconds after last get_graphic_stats call my $data_pull_interval = 1; # Interval in seconds between data pulls @@ -463,74 +464,19 @@ sub _start_child_collector { if ($pid == 0) { # Child process + $process_type = 'collector'; _debug(__LINE__, "In child process for $collector_name"); $0 = "pve-mod-$collector_name"; $collector_sub->($device); exit(0); } - # Parent process + # Parent process (worker only) _debug(__LINE__, "Forked child PID $pid for $collector_name"); return $pid; } -sub _write_collector_lock { - my ($lock_path, @collector_entries) = @_; - - my $lock_fh; - unless (open $lock_fh, '>', $lock_path) { - _debug(__LINE__, "Failed to open lock file for writing: $!"); - return 0; - } - - foreach my $entry (@collector_entries) { - my ($pid, $name, $type) = @$entry; - # Quote the name to preserve spaces - print $lock_fh "$pid \"$name\" $type\n"; - } - - close($lock_fh); - _debug(__LINE__, "Wrote " . scalar(@collector_entries) . " collector entries to lock file"); - return 1; -} - -sub _read_collector_lock { - my ($lock_path) = @_; - - my %collectors; - return %collectors unless -f $lock_path; - - if (open my $lock_fh, '<', $lock_path) { - while (my $line = <$lock_fh>) { - chomp $line; - # Match: PID "name with spaces" type - if ($line =~ /^(\d+)\s+"([^"]+)"\s+(\S+)/) { - $collectors{$2} = { pid => $1, type => $3 }; - } - } - close($lock_fh); - } - - return %collectors; -} - -sub _is_collector_running { - my ($collector_name, $lock_path) = @_; - - my %collectors = _read_collector_lock($lock_path); - - if (exists $collectors{$collector_name}) { - my $pid = $collectors{$collector_name}->{pid}; - if (_is_process_alive($pid)) { - _debug(__LINE__, "Collector '$collector_name' already running with PID $pid"); - return $pid; - } else { - _debug(__LINE__, "Collector '$collector_name' PID $pid is stale"); - } - } - - return undef; -} +# Legacy PID file functions removed - worker now manages collectors directly via %collectors hash # ============================================================================ # Temperature Sensors @@ -1412,11 +1358,16 @@ sub _start_collector { _debug(__LINE__, "Starting $collector_type collector: $collector_name"); - # Check if already running - my $existing_pid = _is_collector_running($collector_name, $collectors_pid_file); - if ($existing_pid) { - _debug(__LINE__, "$collector_type collector '$collector_name' already running with PID $existing_pid"); - return $existing_pid; + # Check if already running (in worker's hash) + if (exists $collectors{$collector_name}) { + my $pid = $collectors{$collector_name}; + if (kill(0, $pid)) { + _debug(__LINE__, "$collector_type collector '$collector_name' already running with PID $pid"); + return $pid; + } else { + _debug(__LINE__, "Collector '$collector_name' PID $pid is stale, removing from registry"); + delete $collectors{$collector_name}; + } } # Start the collector @@ -1427,22 +1378,9 @@ sub _start_collector { return undef; } - # Read existing entries from lock file - my @collector_entries; - my %existing = _read_collector_lock($collectors_pid_file); - foreach my $name (keys %existing) { - push @collector_entries, [$existing{$name}->{pid}, $name, $existing{$name}->{type}]; - } - - # Add new collector entry - push @collector_entries, [$pid, $collector_name, $collector_type]; - - # Write updated lock file - unless (_write_collector_lock($collectors_pid_file, @collector_entries)) { - _debug(__LINE__, "Failed to update lock file, terminating $collector_type collector"); - kill 'TERM', $pid; - return undef; - } + # Register in worker's hash + $collectors{$collector_name} = $pid; + _debug(__LINE__, "Registered $collector_type collector '$collector_name' with PID $pid"); # Verify it's alive sleep 0.1; @@ -1451,6 +1389,7 @@ sub _start_collector { return $pid; } else { _debug(__LINE__, "WARNING - $collector_type collector '$collector_name' (PID $pid) died immediately!"); + delete $collectors{$collector_name}; return undef; } } @@ -1603,18 +1542,7 @@ sub _pve_mod_starter { $startup_fh->flush(); _debug(__LINE__, "Wrote PID, $$, to startup lock"); - # Start sensors collector first - _start_sensors_collector(); - - # Start graphics collectors - _start_graphics_collectors(); - - # Start UPS collector - _start_ups_collector(); - - _debug(__LINE__, "All collectors started"); - - # Start pve mod worker + # Start pve mod worker (which will start all collectors) _pve_mod_worker(); # Remove startup lock LAST @@ -1708,17 +1636,44 @@ sub _pve_mod_keep_alive { $last_activity = time(); _debug(__LINE__, "Activity ping received"); }; + + # SIGCHLD handler to prevent zombies and clean up collector registry + $SIG{CHLD} = sub { + while ((my $pid = waitpid(-1, WNOHANG)) > 0) { + my $exit_status = $? >> 8; + _debug(__LINE__, "Child process $pid exited with status $exit_status"); + + # Find and remove from collector registry + foreach my $name (keys %collectors) { + if ($collectors{$name} == $pid) { + _debug(__LINE__, "Collector '$name' (PID $pid) exited, removing from registry"); + delete $collectors{$name}; + last; + } + } + } + }; + $SIG{TERM} = sub { _debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down"); + _stop_collectors(); unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); }; $SIG{INT} = sub { _debug(__LINE__, "pve_mod_worker received SIGINT, shutting down"); + _stop_collectors(); unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); }; + # Worker now starts all collectors (moved from _pve_mod_starter) + _debug(__LINE__, "Worker starting all collectors"); + _start_sensors_collector(); + _start_graphics_collectors(); + _start_ups_collector(); + _debug(__LINE__, "All collectors started by worker"); + _debug(__LINE__, "Entering pve_mod_worker loop, timeout=${COLLECTOR_TIMEOUT}s"); while (1) { @@ -1753,37 +1708,25 @@ sub _is_pve_mod_worker_running { sub _stop_collectors { _debug(__LINE__, "Stopping all collectors"); - # Read current PIDs from lock file - my @pids; - if (open my $lock_fh, '<', $collectors_pid_file) { - while (my $line = <$lock_fh>) { - chomp $line; - # Extract PID from "PID card type" format - if ($line =~ /^(\d+)/) { - push @pids, $1; - } - } - close($lock_fh); - } - _debug(__LINE__, "Cleanup complete_1"); - + # Get PIDs from worker's collector registry + my @pids = values %collectors; + if (@pids) { _debug(__LINE__, "Sending SIGTERM to " . scalar(@pids) . " collector process(es)"); foreach my $pid (@pids) { - kill('TERM', $pid) if kill(0, $pid); + if (kill(0, $pid)) { + kill('TERM', $pid); + _debug(__LINE__, "Sent SIGTERM to collector PID $pid"); + } } - _debug(__LINE__, "Cleanup complete_2"); - - # Wait up to 5 seconds for graceful shutdown - my $timeout = 30; + # Wait up to 2 seconds for graceful shutdown + my $timeout = 2; my $start = time(); while (time() - $start < $timeout) { my $any_alive = 0; foreach my $pid (@pids) { - _debug(__LINE__, "Checking if collector process $pid is still alive"); if (kill(0, $pid)) { - _debug(__LINE__, "Collector process $pid is still alive"); $any_alive = 1; last; } @@ -1791,7 +1734,7 @@ sub _stop_collectors { last unless $any_alive; select(undef, undef, undef, 0.1); } - _debug(__LINE__, "Cleanup complete_3"); + # Force kill any survivors foreach my $pid (@pids) { if (kill(0, $pid)) { @@ -1799,15 +1742,16 @@ sub _stop_collectors { kill('KILL', $pid); } } - _debug(__LINE__, "Cleanup complete_4"); } + # Clear collector registry + %collectors = (); + _debug(__LINE__, "Cleared collector registry"); + + # Remove state files if (-f $state_file) { unlink $state_file or _debug(__LINE__, "Failed to remove $state_file: $!"); } - if (-f $collectors_pid_file) { - unlink $collectors_pid_file or _debug(__LINE__, "Failed to remove $collectors_pid_file: $!"); - } # Remove pve mod worker directory and all files if it exists if (-d $pve_mod_working_dir) { @@ -1815,7 +1759,7 @@ sub _stop_collectors { _debug(__LINE__, "Cleanup errors: @$err") if @$err; } - _debug(__LINE__, "Cleanup complete_5"); + _debug(__LINE__, "Cleanup complete"); } END { From 87a925aeeb187b502c12d69726797efcdd6a8b78 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 23:05:00 +0100 Subject: [PATCH 20/48] prettify process names --- GPUcollecter.pm | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/GPUcollecter.pm b/GPUcollecter.pm index 302932a..291dbf6 100644 --- a/GPUcollecter.pm +++ b/GPUcollecter.pm @@ -183,7 +183,7 @@ sub _get_intel_gpu_devices { sub _collector_for_intel_device { my ($device) = @_; $process_type = 'collector'; - $0 = "pve-mod-gpu-intel-collector: $device->{card}"; + $0 = "collector-gpu-intel-$device->{card}"; my $drm_dev = "drm:/dev/dri/$device->{card}"; my $intel_gpu_top_pid = undef; @@ -423,7 +423,7 @@ sub collector_for_nvidia_device { my ($device) = @_; $process_type = 'collector'; - $0 = "pve-mod-gpu-nvidia-collector: $device->{index}"; + $0 = "collector-gpu-nvidia-$device->{index}"; _debug(__LINE__, "NVIDIA collector started (stub implementation)"); # Set up signal handlers for graceful shutdown @@ -466,7 +466,7 @@ sub _start_child_collector { # Child process $process_type = 'collector'; _debug(__LINE__, "In child process for $collector_name"); - $0 = "pve-mod-$collector_name"; + $0 = "collector-$collector_name"; $collector_sub->($device); exit(0); } @@ -485,7 +485,7 @@ sub _start_child_collector { sub _collector_for_temperature_sensors { my ($device) = @_; $process_type = 'collector'; - $0 = "pve-mod-sensors-collector"; + $0 = "collector-temperature-sensors"; _debug(__LINE__, "Temperature sensor collector started"); @@ -958,7 +958,7 @@ sub _cpu_model_by_package { sub _collector_for_ups { my ($device) = @_; $process_type = 'collector'; - $0 = "pve-mod-ups-collector"; + $0 = "collector-ups-$device->{ups_name}"; _debug(__LINE__, "UPS collector started"); # Set up signal handlers for graceful shutdown From 80e73081b63fda91eb34aebce07c4e06d3e3cb9c Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 23:05:32 +0100 Subject: [PATCH 21/48] rename pve mod perl script --- GPUcollecter.pm => pve-mod.pm | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename GPUcollecter.pm => pve-mod.pm (100%) diff --git a/GPUcollecter.pm b/pve-mod.pm similarity index 100% rename from GPUcollecter.pm rename to pve-mod.pm From e8602206059de313726be3252eefe37520362f65 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sat, 10 Jan 2026 23:25:24 +0100 Subject: [PATCH 22/48] fix ups not working --- pve-mod.pm | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/pve-mod.pm b/pve-mod.pm index 291dbf6..0bf15f1 100644 --- a/pve-mod.pm +++ b/pve-mod.pm @@ -995,32 +995,26 @@ sub _get_ups_status { my ($ups_name) = @_; # upsc upsname[@hostname[:port]] - my $cmd = "upsc $ups_name 2>/dev/null"; - - _debug(__LINE__, "Running command: $cmd"); + _debug(__LINE__, "Collecting UPS status for $ups_name"); # Execute command and capture output - my $output = `$cmd`; - - _debug(__LINE__, "upsc command output collected"); - - my $exit_code = $? >> 8; + my $output = `/usr/bin/upsc $ups_name 2>/dev/null`; - _debug(__LINE__, "upsc command exited with code $exit_code"); + unless (defined $output) { + _debug(__LINE__, "Failed to execute upsc"); + return encode_json({ error => "Failed to execute upsc" }); + } - if ($exit_code != 0) { - _debug(__LINE__, "upsc command failed with exit code $exit_code"); - return encode_json({ error => "Failed to execute upsc for $ups_name" }); + # Check if we got any output + unless (defined $output && length($output) > 0) { + _debug(__LINE__, "No output from upsc for $ups_name"); + return encode_json({ error => "No data from UPS $ups_name" }); } - - _debug(__LINE__, "upsc command executed successfully"); # Convert upsc output to nested hash structure my $ups_data = _parse_upsc_output($output); - - _debug(__LINE__, "Parsed upsc output for $ups_name"); - # Check if we got any data + # Check if we got any parsed data unless (keys %$ups_data) { _debug(__LINE__, "No data received from upsc for $ups_name"); return encode_json({ error => "No data from UPS $ups_name" }); @@ -1031,8 +1025,6 @@ sub _get_ups_status { $ups_name => $ups_data }; - _debug(__LINE__, "Successfully parsed UPS data for $ups_name"); - # Return as pretty JSON return JSON->new->pretty->canonical->encode($result); } @@ -1496,6 +1488,12 @@ sub _start_ups_collector { _debug(__LINE__, "Starting UPS collector"); + # Check if upsc is available + unless (-x '/usr/bin/upsc') { + _debug(__LINE__, "upsc not available, skipping UPS collector startup"); + return; + } + # Check if UPS is configured unless (defined $ups_device && $ups_device->{ups_name}) { _debug(__LINE__, "No UPS configured, skipping collector startup"); From 25389e0ce9b93843d226cd6346b42179141b8448 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 09:22:24 +0100 Subject: [PATCH 23/48] finalise NVIDA implementation as a combined call --- pve-mod.pm | 228 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 170 insertions(+), 58 deletions(-) diff --git a/pve-mod.pm b/pve-mod.pm index 0bf15f1..e600830 100644 --- a/pve-mod.pm +++ b/pve-mod.pm @@ -58,6 +58,9 @@ my $data_pull_interval = 1; # Interval in seconds between data pulls my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) my $nvidia_gpu_enabled = 1; # Set to 1 to enable NVIDIA GPU support (not yet implemented) +my $nvidia_debug_mode = 1; # Set to 1 to enable NVIDIA debug mode (load from files instead of nvidia-smi) +my $nvidia_debug_devices = '/tmp/nvidia-smi-devices.csv'; +my $nvidia_debug_output = '/tmp/nvidia-smi-output.csv'; my $ups_enabled = 1; # Set to 1 to enable UPS support my $pve_mod_worker_pid; my $pve_mod_worker_running = 0; @@ -312,18 +315,18 @@ sub get_nvidia_gpu_devices { # 0, NVIDIA GeForce RTX 3080 # 1, NVIDIA RTX A4000 - # add debug mode where Expected format is loaded from a file instead or calling nvidia-smi - my $debug_file = '/tmp/nvidia-smi-debug.csv'; - my $use_debug_file = 1; # Set to 1 to enable debug mode - # todo - delete this block later - if ($use_debug_file && -f $debug_file) { - _debug(__LINE__, "Debug mode: reading NVIDIA GPU data from $debug_file"); - if (open my $fh, '<', $debug_file) { + + if ($nvidia_debug_mode && -f $nvidia_debug_devices) { + _debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $nvidia_debug_devices"); + if (open my $fh, '<', $nvidia_debug_devices) { + my $line_num = 0; while (<$fh>) { chomp; - # Skip empty lines - next if /^\s*$/; + $line_num++; + + # Skip header line and empty lines + next if $line_num == 1 || /^\s*$/; # Parse CSV: "0, NVIDIA GeForce RTX 3080" if (/^\s*(\d+)\s*,\s*(.+?)\s*$/) { @@ -338,35 +341,37 @@ sub get_nvidia_gpu_devices { } close $fh; } else { - _debug(__LINE__, "Failed to open debug file $debug_file: $!"); + _debug(__LINE__, "Failed to open debug file $nvidia_debug_devices: $!"); + } + } else { + # Use nvidia-smi to get device list + if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header line and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse CSV: "0, NVIDIA GeForce RTX 3080" + if (/^\s*(\d+)\s*,\s*(.+?)\s*$/) { + my $index = $1; + my $name = $2; + push @devices, { + name => $name, + index => $index, + }; + _debug(__LINE__, "Found NVIDIA GPU device: $name -> (index: $index)"); + } + } + close $fh; + } else { + _debug(__LINE__, "Failed to run nvidia-smi: $!"); } } + return @devices; - - - # if (open my $fh, '-|', 'nvidia-smi --query-gpu=index,name --format=csv') { - # while (<$fh>) { - # chomp; - # # Skip empty lines - # next if /^\s*$/; - - # # Parse CSV: "0, NVIDIA GeForce RTX 3080" - # if (/^\s*(\d+)\s*,\s*(.+?)\s*$/) { - # my $index = $1; - # my $name = $2; - # push @devices, { - # name => $name, - # index => $index, - # }; - # _debug(__LINE__, "Found NVIDIA GPU device: $name -> (index: $index)"); - # } - # } - # close $fh; - # } else { - # _debug(__LINE__, "Failed to run nvidia-smi: $!"); - # } - - # return @devices; } sub parse_nvidia_gpu_line { @@ -419,12 +424,13 @@ sub parse_nvidia_gpu_line { return $stats; } -sub collector_for_nvidia_device { - my ($device) = @_; +sub _collector_for_nvidia_devices { + my ($devices) = @_; $process_type = 'collector'; - $0 = "collector-gpu-nvidia-$device->{index}"; - _debug(__LINE__, "NVIDIA collector started (stub implementation)"); + $0 = "collector-gpu-nvidia-all"; + + _debug(__LINE__, "NVIDIA collector started for " . scalar(@$devices) . " GPU(s)"); # Set up signal handlers for graceful shutdown my $shutdown = 0; @@ -437,10 +443,107 @@ sub collector_for_nvidia_device { $shutdown = 1; }; - # TODO: Implement actual NVIDIA monitoring + # Expected CSV format (with header): + # index, name, temperature.gpu, utilization.gpu, utilization.memory, memory.used, memory.total, power.draw, power.limit, fan.speed + # 0, NVIDIA GeForce RTX 3080, 62, 79, 44, 8260, 10240, 268.12, 320.00, 67 + # 1, NVIDIA RTX A4000, 58, 45, 32, 4120, 16384, 145.50, 200.00, 55 + while (!$shutdown) { - _debug(__LINE__, "NVIDIA collector running (stub)"); - sleep $data_pull_interval; + my @all_stats; + + if ($nvidia_debug_mode && -f $nvidia_debug_output) { + # Debug mode: read all GPUs from single file + _debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $nvidia_debug_output"); + if (open my $fh, '<', $nvidia_debug_output) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse the stats line + my $stats = parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } else { + _debug(__LINE__, "Failed to open debug file $nvidia_debug_output: $!"); + } + } else { + # Production mode: query all GPUs at once + my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed'; + my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits"; + + if (open my $fh, '-|', $cmd) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse the stats line + my $stats = parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } else { + _debug(__LINE__, "Failed to run nvidia-smi: $!"); + } + } + + # Write each GPU's stats to its own file + foreach my $stats (@all_stats) { + my $device_index = $stats->{index}; + + # Untaint device_index for file operations (validate it's a number) + unless ($device_index =~ /^(\d+)$/) { + _debug(__LINE__, "Invalid device index: $device_index, skipping"); + next; + } + $device_index = $1; # Now untainted + + my $node_name = "gpu$device_index"; + my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json"; + + # Find device name from devices array + my $device_name = $stats->{name}; # Fallback to name from stats + foreach my $dev (@$devices) { + if ($dev->{index} == $device_index) { + $device_name = $dev->{name}; + last; + } + } + + # Build device-specific structure + my $device_data = { + $node_name => { + name => $device_name, + index => $device_index, + stats => $stats + } + }; + + # Write to device-specific file + eval { + open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; + print $ofh JSON->new->pretty->encode($device_data); + close $ofh; + _debug(__LINE__, "Wrote NVIDIA GPU $device_index stats to $device_state_file"); + }; + if ($@) { + _debug(__LINE__, "Error writing NVIDIA stats for GPU $device_index: $@"); + } + } + + unless (@all_stats) { + _debug(__LINE__, "No valid NVIDIA GPU stats collected"); + } + + sleep $data_pull_interval unless $shutdown; } _debug(__LINE__, "NVIDIA collector shutting down"); @@ -1196,7 +1299,7 @@ sub get_graphic_stats { return $last_snapshot; } - my @stat_files = grep { /^stats-card\d+\.json$/ } readdir($dh); + my @stat_files = grep { /^stats-(card\d+|nvidia\d+)\.json$/ } readdir($dh); closedir($dh); unless (@stat_files) { @@ -1228,7 +1331,8 @@ sub get_graphic_stats { # Merge all device files my $merged = { Graphics => { - Intel => {} + Intel => {}, + NVIDIA => {} } }; @@ -1252,10 +1356,13 @@ sub get_graphic_stats { my $device_data = decode_json($json); + # Determine device type from filename and merge accordingly + my $device_type = ($file =~ /^stats-card/) ? 'Intel' : 'NVIDIA'; + # Merge this device's data into the main structure foreach my $node_name (keys %$device_data) { - $merged->{Graphics}->{Intel}->{$node_name} = $device_data->{$node_name}; - _debug(__LINE__, "Merged node '$node_name' from $file"); + $merged->{Graphics}->{$device_type}->{$node_name} = $device_data->{$node_name}; + _debug(__LINE__, "Merged $device_type node '$node_name' from $file"); } }; if ($@) { @@ -1268,7 +1375,9 @@ sub get_graphic_stats { $last_mtime = $newest_mtime; $last_get_graphic_stats_time = time(); - _debug(__LINE__, "Successfully merged " . scalar(keys %{$merged->{Graphics}->{Intel}}) . " device node(s)"); + my $intel_count = scalar(keys %{$merged->{Graphics}->{Intel}}); + my $nvidia_count = scalar(keys %{$merged->{Graphics}->{NVIDIA}}); + _debug(__LINE__, "Successfully merged $intel_count Intel + $nvidia_count NVIDIA device node(s)"); # Notify pve_mod_worker of activity _notify_pve_mod_worker(); @@ -1435,24 +1544,27 @@ sub _start_graphics_collectors { push @all_collector_subs, \&_collector_for_amd_device; } } + + _debug(__LINE__, "Finished detecting devices. Total collectors to manage: " . scalar(@all_devices)); + + # Start each graphics collector using unified function (Intel/AMD only - NVIDIA handled separately) + my $started_count = 0; - # NVIDIA (future) + # NVIDIA - single collector for all devices if ($nvidia_gpu_enabled) { _debug(__LINE__, "NVIDIA GPU support enabled"); + # return unless _check_executable('/usr/bin/nvidia-smi', 'NVIDIA'); + my @nvidia_devices = get_nvidia_gpu_devices(); _debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); - foreach my $device (@nvidia_devices) { - push @all_devices, $device; - push @all_types, 'nvidia'; - push @all_collector_subs, \&collector_for_nvidia_device; + + if (@nvidia_devices) { + # Start single collector for all NVIDIA GPUs + my $pid = _start_collector('nvidia-all', 'nvidia', \&_collector_for_nvidia_devices, \@nvidia_devices); + $started_count++ if $pid; } } - - _debug(__LINE__, "Finished detecting devices. Total collectors to manage: " . scalar(@all_devices)); - - # Start each graphics collector using unified function - my $started_count = 0; for (my $i = 0; $i < @all_devices; $i++) { my $device = $all_devices[$i]; my $type = $all_types[$i]; @@ -1463,7 +1575,7 @@ sub _start_graphics_collectors { $started_count++ if $pid; } - _debug(__LINE__, "Started/verified $started_count graphics collector(s)"); + _debug(__LINE__, "Started/verified $started_count graphics collector(s) (Intel/AMD)"); } sub _start_sensors_collector { From 5855e8be25c12e547a010d493e7eca4e891d1048 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 09:29:16 +0100 Subject: [PATCH 24/48] update gui --- pvemanagerlib.js | 340 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 305 insertions(+), 35 deletions(-) diff --git a/pvemanagerlib.js b/pvemanagerlib.js index c35b83b..2d71d6f 100644 --- a/pvemanagerlib.js +++ b/pvemanagerlib.js @@ -49105,7 +49105,9 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('KSM sharing'), textField: 'ksm', - renderer: (record) => Proxmox.Utils.render_size(record.shared), + renderer: function (record) { + return Proxmox.Utils.render_size(record.shared); + }, padding: '0 10 10 10', }, { @@ -49128,53 +49130,99 @@ Ext.define('PVE.node.StatusView', { printBar: false, textField: 'gpuStats', renderer: function(gpuStats) { - console.log(gpuStats); if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.Intel) { return 'N/A'; } let html = ''; - Object.keys(gpuStats.Graphics.Intel).forEach(key => { - const gpuData = gpuStats.Graphics.Intel[key]; - console.log("here1"); - html += `
    `; - html += `
    ${gpuData.name}
    `; - html += `
    `; - - if (gpuData.stats.engines) { - console.log("here2"); - // Render/3D - if (gpuData.stats.engines['Render/3D']) { - html += `Render/3D: ${gpuData.stats.engines['Render/3D'].busy}% | `; + console.log(gpuStats); + + // Intel GPUs + if (gpuStats.Graphics.Intel) { + Object.keys(gpuStats.Graphics.Intel).sort().forEach(key => { + const gpuData = gpuStats.Graphics.Intel[key]; + html += `
    `; + html += `
    ${gpuData.name}
    `; + html += `
    `; + + if (gpuData.stats.engines) { + // Render/3D + if (gpuData.stats.engines['Render/3D']) { + html += `Render/3D: ${gpuData.stats.engines['Render/3D'].busy}% | `; + } + + // Video + if (gpuData.stats.engines['Video']) { + html += `Video: ${gpuData.stats.engines['Video'].busy}% | `; + } + + // Blitter + if (gpuData.stats.engines['Blitter']) { + html += `Blitter: ${gpuData.stats.engines['Blitter'].busy}% | `; + } + + // VideoEnhance + if (gpuData.stats.engines['VideoEnhance']) { + html += `VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}% | `; + } } - // Video - if (gpuData.stats.engines['Video']) { - html += `Video: ${gpuData.stats.engines['Video'].busy}% | `; + // Power and Frequency info + html += `Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`; + html += ` | Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.frequency?.unit || 'MHz'}`; + + html += `
    `; + }); + } + + // NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData.stats; + + html += `
    `; + html += `
    ${stats.name}
    `; + html += `
    `; + + // GPU Utilization + if (stats.utilization) { + html += `GPU: ${stats.utilization.gpu}${stats.utilization.unit} | `; + html += `MEM: ${stats.utilization.memory}${stats.utilization.unit} | `; } - // Blitter - if (gpuData.stats.engines['Blitter']) { - html += `Blitter: ${gpuData.stats.engines['Blitter'].busy}% | `; + // Memory Usage + if (stats.memory) { + html += `VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit} | `; } - // VideoEnhance - if (gpuData.stats.engines['VideoEnhance']) { - html += `VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}% | `; + // Temperature + if (stats.temperature) { + let tempStyle = ''; + if (stats.temperature.gpu >= 80) { + tempStyle = 'color: red; font-weight: bold;'; + } else if (stats.temperature.gpu >= 70) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + html += `Temp: ${stats.temperature.gpu}${stats.temperature.unit} | `; } - } - - // Power and Frequency info - html += `Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`; - html += ` | Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.frequency?.unit || 'MHz'}`; - - html += `
    `; - }); - - // todo add NVIDIA + + // Fan Speed + if (stats.fan) { + html += `Fan: ${stats.fan.speed}${stats.fan.unit} | `; + } + + // Power + if (stats.power) { + html += `Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`; + } + + html += `
    `; + }); + } - // todo add NVIDIA + // todo add AMD return html; }, @@ -49318,7 +49366,11 @@ Ext.define('PVE.node.StatusView', { // Call the recursive function to find fan keys and values findFanKeys(parentObj, fanKeys); // Sort the fan keys - fanKeys.sort(); + fanKeys.sort((a, b) => { + if (a.key < b.key) return -1; + if (a.key > b.key) return 1; + return 0; + }); // Process each fan key and value fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { try { @@ -49332,6 +49384,216 @@ Ext.define('PVE.node.StatusView', { return '
    ' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
    '; } }, + { + xtype: 'box', + colspan: 2, + html: gettext('UPS'), + }, + { + itemId: 'upsc', + colspan: 2, + printBar: false, + title: gettext('Device'), + iconCls: 'fa fa-fw fa-battery-three-quarters', + textField: 'upsStats', + renderer: function(value) { + let objValue = {}; + try { + // Parse the UPS data + if (typeof value === 'string') { + objValue = JSON.parse(value) || {}; + } else if (typeof value === 'object') { + objValue = value || {}; + } + } catch(e) { + objValue = {}; + } + + // If objValue is null or empty, return N/A + if (!objValue || Object.keys(objValue).length === 0) { + return '
    N/A
    '; + } + + // Helper function to get status color + function getStatusColor(status) { + if (!status) return '#999'; + const statusUpper = status.toUpperCase(); + if (statusUpper.includes('OL')) return null; + if (statusUpper.includes('OB')) return '#d9534f'; + if (statusUpper.includes('LB')) return '#d9534f'; + return '#f0ad4e'; + } + + // Helper function to get load/charge color + function getPercentageColor(value, isLoad = false) { + if (!value || isNaN(value)) return '#999'; + const num = parseFloat(value); + if (isLoad) { + if (num >= 80) return '#d9534f'; + if (num >= 60) return '#f0ad4e'; + return null; + } else { + if (num <= 20) return '#d9534f'; + if (num <= 50) return '#f0ad4e'; + return null; + } + } + + // Helper function to format runtime + function formatRuntime(seconds) { + if (!seconds || isNaN(seconds)) return 'N/A'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}m ${secs}s`; + } + + // Process each UPS in the data + let allDisplayItems = []; + + Object.keys(objValue).forEach(upsKey => { + const upsData = objValue[upsKey]; + + // Extract key UPS information + const batteryCharge = upsData['battery.charge']; + const batteryRuntime = upsData['battery.runtime']; + const inputVoltage = upsData['input.voltage']; + const upsLoad = upsData['ups.load']; + const upsStatus = upsData['ups.status']; + const upsModel = upsData['ups.model'] || upsData['device.model']; + const testResult = upsData['ups.test.result']; + const batteryChargeLow = upsData['battery.charge.low']; + const batteryRuntimeLow = upsData['battery.runtime.low']; + const upsRealPowerNominal = upsData['ups.realpower.nominal']; + const batteryMfrDate = upsData['battery.mfr.date']; + + let displayItems = []; + + // First line: UPS identifier and model + let modelLine = ''; + if (upsModel) { + modelLine = `${upsModel}:`; + } else { + modelLine = `${upsKey}: N/A`; + } + displayItems.push(modelLine); + + // Main status line with all metrics + let statusLine = ''; + + // Status + if (upsStatus) { + const statusUpper = upsStatus.toUpperCase(); + let statusText = 'Unknown'; + let statusColor = '#f0ad4e'; + + if (statusUpper.includes('OL')) { + statusText = 'Online'; + statusColor = null; + } else if (statusUpper.includes('OB')) { + statusText = 'On Battery'; + statusColor = '#d9534f'; + } else if (statusUpper.includes('LB')) { + statusText = 'Low Battery'; + statusColor = '#d9534f'; + } else { + statusText = upsStatus; + statusColor = '#f0ad4e'; + } + + let statusStyle = statusColor ? ('color: ' + statusColor + ';') : ''; + statusLine += 'Status: ' + statusText + ''; + } else { + statusLine += 'Status: N/A'; + } + + // Battery charge + if (statusLine) statusLine += ' | '; + if (batteryCharge) { + const chargeColor = getPercentageColor(batteryCharge, false); + let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; + statusLine += 'Battery: ' + batteryCharge + '%'; + } else { + statusLine += 'Battery: N/A'; + } + + // Load percentage + if (statusLine) statusLine += ' | '; + if (upsLoad) { + const loadColor = getPercentageColor(upsLoad, true); + let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; + statusLine += 'Load: ' + upsLoad + '%'; + } else { + statusLine += 'Load: N/A'; + } + + // Runtime + if (statusLine) statusLine += ' | '; + if (batteryRuntime) { + const runtime = parseInt(batteryRuntime); + const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600; + let runtimeColor = null; + if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; + else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; + let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : ''; + statusLine += 'Runtime: ' + formatRuntime(runtime) + ''; + } else { + statusLine += 'Runtime: N/A'; + } + + // Input voltage + if (statusLine) statusLine += ' | '; + if (inputVoltage) { + statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; + } else { + statusLine += 'Input: N/A'; + } + + // Calculate actual watt usage + if (statusLine) statusLine += ' | '; + let actualWattage = null; + if (upsLoad && upsRealPowerNominal) { + const load = parseFloat(upsLoad); + const nominal = parseFloat(upsRealPowerNominal); + if (!isNaN(load) && !isNaN(nominal)) { + actualWattage = Math.round((load / 100) * nominal); + } + } + + // Real power (calculated watt usage) + if (actualWattage !== null) { + statusLine += 'Output: ' + actualWattage + 'W'; + } else { + statusLine += 'Output: N/A'; + } + + displayItems.push(statusLine); + + // Combined battery and test line + let batteryTestLine = ''; + if (batteryMfrDate) { + batteryTestLine += 'Battery MFD: ' + batteryMfrDate + ''; + } else { + batteryTestLine += 'Battery MFD: N/A'; + } + + if (testResult && !testResult.toLowerCase().includes('no test')) { + const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; + let testStyle = testColor ? ('color: ' + testColor + ';') : ''; + batteryTestLine += ' | Test: ' + testResult + ''; + } else { + batteryTestLine += ' | Test: N/A'; + } + + displayItems.push(batteryTestLine); + + // Add this UPS's display to the overall collection + allDisplayItems.push('
    ' + displayItems.join('
    ') + '
    '); + }); + + // Format the final output for all UPS devices + return '
    ' + allDisplayItems.join('') + '
    '; + } + }, { xtype: 'box', colspan: 2, @@ -49927,6 +50189,14 @@ Ext.define('PVE.node.Summary', { store: rrdstore, unit: 'percent', }, + { + // todo find me + xtype: 'proxmoxRRDChart', + title: gettext('Graphics Intel'), + fields: ['netin', 'netout'], + fieldTitles: [gettext('Incoming'), gettext('Outgoing')], + store: rrdstore, + }, ], listeners: { resize: function (panel) { From f76a4c23d795cebd1a0a832915c9714de4d4e1aa Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 11:19:17 +0100 Subject: [PATCH 25/48] refactor read_sysfs add pve_mod_version api --- pve-mod.pm | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/pve-mod.pm b/pve-mod.pm index e600830..3baa756 100644 --- a/pve-mod.pm +++ b/pve-mod.pm @@ -995,26 +995,6 @@ sub _get_cpu_name { return $enhanced_json; } -sub read_sysfs { - my ($path) = @_; - - return "unknown" unless defined $path && -f $path; - - if (open my $fh, '<', $path) { - my $value = <$fh>; - close $fh; - - if (defined $value) { - chomp $value; - # Remove leading/trailing whitespace - $value =~ s/^\s+|\s+$//g; - return $value ne '' ? $value : "unknown"; - } - } - - return "unknown"; -} - # Helper function to get CPU model by package ID sub _cpu_model_by_package { my ($pkg) = @_; @@ -1176,6 +1156,26 @@ sub _parse_upsc_output { # Supporting functions # ============================================================================ +sub read_sysfs { + my ($path) = @_; + + return "unknown" unless defined $path && -f $path; + + if (open my $fh, '<', $path) { + my $value = <$fh>; + close $fh; + + if (defined $value) { + chomp $value; + # Remove leading/trailing whitespace + $value =~ s/^\s+|\s+$//g; + return $value ne '' ? $value : "unknown"; + } + } + + return "unknown"; +} + sub _is_process_alive { my ($pid) = @_; return -d "/proc/$pid"; @@ -1450,6 +1450,10 @@ sub get_ups_stats { return $ups_data; } +sub get_pve_mod_version { + return $VERSION; +} + # ============================================================================ # Main Collector # ============================================================================ From e19f9174c91cc9be82f098b9d32449c379099ddb Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 11:27:09 +0100 Subject: [PATCH 26/48] rework gui --- pvemanagerlib.js | 511 ++++++++++++++++++++++++++++------------------- 1 file changed, 310 insertions(+), 201 deletions(-) diff --git a/pvemanagerlib.js b/pvemanagerlib.js index 2d71d6f..2efa46c 100644 --- a/pvemanagerlib.js +++ b/pvemanagerlib.js @@ -48875,46 +48875,177 @@ Ext.define('PVE.node.StatusView', { defaults: { xtype: 'pmxInfoWidget', - padding: '0 10 5 10', + padding: '0 10 2 10', }, items: [ + // ========== PRIMARY KPIs (Tier 1) ========== { - itemId: 'cpuss', + xtype: 'box', colspan: 2, - printBar: false, - title: gettext('CPU (s)'), - textField: 'cpuinfo', - renderer: Proxmox.Utils.render_cpu_model, - value: '', - }, + padding: '0', + html: '
    Primary Metrics
    ', + }, { itemId: 'cpu', iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', - title: gettext('Usage'), + title: gettext('CPU Usage'), valueField: 'cpu', maxField: 'cpuinfo', - renderer: Proxmox.Utils.render_node_cpu_usage, + renderer: function(value, record) { + let result = Proxmox.Utils.render_node_cpu_usage(value, record); + // Append CPU model if available + if (record && record.cpuinfo && record.cpuinfo.model) { + result += ` (${record.cpuinfo.model})`; + } + return result; + }, }, { - itemId: 'wait', - iconCls: 'fa fa-fw fa-clock-o', - title: gettext('IO delay'), - valueField: 'wait', - rowspan: 2, + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + itemId: 'memory', + title: gettext('Memory Usage'), + valueField: 'memory', + maxField: 'memory', + warningThreshold: 0.9, + criticalThreshold: 0.975, + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + itemId: 'ksm', + iconCls: 'fa fa-fw fa-clone', + printBar: false, + title: gettext('KSM sharing'), + textField: 'ksm', + renderer: function (record) { + return Proxmox.Utils.render_size(record.shared); + }, + }, + { + itemId: 'gpu', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Usage'), + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + let hasActiveGPU = false; + let gpuName = ''; + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + hasActiveGPU = true; + gpuName = gpuData.name; + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + hasActiveGPU = true; + gpuName = stats.name; + } + } + + return hasActiveGPU ? gpuName : ''; + }, + }, + { + itemId: 'gpu_usage', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU 0'), + valueField: 'gpuStats', + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + if (gpuData.stats.engines && gpuData.stats.engines['Render/3D']) { + const usage = gpuData.stats.engines['Render/3D'].busy; + return `${usage}%`; + } + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + if (stats.utilization) { + return `${stats.utilization.gpu}%`; + } + } + } + + return ''; + }, + }, + { + iconCls: 'fa fa-fw fa-hdd-o', + itemId: 'rootfs', + title: gettext('Disk (/) Usage'), + valueField: 'rootfs', + maxField: 'rootfs', + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + iconCls: 'fa fa-fw fa-refresh', + itemId: 'swap', + title: gettext('SWAP Usage'), + valueField: 'swap', + maxField: 'swap', + warningThreshold: 0.4, + criticalThreshold: 0.8, + renderer: Proxmox.Utils.render_node_size_usage, + }, + // Fill the remaining cell so the next colspan:2 section header starts on a new row. + { + xtype: 'box', + html: '', + padding: 0, + }, + + // ========== SECONDARY DETAILS (Tier 2) ========== + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
    Secondary Details
    ', }, { itemId: 'load', iconCls: 'fa fa-fw fa-tasks', - title: gettext('Load average'), + title: gettext('CPU Load Average'), printBar: false, textField: 'loadavg', }, + { + itemId: 'wait', + iconCls: 'fa fa-fw fa-clock-o', + title: gettext('CPU I/O Delay'), + valueField: 'wait', + }, { itemId: 'thermalCpu', colspan: 2, printBar: false, - title: gettext('Thermal State'), + title: gettext('CPU Thermal State'), iconCls: 'fa fa-fw fa-thermometer-half', textField: 'sensorsOutput', renderer: function(value){ @@ -49062,139 +49193,108 @@ Ext.define('PVE.node.StatusView', { } }); - let result = ''; + let html = ''; temps.forEach((cpuData, cpuIndex) => { const strCoreTemps = cpuData.temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); }); if(strCoreTemps.length > 0) { - let cpuPrefix = cpuCount > 1 ? `CPU ${cpuIndex + 1}` : 'CPU'; - let cpuModelStr = cpuData.model ? ` (${cpuData.model})` : ''; - result += `${cpuPrefix}${cpuModelStr}: ` + strCoreTemps.join('') + (cpuIndex < cpuCount - 1 ? '
    ' : ''); + let cpuLabel = cpuCount > 1 ? `Socket ${cpuIndex + 1}` : 'Socket 1'; + let cpuModelStr = cpuData.model || 'Unknown CPU'; + + html += ''; + html += ``; + html += ``; + html += ''; } }); - - return '
    ' + (result.length > 0 ? result : 'N/A') + '
    '; + html += '
    ${cpuModelStr}${strCoreTemps.join('')}
    '; + + return html.indexOf('') > 0 + ? '
    ' + html + '
    ' + : 'N/A'; } }, { - xtype: 'box', - colspan: 2, - padding: '0 0 10 0', - }, - { - itemId: 'memory2', + itemId: 'gpu_details', colspan: 2, - printBar: false, - title: gettext('Memory'), - textField: 'Memory', - }, - { - iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', - itemId: 'memory', - title: gettext('Usage'), - valueField: 'memory', - maxField: 'memory', - warningThreshold: 0.9, - criticalThreshold: 0.975, - // TODO: split out ARC usage - renderer: Proxmox.Utils.render_node_size_usage, - }, - { - itemId: 'ksm', - printBar: false, - title: gettext('KSM sharing'), - textField: 'ksm', - renderer: function (record) { - return Proxmox.Utils.render_size(record.shared); - }, - padding: '0 10 10 10', - }, - { - xtype: 'box', - colspan: 2, - padding: '0 0 10 0', - }, - { - itemId: 'gpu_heading', - colspan: 2, - printBar: false, - title: gettext('GPU(s)'), - textField: 'Memory', - }, - { - itemId: 'gpu', - colspan: 2, - iconCls: 'fa fa-desktop', - title: gettext('Device'), + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Details'), printBar: false, textField: 'gpuStats', renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.Intel) { - return 'N/A'; + if (!gpuStats || !gpuStats.Graphics) { + return ''; } - let html = ''; - - console.log(gpuStats); + let html = ''; - // Intel GPUs + // Intel GPUs - Secondary details if (gpuStats.Graphics.Intel) { Object.keys(gpuStats.Graphics.Intel).sort().forEach(key => { const gpuData = gpuStats.Graphics.Intel[key]; - html += `
    `; - html += `
    ${gpuData.name}
    `; - html += `
    `; + let details = []; + + // All engine details if (gpuData.stats.engines) { - // Render/3D if (gpuData.stats.engines['Render/3D']) { - html += `Render/3D: ${gpuData.stats.engines['Render/3D'].busy}% | `; + details.push(`Render/3D: ${gpuData.stats.engines['Render/3D'].busy}%`); } - - // Video if (gpuData.stats.engines['Video']) { - html += `Video: ${gpuData.stats.engines['Video'].busy}% | `; + details.push(`Video: ${gpuData.stats.engines['Video'].busy}%`); } - - // Blitter if (gpuData.stats.engines['Blitter']) { - html += `Blitter: ${gpuData.stats.engines['Blitter'].busy}% | `; + details.push(`Blitter: ${gpuData.stats.engines['Blitter'].busy}%`); } - - // VideoEnhance if (gpuData.stats.engines['VideoEnhance']) { - html += `VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}% | `; + details.push(`VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}%`); } } - // Power and Frequency info - html += `Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`; - html += ` | Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.frequency?.unit || 'MHz'}`; + // Power + if (gpuData.stats.power) { + details.push(`Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`); + } + + // Frequency + if (gpuData.stats.frequency) { + details.push(`Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.stats.frequency?.unit || 'MHz'}`); + } - html += `
    `; + html += ''; + html += ``; + html += ``; + html += ''; }); } - // NVIDIA GPUs + // NVIDIA GPUs - Secondary details if (gpuStats.Graphics.NVIDIA) { Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { const gpuData = gpuStats.Graphics.NVIDIA[key]; const stats = gpuData.stats; - html += `
    `; - html += `
    ${stats.name}
    `; - html += `
    `; + let details = []; - // GPU Utilization - if (stats.utilization) { - html += `GPU: ${stats.utilization.gpu}${stats.utilization.unit} | `; - html += `MEM: ${stats.utilization.memory}${stats.utilization.unit} | `; + // Memory Utilization + if (stats.utilization && stats.utilization.memory) { + const memUsage = parseInt(stats.utilization.memory); + let memStyle = ''; + if (memUsage >= 90) memStyle = 'color: #d9534f; font-weight: bold;'; + else if (memUsage >= 70) memStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`MEM: ${stats.utilization.memory}%`); } - // Memory Usage + // VRAM Usage if (stats.memory) { - html += `VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit} | `; + const vramUsedGB = parseInt(stats.memory.used); + const vramTotalGB = parseInt(stats.memory.total); + const vramPercent = (vramUsedGB / vramTotalGB) * 100; + let vramStyle = ''; + if (vramPercent >= 90) vramStyle = 'color: #d9534f; font-weight: bold;'; + else if (vramPercent >= 70) vramStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit}`); } // Temperature @@ -49205,62 +49305,32 @@ Ext.define('PVE.node.StatusView', { } else if (stats.temperature.gpu >= 70) { tempStyle = 'color: #FFC300; font-weight: bold;'; } - html += `Temp: ${stats.temperature.gpu}${stats.temperature.unit} | `; - } - - // Fan Speed - if (stats.fan) { - html += `Fan: ${stats.fan.speed}${stats.fan.unit} | `; + details.push(`Temp: ${stats.temperature.gpu}${stats.temperature.unit}`); } // Power if (stats.power) { - html += `Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`; + details.push(`Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`); } - html += `
    `; + html += ''; + html += ``; + html += ``; + html += ''; }); } - // todo add AMD - - return html; + html += '
    ${gpuData.name}${details.join(' | ')}
    ${stats.name}${details.join(' | ')}
    '; + return html.indexOf('') > 0 + ? '
    ' + html + '
    ' + : ''; }, - }, - { - xtype: 'box', - colspan: 2, - padding: '0 0 10 0', }, - { - itemId: 'Storage', - colspan: 2, - printBar: false, - title: gettext('Storage'), - textField: 'Memory', - }, - { - iconCls: 'fa fa-fw fa-hdd-o', - itemId: 'rootfs', - title: '/ ' + gettext('HD space'), - valueField: 'rootfs', - maxField: 'rootfs', - renderer: Proxmox.Utils.render_node_size_usage, - }, - { - iconCls: 'fa fa-fw fa-refresh', - itemId: 'swap', - printSize: true, - title: gettext('SWAP usage'), - valueField: 'swap', - maxField: 'swap', - renderer: Proxmox.Utils.render_node_size_usage, - }, { itemId: 'thermalNvme', colspan: 2, printBar: false, - title: gettext('NVMe Thermal State'), + title: gettext('NVMe Temperatures'), iconCls: 'fa fa-fw fa-thermometer-half', textField: 'sensorsOutput', renderer: function(value) { @@ -49279,7 +49349,7 @@ Ext.define('PVE.node.StatusView', { objValue = {}; } const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); - let temps = []; + let nvmeData = []; nvmeKeys.forEach((nvmeKey, index) => { try { let tempVal = NaN, tempMax = NaN, tempCrit = NaN, model = '', serial = ''; @@ -49291,9 +49361,10 @@ Ext.define('PVE.node.StatusView', { } else if (secondLevelKey.endsWith('_crit')) { tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); } - model = objValue[nvmeKey]['model'] || ''; - serial = objValue[nvmeKey]['serial'] || ''; }); + model = objValue[nvmeKey]['model'] || 'Unknown'; + serial = objValue[nvmeKey]['serial'] || ''; + if (!isNaN(tempVal)) { let tempStyle = ''; if (!isNaN(tempMax) && tempVal >= tempMax) { @@ -49302,31 +49373,49 @@ Ext.define('PVE.node.StatusView', { if (!isNaN(tempCrit) && tempVal >= tempCrit) { tempStyle = 'color: red; font-weight: bold;'; } - const tempStr = `${model} (${serial}): ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`; - temps.push(tempStr); + nvmeData.push({ + model: model, + serial: serial, + temp: tempVal, + tempStyle: tempStyle, + unit: tempHelper.getUnit() + }); } } catch(e) { /*_*/ } }); - const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); }); - return '
    ' + (result.length > 0 ? result.join('') : 'N/A') + '
    '; + + if (nvmeData.length === 0) { + return 'N/A'; + } + + let html = ''; + nvmeData.forEach((data) => { + let deviceName = data.model; + if (data.serial) { + deviceName += ` (${data.serial})`; + } + html += ''; + html += ``; + html += ``; + html += ''; + }); + html += '
    ${deviceName}${Ext.util.Format.number(data.temp, '0.0')}${data.unit}
    '; + return '
    ' + html + '
    '; } }, + + // ========== TERTIARY DIAGNOSTICS (Tier 3) ========== { xtype: 'box', colspan: 2, - padding: '0 0 10 0', - }, - { - itemId: 'Coooling', - colspan: 2, - printBar: false, - title: gettext('Cooling'), + padding: '15 0 5 0', + html: '
    Diagnostics
    ', }, { itemId: 'speedFan', colspan: 2, printBar: false, - title: gettext('Fan Speed(s)'), + title: gettext('System Fans'), iconCls: 'fa fa-fw fa-snowflake-o', textField: 'sensorsOutput', renderer: function(value) { @@ -49381,19 +49470,54 @@ Ext.define('PVE.node.StatusView', { } }); }); - return '
    ' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
    '; + return '
    ' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
    '; } }, { - xtype: 'box', - colspan: 2, - html: gettext('UPS'), - }, - { + itemId: 'gpuFans', + colspan: 2, + printBar: false, + title: gettext('GPU Fans'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.NVIDIA) { + return 'N/A'; + } + + let rows = []; + + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData?.stats; + const fan = stats?.fan; + + if (!fan || fan.speed === undefined || fan.speed === null) { + return; + } + + const gpuName = stats?.name || key; + const unit = fan.unit || '%'; + rows.push( + '' + + `${gpuName}` + + `Fan: ${fan.speed}${unit}` + + '', + ); + }); + + if (rows.length === 0) { + return 'N/A'; + } + + return '
    ' + rows.join('') + '
    '; + }, + }, + { itemId: 'upsc', colspan: 2, printBar: false, - title: gettext('Device'), + title: gettext('UPS Status'), iconCls: 'fa fa-fw fa-battery-three-quarters', textField: 'upsStats', renderer: function(value) { @@ -49411,7 +49535,7 @@ Ext.define('PVE.node.StatusView', { // If objValue is null or empty, return N/A if (!objValue || Object.keys(objValue).length === 0) { - return '
    N/A
    '; + return 'N/A'; } // Helper function to get status color @@ -49466,17 +49590,6 @@ Ext.define('PVE.node.StatusView', { const upsRealPowerNominal = upsData['ups.realpower.nominal']; const batteryMfrDate = upsData['battery.mfr.date']; - let displayItems = []; - - // First line: UPS identifier and model - let modelLine = ''; - if (upsModel) { - modelLine = `${upsModel}:`; - } else { - modelLine = `${upsKey}: N/A`; - } - displayItems.push(modelLine); - // Main status line with all metrics let statusLine = ''; @@ -49566,39 +49679,35 @@ Ext.define('PVE.node.StatusView', { statusLine += 'Output: N/A'; } - displayItems.push(statusLine); - - // Combined battery and test line - let batteryTestLine = ''; - if (batteryMfrDate) { - batteryTestLine += 'Battery MFD: ' + batteryMfrDate + ''; - } else { - batteryTestLine += 'Battery MFD: N/A'; - } - + // Append battery MFD + last test to the same line (single-line UPS summary) + statusLine += ' | Battery MFD: ' + (batteryMfrDate || 'N/A'); if (testResult && !testResult.toLowerCase().includes('no test')) { const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; let testStyle = testColor ? ('color: ' + testColor + ';') : ''; - batteryTestLine += ' | Test: ' + testResult + ''; + statusLine += ' | Test: ' + testResult + ''; } else { - batteryTestLine += ' | Test: N/A'; + statusLine += ' | Test: N/A'; } - - displayItems.push(batteryTestLine); - // Add this UPS's display to the overall collection - allDisplayItems.push('
    ' + displayItems.join('
    ') + '
    '); + // Build UPS display with model on left, details on right + let upsHtml = ''; + upsHtml += '' + (upsModel || upsKey) + ''; + upsHtml += '' + statusLine + ''; + upsHtml += ''; + + allDisplayItems.push(upsHtml); }); // Format the final output for all UPS devices - return '
    ' + allDisplayItems.join('') + '
    '; + return '
    ' + allDisplayItems.join('') + '
    '; } - }, + }, { xtype: 'box', colspan: 2, - padding: '0 0 10 0', - }, + padding: '15 0 5 0', + html: '
    System
    ', + }, { colspan: 2, title: gettext('Kernel Version'), @@ -49644,7 +49753,7 @@ Ext.define('PVE.node.StatusView', { colspan: 2, printBar: false, title: gettext('Sensor Mod Version'), - textField: 'pveversion', + textField: 'pveModVersion', value: '', }, ], From 6fb9e1cf75453504d05a3ebed16d49e82ff92be1 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 14:00:04 +0100 Subject: [PATCH 27/48] refactor nvidia collection and cleanup --- pve-mod.pm | 205 +++++++++++++++++++++++++++-------------------------- 1 file changed, 105 insertions(+), 100 deletions(-) diff --git a/pve-mod.pm b/pve-mod.pm index 3baa756..f572d56 100644 --- a/pve-mod.pm +++ b/pve-mod.pm @@ -271,8 +271,9 @@ sub _collector_for_intel_device { sub _parse_graphic_info { my ($line) = @_; - # Create an intel file with - # Timestamp Device index, name, Render/3D, Blitter, Video, VideoEnhance, power consumption + # Create a RRD Database (One-Time Setup) + + # Collect intel GPU data and save it into the database return undef; } @@ -304,7 +305,7 @@ sub _collector_for_amd_device { } # ============================================================================ -# NVIDIA GPU Support (Placeholder) +# NVIDIA GPU Support # ============================================================================ sub get_nvidia_gpu_devices { @@ -314,8 +315,6 @@ sub get_nvidia_gpu_devices { # index, name # 0, NVIDIA GeForce RTX 3080 # 1, NVIDIA RTX A4000 - - if ($nvidia_debug_mode && -f $nvidia_debug_devices) { _debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $nvidia_debug_devices"); @@ -424,6 +423,105 @@ sub parse_nvidia_gpu_line { return $stats; } +sub _get_and_write_nvidia_stats { + my ($devices) = @_; + my @all_stats; + + if ($nvidia_debug_mode && -f $nvidia_debug_output) { + # Debug mode: read all GPUs from single file + _debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $nvidia_debug_output"); + if (open my $fh, '<', $nvidia_debug_output) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse the stats line + my $stats = parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } else { + _debug(__LINE__, "Failed to open debug file $nvidia_debug_output: $!"); + } + } else { + # Production mode: query all GPUs at once + my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed'; + my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits"; + + if (open my $fh, '-|', $cmd) { + my $line_num = 0; + while (<$fh>) { + chomp; + $line_num++; + + # Skip header and empty lines + next if $line_num == 1 || /^\s*$/; + + # Parse the stats line + my $stats = parse_nvidia_gpu_line($_); + push @all_stats, $stats if $stats; + } + close $fh; + } else { + _debug(__LINE__, "Failed to run nvidia-smi: $!"); + } + } + + # Write each GPU's stats to its own file + foreach my $stats (@all_stats) { + my $device_index = $stats->{index}; + + # Untaint device_index for file operations (validate it's a number) + unless ($device_index =~ /^(\d+)$/) { + _debug(__LINE__, "Invalid device index: $device_index, skipping"); + next; + } + $device_index = $1; # Now untainted + + my $node_name = "gpu$device_index"; + my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json"; + + # Find device name from devices array + my $device_name = $stats->{name}; # Fallback to name from stats + foreach my $dev (@$devices) { + if ($dev->{index} == $device_index) { + $device_name = $dev->{name}; + last; + } + } + + # Build device-specific structure + my $device_data = { + $node_name => { + name => $device_name, + index => $device_index, + stats => $stats + } + }; + + # Write to device-specific file + eval { + open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; + print $ofh JSON->new->pretty->encode($device_data); + close $ofh; + _debug(__LINE__, "Wrote NVIDIA GPU $device_index stats to $device_state_file"); + }; + if ($@) { + _debug(__LINE__, "Error writing NVIDIA stats for GPU $device_index: $@"); + } + } + + unless (@all_stats) { + _debug(__LINE__, "No valid NVIDIA GPU stats collected"); + } + + return scalar(@all_stats); +} + sub _collector_for_nvidia_devices { my ($devices) = @_; $process_type = 'collector'; @@ -449,99 +547,8 @@ sub _collector_for_nvidia_devices { # 1, NVIDIA RTX A4000, 58, 45, 32, 4120, 16384, 145.50, 200.00, 55 while (!$shutdown) { - my @all_stats; - - if ($nvidia_debug_mode && -f $nvidia_debug_output) { - # Debug mode: read all GPUs from single file - _debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $nvidia_debug_output"); - if (open my $fh, '<', $nvidia_debug_output) { - my $line_num = 0; - while (<$fh>) { - chomp; - $line_num++; - - # Skip header and empty lines - next if $line_num == 1 || /^\s*$/; - - # Parse the stats line - my $stats = parse_nvidia_gpu_line($_); - push @all_stats, $stats if $stats; - } - close $fh; - } else { - _debug(__LINE__, "Failed to open debug file $nvidia_debug_output: $!"); - } - } else { - # Production mode: query all GPUs at once - my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed'; - my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits"; - - if (open my $fh, '-|', $cmd) { - my $line_num = 0; - while (<$fh>) { - chomp; - $line_num++; - - # Skip header and empty lines - next if $line_num == 1 || /^\s*$/; - - # Parse the stats line - my $stats = parse_nvidia_gpu_line($_); - push @all_stats, $stats if $stats; - } - close $fh; - } else { - _debug(__LINE__, "Failed to run nvidia-smi: $!"); - } - } - - # Write each GPU's stats to its own file - foreach my $stats (@all_stats) { - my $device_index = $stats->{index}; - - # Untaint device_index for file operations (validate it's a number) - unless ($device_index =~ /^(\d+)$/) { - _debug(__LINE__, "Invalid device index: $device_index, skipping"); - next; - } - $device_index = $1; # Now untainted - - my $node_name = "gpu$device_index"; - my $device_state_file = "$pve_mod_working_dir/stats-nvidia$device_index.json"; - - # Find device name from devices array - my $device_name = $stats->{name}; # Fallback to name from stats - foreach my $dev (@$devices) { - if ($dev->{index} == $device_index) { - $device_name = $dev->{name}; - last; - } - } - - # Build device-specific structure - my $device_data = { - $node_name => { - name => $device_name, - index => $device_index, - stats => $stats - } - }; - - # Write to device-specific file - eval { - open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; - print $ofh JSON->new->pretty->encode($device_data); - close $ofh; - _debug(__LINE__, "Wrote NVIDIA GPU $device_index stats to $device_state_file"); - }; - if ($@) { - _debug(__LINE__, "Error writing NVIDIA stats for GPU $device_index: $@"); - } - } - - unless (@all_stats) { - _debug(__LINE__, "No valid NVIDIA GPU stats collected"); - } + # Collect and write NVIDIA GPU stats + _get_and_write_nvidia_stats($devices); sleep $data_pull_interval unless $shutdown; } @@ -579,8 +586,6 @@ sub _start_child_collector { return $pid; } -# Legacy PID file functions removed - worker now manages collectors directly via %collectors hash - # ============================================================================ # Temperature Sensors # ============================================================================ From 7fdc55643f43f3a8311efe986cfa283309b377bc Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 14:03:09 +0100 Subject: [PATCH 28/48] implement filebased lm-sensors output --- pve-mod.pm | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pve-mod.pm b/pve-mod.pm index f572d56..957dc2d 100644 --- a/pve-mod.pm +++ b/pve-mod.pm @@ -61,6 +61,8 @@ my $nvidia_gpu_enabled = 1; # Set to 1 to enable NVIDIA GPU support (not yet imp my $nvidia_debug_mode = 1; # Set to 1 to enable NVIDIA debug mode (load from files instead of nvidia-smi) my $nvidia_debug_devices = '/tmp/nvidia-smi-devices.csv'; my $nvidia_debug_output = '/tmp/nvidia-smi-output.csv'; +my $sensors_debug_mode = 0; # Set to 1 to enable sensors debug mode (load from file instead of sensors command) +my $sensors_debug_output = '/tmp/sensors-output.json'; my $ups_enabled = 1; # Set to 1 to enable UPS support my $pve_mod_worker_pid; my $pve_mod_worker_running = 0; @@ -644,7 +646,23 @@ sub _get_temperature_sensors { my $sensorsOutput; # Collect sensor data from lm-sensors - $sensorsOutput = `sensors -j 2>/dev/null | python3 -m json.tool`; + if ($sensors_debug_mode && -f $sensors_debug_output) { + # Debug mode: read from file + _debug(__LINE__, "Debug mode: reading sensors data from $sensors_debug_output"); + if (open my $fh, '<', $sensors_debug_output) { + local $/; + $sensorsOutput = <$fh>; + close $fh; + _debug(__LINE__, "Read sensors data from debug file, length: " . length($sensorsOutput) . " bytes"); + } else { + _debug(__LINE__, "Failed to open debug file $sensors_debug_output: $!"); + $sensorsOutput = '{}'; + } + } else { + # Production mode: call sensors command + $sensorsOutput = `sensors -j 2>/dev/null | python3 -m json.tool`; + _debug(__LINE__, "Raw sensors output collected from command"); + } _debug(__LINE__, "Raw sensors output collected"); From d751c58f3aff081d49b2e6faa44167920647fae1 Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 14:25:18 +0100 Subject: [PATCH 29/48] refactor check_executable --- pve-mod.pm | 60 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/pve-mod.pm b/pve-mod.pm index 957dc2d..c2f3553 100644 --- a/pve-mod.pm +++ b/pve-mod.pm @@ -156,7 +156,8 @@ sub _parse_intel_gpu_line { sub _get_intel_gpu_devices { my @devices = (); - return @devices unless -x '/usr/bin/intel_gpu_top'; + # Check if intel_gpu_top is available (debug mode doesn't apply to device listing) + return @devices unless _check_executable('/usr/bin/intel_gpu_top', 'Intel GPU'); _debug(__LINE__, "Getting Intel GPU devices"); if (open my $fh, '-|', 'intel_gpu_top -L') { @@ -318,6 +319,11 @@ sub get_nvidia_gpu_devices { # 0, NVIDIA GeForce RTX 3080 # 1, NVIDIA RTX A4000 + # Check if nvidia-smi is available (or debug mode with debug file) + unless (_check_executable('/usr/bin/nvidia-smi', 'NVIDIA', $nvidia_debug_mode, $nvidia_debug_devices)) { + return @devices; + } + if ($nvidia_debug_mode && -f $nvidia_debug_devices) { _debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $nvidia_debug_devices"); if (open my $fh, '<', $nvidia_debug_devices) { @@ -367,8 +373,6 @@ sub get_nvidia_gpu_devices { } } close $fh; - } else { - _debug(__LINE__, "Failed to run nvidia-smi: $!"); } } @@ -450,7 +454,13 @@ sub _get_and_write_nvidia_stats { _debug(__LINE__, "Failed to open debug file $nvidia_debug_output: $!"); } } else { - # Production mode: query all GPUs at once + # Production mode: check if nvidia-smi is available before querying + unless (_check_executable('/usr/bin/nvidia-smi', 'NVIDIA')) { + _debug(__LINE__, "nvidia-smi not available, cannot collect stats"); + return 0; + } + + # Query all GPUs at once my $query = 'index,name,temperature.gpu,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw,power.limit,fan.speed'; my $cmd = "nvidia-smi --query-gpu=$query --format=csv,nounits"; @@ -468,8 +478,6 @@ sub _get_and_write_nvidia_stats { push @all_stats, $stats if $stats; } close $fh; - } else { - _debug(__LINE__, "Failed to run nvidia-smi: $!"); } } @@ -599,9 +607,9 @@ sub _collector_for_temperature_sensors { _debug(__LINE__, "Temperature sensor collector started"); - # return if lm-sensors is not installed - unless (-x '/usr/bin/sensors') { - _debug(__LINE__, "sensors not available, exiting"); + # Check if lm-sensors is available (or debug mode with debug file) + unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $sensors_debug_mode, $sensors_debug_output)) { + _debug(__LINE__, "sensors not available and not in debug mode, exiting"); exit(1); } @@ -1289,14 +1297,32 @@ sub _ensure_pve_mod_directory_exists { } # Generic function to check if required executable exists +# Returns 1 if executable exists or debug mode is enabled for that type +# Returns 0 if executable doesn't exist and debug mode is not enabled sub _check_executable { - my ($exec_path, $type) = @_; + my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_; + + # If debug mode is enabled for this type, check if debug file exists instead + if (defined $debug_mode_enabled && $debug_mode_enabled) { + if (defined $debug_file && -f $debug_file) { + _debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file"); + return 1; + } elsif (defined $debug_file) { + _debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file"); + return 0; + } else { + _debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path"); + return 1; + } + } + # Normal mode: check if executable exists unless (-x $exec_path) { - _debug(__LINE__, "$exec_path not executable for $type"); + _debug(__LINE__, "$type executable not found or not executable: $exec_path"); return 0; } - _debug(__LINE__, "$exec_path is executable"); + + _debug(__LINE__, "$type executable found: $exec_path"); return 1; } @@ -1581,8 +1607,6 @@ sub _start_graphics_collectors { if ($nvidia_gpu_enabled) { _debug(__LINE__, "NVIDIA GPU support enabled"); - # return unless _check_executable('/usr/bin/nvidia-smi', 'NVIDIA'); - my @nvidia_devices = get_nvidia_gpu_devices(); _debug(__LINE__, "Got " . scalar(@nvidia_devices) . " NVIDIA devices"); @@ -1608,9 +1632,9 @@ sub _start_graphics_collectors { sub _start_sensors_collector { _debug(__LINE__, "Starting temperature sensor collector"); - # Check if sensors is available - unless (-x '/usr/bin/sensors') { - _debug(__LINE__, "sensors not available, skipping"); + # Check if sensors is available (or debug mode with debug file) + unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $sensors_debug_mode, $sensors_debug_output)) { + _debug(__LINE__, "sensors not available and not in debug mode, skipping"); return; } @@ -1628,7 +1652,7 @@ sub _start_ups_collector { _debug(__LINE__, "Starting UPS collector"); # Check if upsc is available - unless (-x '/usr/bin/upsc') { + unless (_check_executable('/usr/bin/upsc', 'UPS')) { _debug(__LINE__, "upsc not available, skipping UPS collector startup"); return; } From 4e7396d7199390873338491774c3692d5b2f3cfd Mon Sep 17 00:00:00 2001 From: Meliox Date: Sun, 11 Jan 2026 14:59:30 +0100 Subject: [PATCH 30/48] refactor code --- pve-mod.pm | 724 +++++++++++++++++++++++++++++------------------------ 1 file changed, 397 insertions(+), 327 deletions(-) diff --git a/pve-mod.pm b/pve-mod.pm index c2f3553..7cfdb44 100644 --- a/pve-mod.pm +++ b/pve-mod.pm @@ -4,7 +4,6 @@ use strict; use warnings; use JSON; use POSIX qw(WNOHANG); -use Fcntl qw(:flock); use Time::HiRes qw(time); use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); use File::Path qw(remove_tree); @@ -13,6 +12,63 @@ use File::Path qw(remove_tree); my $debug_ENABLED = 1; my $VERSION = '1.0.0'; +# ============================================================================ +# Configuration +# ============================================================================ +my %config = ( + gpu => { + intel_enabled => 1, + amd_enabled => 0, + nvidia_enabled => 1, + }, + debug => { + nvidia_mode => 1, + nvidia_devices_file => '/tmp/nvidia-smi-devices.csv', + nvidia_output_file => '/tmp/nvidia-smi-output.csv', + sensors_mode => 0, + sensors_output_file => '/tmp/sensors-output.json', + }, + intervals => { + data_pull => 1, # seconds between data pulls + collector_timeout => 10, # stop collectors after N seconds of inactivity + }, + ups => { + enabled => 1, + device_name => 'ups@192.168.3.2', + }, + paths => { + working_dir => '/run/pveproxy/pve-mod', + }, +); + +# ============================================================================ +# Derived paths and runtime state +# ============================================================================ + +# Derived paths from configuration +my $pve_mod_working_dir = $config{paths}{working_dir}; +my $stats_dir = $pve_mod_working_dir; +my $state_file = "$pve_mod_working_dir/stats.json"; +my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; +my $ups_state_file = "$pve_mod_working_dir/ups.json"; +my $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; +my $startup_lock = "$pve_mod_working_dir/startup.lock"; + +# Runtime state variables +my $process_type = 'main'; # 'main', 'worker', or 'collector' +my $last_snapshot = {}; +my $last_mtime = 0; +my $last_get_graphic_stats_time = 0; +my $pve_mod_worker_pid; +my $pve_mod_worker_running = 0; + +# Collector registry - only populated in worker process +my %collectors = (); # key: device/card name, value: PID + +# ============================================================================ +# Shared Utility Functions +# ============================================================================ + # debug function showing line number and call chain # Usage: _debug(__LINE__, "message") sub _debug { @@ -38,44 +94,266 @@ sub _debug { } } -my $pve_mod_working_dir = '/run/pveproxy/pve-mod'; -my $stats_dir = $pve_mod_working_dir; -my $state_file = "$pve_mod_working_dir/stats.json"; -my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; -my $ups_state_file = "$pve_mod_working_dir/ups.json"; +sub read_sysfs { + my ($path) = @_; + + return "unknown" unless defined $path && -f $path; + + if (open my $fh, '<', $path) { + my $value = <$fh>; + close $fh; + + if (defined $value) { + chomp $value; + # Remove leading/trailing whitespace + $value =~ s/^\s+|\s+$//g; + return $value ne '' ? $value : "unknown"; + } + } + + return "unknown"; +} -my $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; # PVE Mod worker lock -my $startup_lock = "$pve_mod_working_dir/startup.lock"; # Exclusive startup lock -my $last_snapshot = {}; -my $last_mtime = 0; +sub _is_process_alive { + my ($pid) = @_; + return -d "/proc/$pid"; +} -# Collector registry - only populated in worker process -my %collectors = (); # key: device/card name, value: PID -my $last_get_graphic_stats_time = 0; # Track when get_graphic_stats was last called -my $COLLECTOR_TIMEOUT = 10; # Stop collectors x seconds after last get_graphic_stats call -my $data_pull_interval = 1; # Interval in seconds between data pulls - -my $intel_gpu_enabled = 1; # Set to 0 to disable Intel GPU support -my $amd_gpu_enabled = 0; # Set to 1 to enable AMD GPU support (not yet implemented) -my $nvidia_gpu_enabled = 1; # Set to 1 to enable NVIDIA GPU support (not yet implemented) -my $nvidia_debug_mode = 1; # Set to 1 to enable NVIDIA debug mode (load from files instead of nvidia-smi) -my $nvidia_debug_devices = '/tmp/nvidia-smi-devices.csv'; -my $nvidia_debug_output = '/tmp/nvidia-smi-output.csv'; -my $sensors_debug_mode = 0; # Set to 1 to enable sensors debug mode (load from file instead of sensors command) -my $sensors_debug_output = '/tmp/sensors-output.json'; -my $ups_enabled = 1; # Set to 1 to enable UPS support -my $pve_mod_worker_pid; -my $pve_mod_worker_running = 0; +sub _read_lock_pid { + my ($lock_path) = @_; + + return undef unless open(my $fh, '<', $lock_path); + + my $pid = <$fh>; + close($fh); + chomp $pid if defined $pid; + + return $pid; +} -# UPS Configuration -my $ups_device = { - ups_name => 'ups@192.168.3.2', # Format: upsname[@hostname[:port]] -}; +sub _acquire_exclusive_lock { + my ($lock_path, $purpose) = @_; + $purpose //= 'lock'; + + my $fh; -# ============================================================================ -# Code starts here -# ============================================================================ -my $process_type = 'main'; # 'main', 'worker', or 'collector' + # Try to create lock file exclusively + if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + _debug(__LINE__, "Acquired $purpose on first try"); + return $fh; + } + + # Lock file creation failed - check if it's stale or held by another process + _debug(__LINE__, ucfirst($purpose) . " exists, checking if stale"); + + my $lock_pid = _read_lock_pid($lock_path); + + if (!defined $lock_pid) { + _debug(__LINE__, "Could not read $purpose file: $!"); + return undef; + } + + if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) { + _debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing"); + unlink($lock_path); + } elsif (_is_process_alive($lock_pid)) { + _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive"); + return undef; + } else { + _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock"); + unlink($lock_path); + } + + # Try to acquire lock again after cleanup + unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { + _debug(__LINE__, "Failed to acquire $purpose on retry: $!"); + return undef; + } + + _debug(__LINE__, "Acquired $purpose after removing stale lock"); + return $fh; +} + +sub _is_lock_stale { + my ($lock_path) = @_; + + return 0 unless open(my $fh, '<', $lock_path); + + my $lock_pid = <$fh>; + chomp $lock_pid if defined $lock_pid; + close($fh); + + # Invalid or missing PID + return 1 unless defined $lock_pid && $lock_pid =~ /^\d+$/; + + # Valid PID but process is dead + return !_is_process_alive($lock_pid); +} + +sub _ensure_pve_mod_directory_exists { + unless (-d $pve_mod_working_dir) { + _debug(__LINE__, "Creating directory $pve_mod_working_dir"); + unless (mkdir($pve_mod_working_dir, 0755)) { + _debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start."); + die "Failed to create $pve_mod_working_dir: $!"; + } + _debug(__LINE__, "Directory $pve_mod_working_dir created"); + } else { + _debug(__LINE__, "Directory $pve_mod_working_dir already exists"); + } +} + +# Generic function to check if required executable exists +# Returns 1 if executable exists or debug mode is enabled for that type +# Returns 0 if executable doesn't exist and debug mode is not enabled +sub _check_executable { + my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_; + + # If debug mode is enabled for this type, check if debug file exists instead + if (defined $debug_mode_enabled && $debug_mode_enabled) { + if (defined $debug_file && -f $debug_file) { + _debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file"); + return 1; + } elsif (defined $debug_file) { + _debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file"); + return 0; + } else { + _debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path"); + return 1; + } + } + + # Normal mode: check if executable exists + unless (-x $exec_path) { + _debug(__LINE__, "$type executable not found or not executable: $exec_path"); + return 0; + } + + _debug(__LINE__, "$type executable found: $exec_path"); + return 1; +} + +sub _pve_mod_hello { + _debug(__LINE__, "PVE Mod is being started. Version $VERSION"); +} + +# Setup common signal handlers for collector processes +sub _setup_collector_signals { + my ($name, $shutdown_ref, $extra_cleanup) = @_; + + $SIG{TERM} = sub { + _debug(__LINE__, "Collector $name received SIGTERM"); + $$shutdown_ref = 1; + $extra_cleanup->() if $extra_cleanup; + }; + $SIG{INT} = sub { + _debug(__LINE__, "Collector $name received SIGINT"); + $$shutdown_ref = 1; + $extra_cleanup->() if $extra_cleanup; + }; +} + +# Safe JSON file write with error handling +sub _safe_write_json { + my ($filepath, $data, $pretty) = @_; + $pretty //= 1; + + eval { + open my $fh, '>', $filepath or die "Failed to open $filepath: $!"; + my $json = $pretty ? JSON->new->pretty->encode($data) : encode_json($data); + print $fh $json; + close $fh; + _debug(__LINE__, "Wrote JSON to $filepath"); + }; + if ($@) { + _debug(__LINE__, "Error writing to $filepath: $@"); + return 0; + } + return 1; +} + +# Safe JSON file read with error handling +sub _safe_read_json { + my ($filepath, $as_string) = @_; + + return unless -f $filepath; + + my $result; + eval { + open my $fh, '<', $filepath or die "Failed to open $filepath: $!"; + local $/; + my $json = <$fh>; + close $fh; + + if ($as_string) { + $result = $json; + } else { + $result = decode_json($json); + } + _debug(__LINE__, "Read JSON from $filepath"); + }; + if ($@) { + _debug(__LINE__, "Error reading $filepath: $@"); + return; + } + return $result; +} + +# Parse CSV line with trimming +sub _parse_csv_line { + my ($line, $expected_fields) = @_; + + return unless $line; + $line =~ s/^\s+|\s+$//g; + + my @values = map { s/^\s+|\s+$//gr } split(/,/, $line); + + return unless !$expected_fields || @values >= $expected_fields; + return @values; +} + +# Enhance sensors data with cached lookups (unified for drives and CPUs) +sub _enhance_sensors_with_cache { + my ($sensors_output, $cache_ref, $pattern, $lookup_sub, $field_names) = @_; + + $cache_ref //= {}; + + my $sensors_data = _safe_read_json(\$sensors_output); + return $sensors_output unless $sensors_data; + + # For string input, parse it + unless (ref $sensors_data eq 'HASH') { + eval { $sensors_data = decode_json($sensors_output); }; + return $sensors_output if $@; + } + + my @entries = grep { /$pattern/ } keys %{$sensors_data}; + _debug(__LINE__, "Found " . scalar(@entries) . " entries matching pattern"); + + foreach my $entry (@entries) { + my $metadata; + + # Check cache first + if (exists $cache_ref->{$entry}) { + $metadata = $cache_ref->{$entry}; + _debug(__LINE__, "Using cached info for $entry"); + } else { + # Lookup information + $metadata = $lookup_sub->($entry); + $cache_ref->{$entry} = $metadata if $metadata; + } + + # Add metadata to sensors data + if ($metadata && exists $sensors_data->{$entry}) { + foreach my $key (keys %$metadata) { + $sensors_data->{$entry}->{$key} = $metadata->{$key}; + } + _debug(__LINE__, "Enhanced $entry with metadata"); + } + } + + return JSON->new->pretty->canonical->encode($sensors_data); +} # ============================================================================ # Intel GPU Support @@ -201,20 +479,13 @@ sub _collector_for_intel_device { # Set up signal handlers for graceful shutdown my $shutdown = 0; - $SIG{TERM} = sub { - _debug(__LINE__, "Collector for $device->{card} received SIGTERM"); - $shutdown = 1; - kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; - }; - $SIG{INT} = sub { - _debug(__LINE__, "Collector for $device->{card} received SIGINT"); - $shutdown = 1; + _setup_collector_signals($device->{card}, \$shutdown, sub { kill 'TERM', $intel_gpu_top_pid if defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0; - }; + }); # Run intel_gpu_top once and keep reading from it _debug(__LINE__, "About to open pipe to intel_gpu_top"); - my $intel_pull_interval = $data_pull_interval * 1000; # in milliseconds + my $intel_pull_interval = $config{intervals}{data_pull} * 1000; # in milliseconds $intel_gpu_top_pid = open(my $fh, '-|', "intel_gpu_top -d $drm_dev -s $intel_pull_interval -l 2>&1"); unless (defined $intel_gpu_top_pid && $intel_gpu_top_pid > 0) { @@ -252,15 +523,7 @@ sub _collector_for_intel_device { }; # Write to device-specific file - eval { - open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; - print $ofh JSON->new->pretty->encode($device_data); - close $ofh; - _debug(__LINE__, "Wrote stats to $device_state_file (line #$line_count)"); - }; - if ($@) { - _debug(__LINE__, "Error writing stats: $@"); - } + _safe_write_json($device_state_file, $device_data); } } } @@ -320,13 +583,13 @@ sub get_nvidia_gpu_devices { # 1, NVIDIA RTX A4000 # Check if nvidia-smi is available (or debug mode with debug file) - unless (_check_executable('/usr/bin/nvidia-smi', 'NVIDIA', $nvidia_debug_mode, $nvidia_debug_devices)) { + unless (_check_executable('/usr/bin/nvidia-smi', 'NVIDIA', $config{debug}{nvidia_mode}, $config{debug}{nvidia_devices_file})) { return @devices; } - if ($nvidia_debug_mode && -f $nvidia_debug_devices) { - _debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $nvidia_debug_devices"); - if (open my $fh, '<', $nvidia_debug_devices) { + if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_devices_file}) { + _debug(__LINE__, "Debug mode: reading NVIDIA GPU devices from $config{debug}{nvidia_devices_file}"); + if (open my $fh, '<', $config{debug}{nvidia_devices_file}) { my $line_num = 0; while (<$fh>) { chomp; @@ -335,20 +598,19 @@ sub get_nvidia_gpu_devices { # Skip header line and empty lines next if $line_num == 1 || /^\s*$/; - # Parse CSV: "0, NVIDIA GeForce RTX 3080" - if (/^\s*(\d+)\s*,\s*(.+?)\s*$/) { - my $index = $1; - my $name = $2; + # Parse CSV using shared helper + my @values = _parse_csv_line($_, 2); + if (@values) { push @devices, { - name => $name, - index => $index, + index => $values[0], + name => $values[1], }; - _debug(__LINE__, "Found NVIDIA GPU device (debug): $name -> (index: $index)"); + _debug(__LINE__, "Found NVIDIA GPU device (debug): $values[1] -> (index: $values[0])"); } } close $fh; } else { - _debug(__LINE__, "Failed to open debug file $nvidia_debug_devices: $!"); + _debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_devices_file}: $!"); } } else { # Use nvidia-smi to get device list @@ -361,15 +623,14 @@ sub get_nvidia_gpu_devices { # Skip header line and empty lines next if $line_num == 1 || /^\s*$/; - # Parse CSV: "0, NVIDIA GeForce RTX 3080" - if (/^\s*(\d+)\s*,\s*(.+?)\s*$/) { - my $index = $1; - my $name = $2; + # Parse CSV using shared helper + my @values = _parse_csv_line($_, 2); + if (@values) { push @devices, { - name => $name, - index => $index, + index => $values[0], + name => $values[1], }; - _debug(__LINE__, "Found NVIDIA GPU device: $name -> (index: $index)"); + _debug(__LINE__, "Found NVIDIA GPU device: $values[1] -> (index: $values[0])"); } } close $fh; @@ -386,17 +647,9 @@ sub parse_nvidia_gpu_line { # index, name, temperature.gpu, utilization.gpu, utilization.memory, memory.used, memory.total, power.draw, power.limit, fan.speed #0, NVIDIA GeForce RTX 3080, 62, 79, 44, 8260, 10240, 268.12, 320.00, 67 - # Remove leading/trailing whitespace - $line =~ s/^\s+|\s+$//g; - - # Skip empty lines - return unless $line; - - # Split by comma and trim whitespace from each field - my @values = map { s/^\s+|\s+$//gr } split(/,/, $line); - - # Expected: index(0), name(1), temp(2), util_gpu(3), util_mem(4), mem_used(5), mem_total(6), power_draw(7), power_limit(8), fan_speed(9) - return unless @values >= 10; + # Parse CSV using shared helper + my @values = _parse_csv_line($line, 10); + return unless @values; my $stats = { index => $values[0] + 0, @@ -433,10 +686,10 @@ sub _get_and_write_nvidia_stats { my ($devices) = @_; my @all_stats; - if ($nvidia_debug_mode && -f $nvidia_debug_output) { + if ($config{debug}{nvidia_mode} && -f $config{debug}{nvidia_output_file}) { # Debug mode: read all GPUs from single file - _debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $nvidia_debug_output"); - if (open my $fh, '<', $nvidia_debug_output) { + _debug(__LINE__, "Debug mode: reading NVIDIA GPU stats from $config{debug}{nvidia_output_file}"); + if (open my $fh, '<', $config{debug}{nvidia_output_file}) { my $line_num = 0; while (<$fh>) { chomp; @@ -451,7 +704,7 @@ sub _get_and_write_nvidia_stats { } close $fh; } else { - _debug(__LINE__, "Failed to open debug file $nvidia_debug_output: $!"); + _debug(__LINE__, "Failed to open debug file $config{debug}{nvidia_output_file}: $!"); } } else { # Production mode: check if nvidia-smi is available before querying @@ -514,15 +767,7 @@ sub _get_and_write_nvidia_stats { }; # Write to device-specific file - eval { - open my $ofh, '>', $device_state_file or die "Failed to open $device_state_file: $!"; - print $ofh JSON->new->pretty->encode($device_data); - close $ofh; - _debug(__LINE__, "Wrote NVIDIA GPU $device_index stats to $device_state_file"); - }; - if ($@) { - _debug(__LINE__, "Error writing NVIDIA stats for GPU $device_index: $@"); - } + _safe_write_json($device_state_file, $device_data); } unless (@all_stats) { @@ -542,14 +787,7 @@ sub _collector_for_nvidia_devices { # Set up signal handlers for graceful shutdown my $shutdown = 0; - $SIG{TERM} = sub { - _debug(__LINE__, "NVIDIA collector received SIGTERM"); - $shutdown = 1; - }; - $SIG{INT} = sub { - _debug(__LINE__, "NVIDIA collector received SIGINT"); - $shutdown = 1; - }; + _setup_collector_signals('nvidia-all', \$shutdown); # Expected CSV format (with header): # index, name, temperature.gpu, utilization.gpu, utilization.memory, memory.used, memory.total, power.draw, power.limit, fan.speed @@ -560,42 +798,13 @@ sub _collector_for_nvidia_devices { # Collect and write NVIDIA GPU stats _get_and_write_nvidia_stats($devices); - sleep $data_pull_interval unless $shutdown; + sleep $config{intervals}{data_pull} unless $shutdown; } _debug(__LINE__, "NVIDIA collector shutting down"); exit 0; } -# ============================================================================ -# Unified Child Process Management -# ============================================================================ - -sub _start_child_collector { - my ($collector_name, $collector_sub, $device) = @_; - - _debug(__LINE__, "Starting child collector: $collector_name"); - - my $pid = fork(); - unless (defined $pid) { - _debug(__LINE__, "fork failed for $collector_name: $!"); - return undef; - } - - if ($pid == 0) { - # Child process - $process_type = 'collector'; - _debug(__LINE__, "In child process for $collector_name"); - $0 = "collector-$collector_name"; - $collector_sub->($device); - exit(0); - } - - # Parent process (worker only) - _debug(__LINE__, "Forked child PID $pid for $collector_name"); - return $pid; -} - # ============================================================================ # Temperature Sensors # ============================================================================ @@ -608,7 +817,7 @@ sub _collector_for_temperature_sensors { _debug(__LINE__, "Temperature sensor collector started"); # Check if lm-sensors is available (or debug mode with debug file) - unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $sensors_debug_mode, $sensors_debug_output)) { + unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $config{debug}{sensors_mode}, $config{debug}{sensors_output_file})) { _debug(__LINE__, "sensors not available and not in debug mode, exiting"); exit(1); } @@ -618,19 +827,12 @@ sub _collector_for_temperature_sensors { # Set up signal handlers for graceful shutdown my $shutdown = 0; - $SIG{TERM} = sub { - _debug(__LINE__, "Temperature sensor collector received SIGTERM"); - $shutdown = 1; - }; - $SIG{INT} = sub { - _debug(__LINE__, "Temperature sensor collector received SIGINT"); - $shutdown = 1; - }; + _setup_collector_signals('temperature-sensors', \$shutdown); while (!$shutdown) { my $sensorsData = _get_temperature_sensors(\%cache_ref); - # Write to sensors state file + # Write to sensors state file (as string, not parsed JSON) eval { open my $ofh, '>', $sensors_state_file or die "Failed to open $sensors_state_file: $!"; print $ofh $sensorsData; @@ -641,7 +843,7 @@ sub _collector_for_temperature_sensors { _debug(__LINE__, "Error writing temperature sensor data: $@"); } - sleep $data_pull_interval unless $shutdown; + sleep $config{intervals}{data_pull} unless $shutdown; } _debug(__LINE__, "Temperature sensor collector shutting down"); @@ -654,16 +856,16 @@ sub _get_temperature_sensors { my $sensorsOutput; # Collect sensor data from lm-sensors - if ($sensors_debug_mode && -f $sensors_debug_output) { + if ($config{debug}{sensors_mode} && -f $config{debug}{sensors_output_file}) { # Debug mode: read from file - _debug(__LINE__, "Debug mode: reading sensors data from $sensors_debug_output"); - if (open my $fh, '<', $sensors_debug_output) { + _debug(__LINE__, "Debug mode: reading sensors data from $config{debug}{sensors_output_file}"); + if (open my $fh, '<', $config{debug}{sensors_output_file}) { local $/; $sensorsOutput = <$fh>; close $fh; _debug(__LINE__, "Read sensors data from debug file, length: " . length($sensorsOutput) . " bytes"); } else { - _debug(__LINE__, "Failed to open debug file $sensors_debug_output: $!"); + _debug(__LINE__, "Failed to open debug file $config{debug}{sensors_output_file}: $!"); $sensorsOutput = '{}'; } } else { @@ -1077,18 +1279,12 @@ sub _collector_for_ups { # Set up signal handlers for graceful shutdown my $shutdown = 0; - $SIG{TERM} = sub { - _debug(__LINE__, "UPS collector received SIGTERM"); - $shutdown = 1; - }; - $SIG{INT} = sub { - _debug(__LINE__, "UPS collector received SIGINT"); - $shutdown = 1; - }; + _setup_collector_signals("ups-$device->{ups_name}", \$shutdown); + while (!$shutdown) { my $upsData = _get_ups_status($device->{ups_name}); - # Write to ups state file + # Write to ups state file (as string, not parsed JSON) eval { open my $ofh, '>', $ups_state_file or die "Failed to open $ups_state_file: $!"; print $ofh $upsData; @@ -1099,7 +1295,7 @@ sub _collector_for_ups { _debug(__LINE__, "Error writing ups data: $@"); } - sleep $data_pull_interval unless $shutdown; + sleep $config{intervals}{data_pull} unless $shutdown; } _debug(__LINE__, "UPS collector shutting down"); exit 0; @@ -1183,153 +1379,6 @@ sub _parse_upsc_output { return $ups_data; } -# ============================================================================ -# Supporting functions -# ============================================================================ - -sub read_sysfs { - my ($path) = @_; - - return "unknown" unless defined $path && -f $path; - - if (open my $fh, '<', $path) { - my $value = <$fh>; - close $fh; - - if (defined $value) { - chomp $value; - # Remove leading/trailing whitespace - $value =~ s/^\s+|\s+$//g; - return $value ne '' ? $value : "unknown"; - } - } - - return "unknown"; -} - -sub _is_process_alive { - my ($pid) = @_; - return -d "/proc/$pid"; -} - -sub _read_lock_pid { - my ($lock_path) = @_; - - return undef unless open(my $fh, '<', $lock_path); - - my $pid = <$fh>; - close($fh); - chomp $pid if defined $pid; - - return $pid; -} - -sub _acquire_exclusive_lock { - my ($lock_path, $purpose) = @_; - $purpose //= 'lock'; - - my $fh; - - # Try to create lock file exclusively - if (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - _debug(__LINE__, "Acquired $purpose on first try"); - return $fh; - } - - # Lock file creation failed - check if it's stale or held by another process - _debug(__LINE__, ucfirst($purpose) . " exists, checking if stale"); - - my $lock_pid = _read_lock_pid($lock_path); - - if (!defined $lock_pid) { - _debug(__LINE__, "Could not read $purpose file: $!"); - return undef; - } - - if ($lock_pid eq '' || $lock_pid !~ /^\d+$/) { - _debug(__LINE__, "Invalid PID in $purpose: '" . ($lock_pid // 'undefined') . "', removing"); - unlink($lock_path); - } elsif (_is_process_alive($lock_pid)) { - _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is still alive"); - return undef; - } else { - _debug(__LINE__, ucfirst($purpose) . " holder PID $lock_pid is dead, removing stale lock"); - unlink($lock_path); - } - - # Try to acquire lock again after cleanup - unless (sysopen($fh, $lock_path, O_CREAT|O_EXCL|O_WRONLY, 0644)) { - _debug(__LINE__, "Failed to acquire $purpose on retry: $!"); - return undef; - } - - _debug(__LINE__, "Acquired $purpose after removing stale lock"); - return $fh; -} - -sub _is_lock_stale { - my ($lock_path) = @_; - - return 0 unless open(my $fh, '<', $lock_path); - - my $lock_pid = <$fh>; - chomp $lock_pid if defined $lock_pid; - close($fh); - - # Invalid or missing PID - return 1 unless defined $lock_pid && $lock_pid =~ /^\d+$/; - - # Valid PID but process is dead - return !_is_process_alive($lock_pid); -} - -sub _ensure_pve_mod_directory_exists { - unless (-d $pve_mod_working_dir) { - _debug(__LINE__, "Creating directory $pve_mod_working_dir"); - unless (mkdir($pve_mod_working_dir, 0755)) { - _debug(__LINE__, "Failed to create $pve_mod_working_dir: $!. PVE Mod cannot start."); - die "Failed to create $pve_mod_working_dir: $!"; - } - _debug(__LINE__, "Directory $pve_mod_working_dir created"); - } else { - _debug(__LINE__, "Directory $pve_mod_working_dir already exists"); - } -} - -# Generic function to check if required executable exists -# Returns 1 if executable exists or debug mode is enabled for that type -# Returns 0 if executable doesn't exist and debug mode is not enabled -sub _check_executable { - my ($exec_path, $type, $debug_mode_enabled, $debug_file) = @_; - - # If debug mode is enabled for this type, check if debug file exists instead - if (defined $debug_mode_enabled && $debug_mode_enabled) { - if (defined $debug_file && -f $debug_file) { - _debug(__LINE__, "Debug mode enabled for $type, using debug file: $debug_file"); - return 1; - } elsif (defined $debug_file) { - _debug(__LINE__, "Debug mode enabled for $type but debug file missing: $debug_file"); - return 0; - } else { - _debug(__LINE__, "Debug mode enabled for $type, skipping executable check for $exec_path"); - return 1; - } - } - - # Normal mode: check if executable exists - unless (-x $exec_path) { - _debug(__LINE__, "$type executable not found or not executable: $exec_path"); - return 0; - } - - _debug(__LINE__, "$type executable found: $exec_path"); - return 1; -} - -sub _pve_mod_hello { - _debug(__LINE__, "PVE Mod is being started. Version $VERSION"); -} - # ============================================================================ # API calls # ============================================================================ @@ -1550,7 +1599,7 @@ sub _start_collector { sub _start_graphics_collectors { - if ($intel_gpu_enabled == 0 && $amd_gpu_enabled == 0 && $nvidia_gpu_enabled == 0) { + if (!$config{gpu}{intel_enabled} && !$config{gpu}{amd_enabled} && !$config{gpu}{nvidia_enabled}) { _debug(__LINE__, "No GPU types enabled, skipping collector startup"); return; } @@ -1564,7 +1613,7 @@ sub _start_graphics_collectors { my @all_collector_subs; # Intel - if ($intel_gpu_enabled) { + if ($config{gpu}{intel_enabled}) { _debug(__LINE__, "Intel GPU support enabled"); _debug(__LINE__, "Checking for intel_gpu_top"); @@ -1584,7 +1633,7 @@ sub _start_graphics_collectors { } # AMD (future) - if ($amd_gpu_enabled) { + if ($config{gpu}{amd_enabled}) { _debug(__LINE__, "AMD GPU support enabled"); return unless _check_executable('/usr/bin/rocm-smi', 'AMD'); @@ -1604,7 +1653,7 @@ sub _start_graphics_collectors { my $started_count = 0; # NVIDIA - single collector for all devices - if ($nvidia_gpu_enabled) { + if ($config{gpu}{nvidia_enabled}) { _debug(__LINE__, "NVIDIA GPU support enabled"); my @nvidia_devices = get_nvidia_gpu_devices(); @@ -1633,7 +1682,7 @@ sub _start_sensors_collector { _debug(__LINE__, "Starting temperature sensor collector"); # Check if sensors is available (or debug mode with debug file) - unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $sensors_debug_mode, $sensors_debug_output)) { + unless (_check_executable('/usr/bin/sensors', 'lm-sensors', $config{debug}{sensors_mode}, $config{debug}{sensors_output_file})) { _debug(__LINE__, "sensors not available and not in debug mode, skipping"); return; } @@ -1644,7 +1693,7 @@ sub _start_sensors_collector { sub _start_ups_collector { - if ($ups_enabled == 0) { + if (!$config{ups}{enabled}) { _debug(__LINE__, "UPS support not enabled, skipping collector startup"); return; } @@ -1658,19 +1707,44 @@ sub _start_ups_collector { } # Check if UPS is configured - unless (defined $ups_device && $ups_device->{ups_name}) { + unless ($config{ups}{device_name}) { _debug(__LINE__, "No UPS configured, skipping collector startup"); return; } # Use unified collector startup - _start_collector('ups', 'ups', \&_collector_for_ups, $ups_device); + _start_collector('ups', 'ups', \&_collector_for_ups, { ups_name => $config{ups}{device_name} }); } # ============================================================================ # PVE Mod Worker # ============================================================================ +sub _start_child_collector { + my ($collector_name, $collector_sub, $device) = @_; + + _debug(__LINE__, "Starting child collector: $collector_name"); + + my $pid = fork(); + unless (defined $pid) { + _debug(__LINE__, "fork failed for $collector_name: $!"); + return undef; + } + + if ($pid == 0) { + # Child process + $process_type = 'collector'; + _debug(__LINE__, "In child process for $collector_name"); + $0 = "collector-$collector_name"; + $collector_sub->($device); + exit(0); + } + + # Parent process (worker only) + _debug(__LINE__, "Forked child PID $pid for $collector_name"); + return $pid; +} + sub _pve_mod_starter { # Check if pve_mod_worker is already running - if so, entire system is already up _debug(__LINE__, "Checking if pve_mod_worker is already running"); @@ -1817,13 +1891,13 @@ sub _pve_mod_keep_alive { $SIG{TERM} = sub { _debug(__LINE__, "pve_mod_worker received SIGTERM, shutting down"); - _stop_collectors(); + _stop_child_collectors(); unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); }; $SIG{INT} = sub { _debug(__LINE__, "pve_mod_worker received SIGINT, shutting down"); - _stop_collectors(); + _stop_child_collectors(); unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); }; @@ -1835,18 +1909,18 @@ sub _pve_mod_keep_alive { _start_ups_collector(); _debug(__LINE__, "All collectors started by worker"); - _debug(__LINE__, "Entering pve_mod_worker loop, timeout=${COLLECTOR_TIMEOUT}s"); + _debug(__LINE__, "Entering pve_mod_worker loop, timeout=$config{intervals}{collector_timeout}s"); while (1) { _debug(__LINE__, "pve_mod_worker loop start: checking activity"); my $idle_time = time() - $last_activity; - _debug(__LINE__, "pve_mod_worker loop: idle_time=${idle_time}s, timeout=${COLLECTOR_TIMEOUT}s"); + _debug(__LINE__, "pve_mod_worker loop: idle_time=${idle_time}s, timeout=$config{intervals}{collector_timeout}s"); - if ($idle_time > $COLLECTOR_TIMEOUT) { + if ($idle_time > $config{intervals}{collector_timeout}) { _debug(__LINE__, "Timeout reached, stopping collectors"); - _stop_collectors(); + _stop_child_collectors(); _debug(__LINE__, "Collectors stopped, exiting pve_mod_worker"); unlink($pve_mod_worker_lock) if -f $pve_mod_worker_lock; exit(0); @@ -1862,11 +1936,7 @@ sub _is_pve_mod_worker_running { return -f $pve_mod_worker_lock; } -# ============================================================================ -# Other -# ============================================================================ - -sub _stop_collectors { +sub _stop_child_collectors { _debug(__LINE__, "Stopping all collectors"); # Get PIDs from worker's collector registry @@ -1926,7 +1996,7 @@ sub _stop_collectors { END { if ($process_type eq 'worker') { _debug(__LINE__, "PVE Mod Worker END block: cleaning up"); - _stop_collectors(); + _stop_child_collectors(); } elsif ($process_type eq 'collector') { _debug(__LINE__, "Collector ($0) END block: no cleanup needed"); # Collectors just exit, no cleanup needed From 555b7ebd2b7acb3f7546813f4680c184c8f7d5ae Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 13:26:21 +0200 Subject: [PATCH 31/48] rename mod name, fix installer for perl mod --- pve-mod.pm => PveSensorInfoMod.pm | 0 pve-mod-gui-sensors.sh | 49 +++++++------------------------ 2 files changed, 10 insertions(+), 39 deletions(-) rename pve-mod.pm => PveSensorInfoMod.pm (100%) diff --git a/pve-mod.pm b/PveSensorInfoMod.pm similarity index 100% rename from pve-mod.pm rename to PveSensorInfoMod.pm diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 0852290..5721682 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -117,6 +117,7 @@ function install_packages { fi } +# Main configuration function to detect sensors and set up parameters function configure { SENSORS_DETECTED=false local sensorsOutput @@ -127,7 +128,7 @@ function configure { install_packages - #### Collect lm-sensors output #### + #### Collect sensor data #### #region sensors collection if [ "$DEBUG_REMOTE" = true ]; then warn "Remote debugging is used. Sensor readings from dump file $DEBUG_JSON_FILE will be used." @@ -532,49 +533,19 @@ insert_node_info() { fi } -# Collect lm-sensors data +# Collect sensor data collect_sensors_output() { local output_file="$1" - local sensorsCmd - if [[ $DEBUG_REMOTE == true ]]; then - sensorsCmd="cat \"$DEBUG_JSON_FILE\"" - else - # Note: sensors -f (Fahrenheit) breaks fan speeds - sensorsCmd="sensors -j 2>/dev/null" - fi - - # Remember to reflect this in sanitize_sensors_output() - #region sensors heredoc + #region PveSensorInfoMod heredoc sed -i '/my \$dinfo = df('\''\/'\'', 1);/i\ - # Collect sensor data from lm-sensors\ - $res->{sensorsOutput} = `'"$sensorsCmd"'`;\ - \ - # Sanitize JSON output to handle common lm-sensors parsing issues\ - # Replace ERROR lines with placeholder values\ - $res->{sensorsOutput} =~ s/ERROR:.+\\s(\\w+):\\s(.+)/\\"$1\\": 0.000,/g;\ - $res->{sensorsOutput} =~ s/ERROR:.+\\s(\\w+)!/\\"$1\\": 0.000,/g;\ - \ - # Remove trailing commas before closing braces\ - $res->{sensorsOutput} =~ s/,\\s*(\})/$1/g;\ - \ - # Replace NaN values with null for valid JSON\ - $res->{sensorsOutput} =~ s/\\bNaN\\b/null/g;\ - \ - # Fix duplicate SODIMM keys by appending temperature sensor number with a space - handle both pretty and one-line JSON\ - # Example: "SODIMM":{"temp3_input":34.0} becomes "SODIMM 3":{"temp3_input":34.0}\ - $res->{sensorsOutput} =~ s/"SODIMM"\\s*:\\s*\\{\\s*"temp(\\d+)_input"/"SODIMM $1": {\\n "temp$1_input"/g;\ - \ - # Fix duplicate fans keys by appending fan number with a space - handle both pretty and one-line JSON\ - # Example: "Processor Fan":{"fan2_input":1000,...} → "Processor Fan 2":{"fan2_input":1000,...}\ - $res->{sensorsOutput} =~ s/"([^"]+)"\\s*:\\s*\{\\s*"fan(\\d+)_input"/"$1 $2": {\\n "fan$2_input"/g;\ - \ - # Format JSON output properly (workaround for lm-sensors >3.6.0 issues)\ - $res->{sensorsOutput} =~ /^(.*)$/s;\ - $res->{sensorsOutput} = `echo \\Q$1\\E | python3 -m json.tool 2>/dev/null || echo \\Q$1\E`;\ + # Collect sensor data from PveSensorInfoMod\ + # Bad practice to add use here, but cleaner implementation would require several extensive modifications.\ + use PVE::API2::GPUMonitor;\ + $res->{sensorsJSONOutput} = PVE::API2::GPUMonitor::get_sensors_stats();\ ' "$NODES_PM_FILE" - #endregion sensors heredoc - info "Sensors' retriever added to \"$output_file\"." + #endregion PveSensorInfoMod heredoc + info "Sensor data retriever added to \"$output_file\"." } # Collect UPS data From e84dd9f8266abc2596d3fd3ecb363078d740545e Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 13:30:00 +0200 Subject: [PATCH 32/48] Remove unneeded modification from node install routine --- pve-mod-gui-sensors.sh | 50 +----------------------------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 5721682..c7ad4e1 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -520,23 +520,6 @@ sanitize_sensors_output() { #region node info insertion # Main insertion routine insert_node_info() { - local output_file="$NODES_PM_FILE" - - collect_sensors_output "$output_file" - - if [[ $ENABLE_UPS == true ]]; then - collect_ups_output "$output_file" - fi - - if [[ $ENABLE_SYSTEM_INFO == true ]]; then - collect_system_info "$output_file" - fi -} - -# Collect sensor data -collect_sensors_output() { - local output_file="$1" - #region PveSensorInfoMod heredoc sed -i '/my \$dinfo = df('\''\/'\'', 1);/i\ # Collect sensor data from PveSensorInfoMod\ @@ -545,38 +528,7 @@ collect_sensors_output() { $res->{sensorsJSONOutput} = PVE::API2::GPUMonitor::get_sensors_stats();\ ' "$NODES_PM_FILE" #endregion PveSensorInfoMod heredoc - info "Sensor data retriever added to \"$output_file\"." -} - -# Collect UPS data -collect_ups_output() { - local output_file="$1" - local ups_cmd - - if [[ $DEBUG_REMOTE == true ]]; then - ups_cmd="cat \"$DEBUG_UPS_FILE\"" - else - ups_cmd="upsc \"$upsConnection\" 2>/dev/null" - fi - - # region ups heredoc - sed -i "/my \$dinfo = df('\/', 1);/i\\ - # Collect UPS status information\\ - sub get_upsc {\\ - my \$cmd = '$ups_cmd';\\ - my \$output = \`\\\$cmd\`;\\ - return \$output;\\ - }\\ - \$res->{upsc} = get_upsc();\\ -" "$NODES_PM_FILE" - # endregion ups heredoc - - info "UPS retriever added to \"$output_file\"." -} - -collect_graphics_intel_output() { - - + info "Sensor data retriever added to \"$NODES_PM_FILE\"." } # Collect system information From c64087fbd259e6f6a25b14be0cfee091da57931b Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 13:56:38 +0200 Subject: [PATCH 33/48] Add mod configuration to the installer --- pve-mod-gui-sensors.sh | 86 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index c7ad4e1..d0ece2a 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -39,6 +39,7 @@ JSON_EXPORT_FILENAME="sensorsdata.json" # File paths PVE_MANAGER_LIB_JS_FILE="/usr/share/pve-manager/js/pvemanagerlib.js" NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm" +PVE_SENSOR_INFO_MOD_FILE="/usr/share/perl5/PVE/API2/PveSensorInfoMod.pm" #region message tools # Section header (bold) @@ -440,8 +441,9 @@ function install_mod { perform_backup #### Insert information retrieval code #### - msgb "\n=== Inserting information retrieval code ===" - insert_node_info + msgb "\n=== Installing sensor info module ===" + install_sensor_monitor_module + insert_sensor_monitor_into_pve #### Temperature helper parameters #### msgb "\n=== Creating temperature conversion helper ===" @@ -517,9 +519,54 @@ sanitize_sensors_output() { ' | python3 -m json.tool 2>/dev/null || echo "$input" } +#region Sensor Monitor Module Installation +# Install and configure the Sensor Monitor Perl module +install_sensor_monitor_module() { + # Check if source file exists + if [[ ! -f "$GPU_MONITOR_SOURCE_FILE" ]]; then + err "Source file not found: $GPU_MONITOR_SOURCE_FILE" + fi + + # Copy the module file + # todo - how to install module??!?! + #cp "$GPU_MONITOR_SOURCE_FILE" "$PVE_SENSOR_INFO_MOD_FILE" || err "Failed to copy $GPU_MONITOR_SOURCE_FILE to $PVE_SENSOR_INFO_MOD_FILE" + info "Copied GPU Monitor module to $PVE_SENSOR_INFO_MOD_FILE" + + # Convert boolean flags to Perl format (1 or 0) + local intel_enabled=$([[ "$ENABLE_INTEL_GPU_INFO" = true ]] && echo 1 || echo 0) + local nvidia_enabled=$([[ "$ENABLE_NVIDIA_GPU_INFO" = true ]] && echo 1 || echo 0) + local ups_enabled=$([[ "$ENABLE_UPS" = true ]] && echo 1 || echo 0) + local sensors_mode=$([[ "$DEBUG_REMOTE" = true ]] && echo 1 || echo 0) + + # Determine UPS device name + local ups_device="${upsConnection:-ups@localhost}" + + # Update configuration in the installed module + sed -i " + # Update GPU configuration + /intel_enabled =>/ s/=> [01],/=> $intel_enabled,/ + /nvidia_enabled =>/ s/=> [01],/=> $nvidia_enabled,/ + + # Update UPS configuration + /enabled =>/ { + /ups => {/,/},/ { + /enabled =>/ s/=> [01],/=> $ups_enabled,/ + } + } + /device_name =>/ s|=> '[^']*',|=> '$ups_device',| + " "$PVE_SENSOR_INFO_MOD_FILE" + + if [[ $? -eq 0 ]]; then + info "Sensor Monitor module configured successfully." + else + warn "Failed to configure Sensor Monitor module settings." + fi +} +#endregion Sensor Monitor Module Installation + #region node info insertion # Main insertion routine -insert_node_info() { +insert_sensor_monitor_into_pve() { #region PveSensorInfoMod heredoc sed -i '/my \$dinfo = df('\''\/'\'', 1);/i\ # Collect sensor data from PveSensorInfoMod\ @@ -529,6 +576,11 @@ insert_node_info() { ' "$NODES_PM_FILE" #endregion PveSensorInfoMod heredoc info "Sensor data retriever added to \"$NODES_PM_FILE\"." + + # Add system information if enabled + if [[ $ENABLE_SYSTEM_INFO == true ]]; then + collect_system_info "$NODES_PM_FILE" + fi } # Collect system information @@ -1656,7 +1708,24 @@ function uninstall_mod { warn "No pvemanagerlib.js backup files found." fi - if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ]; then + # Find the latest GPUMonitor.pm file using the find command + local latest_gpumonitor_pm=$(find "$BACKUP_DIR" -name "GPUMonitor.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') + + if [ -n "$latest_gpumonitor_pm" ]; then + # Restore the latest GPUMonitor.pm file + msgb "Restoring latest GPUMonitor.pm from backup: $latest_gpumonitor_pm to \"$PVE_SENSOR_INFO_MOD_FILE\"." + cp "$latest_gpumonitor_pm" "$PVE_SENSOR_INFO_MOD_FILE" + info "Restored GPUMonitor.pm successfully." + elif [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then + # No backup found but file exists, remove it + msgb "No GPUMonitor.pm backup found. Removing installed module: $PVE_SENSOR_INFO_MOD_FILE" + rm "$PVE_SENSOR_INFO_MOD_FILE" + info "Removed GPUMonitor.pm successfully." + else + warn "No GPUMonitor.pm backup files found and module not installed." + fi + + if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ] || [ -n "$latest_gpumonitor_pm" ] || [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then # At least one of the variables is not empty, restart the proxy restart_proxy fi @@ -1666,7 +1735,9 @@ function uninstall_mod { # Function to check if the modification is installed check_mod_installation() { - if [[ -n $(grep -F '$res->{sensorsOutput}' "$NODES_PM_FILE") ]] && \ + if [[ -n $(grep -F 'use PVE::API2::GPUMonitor' "$NODES_PM_FILE") ]] || \ + [[ -n $(grep -F '$res->{sensorsJSONOutput}' "$NODES_PM_FILE") ]] || \ + [[ -n $(grep -F '$res->{sensorsOutput}' "$NODES_PM_FILE") ]] || \ [[ -n $(grep -F '$res->{systemInfo}' "$NODES_PM_FILE") ]] && \ [[ -n $(grep -E "itemId: 'thermal[[:alnum:]]*'" "$PVE_MANAGER_LIB_JS_FILE") ]]; then err "Mod is already installed. Uninstall existing before installing." @@ -1778,6 +1849,11 @@ function perform_backup { create_backup_directory create_file_backup "$NODES_PM_FILE" "$timestamp" create_file_backup "$PVE_MANAGER_LIB_JS_FILE" "$timestamp" + + # Backup GPU Monitor module if it exists + if [[ -f "$PVE_SENSOR_INFO_MOD_FILE" ]]; then + create_file_backup "$PVE_SENSOR_INFO_MOD_FILE" "$timestamp" + fi } # Process the arguments using a while loop and a case statement From 60340f7753e7e9a1510abdf89759bec1bcea7b68 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 14:00:05 +0200 Subject: [PATCH 34/48] add nvidia gpu dection to installer --- pve-mod-gui-sensors.sh | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index d0ece2a..04ce4a7 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -242,7 +242,42 @@ function configure { #endregion intel GPU setup #region NVIDIA GPU setup - # not implemented yet + # Check for NVIDIA GPU - ensure nvidia-smi is installed + if command -v nvidia-smi &>/dev/null; then + # detect all NVIDIA cards using nvidia-smi -L + local nvidiaCards + + # Get the output from nvidia-smi -L (lists GPUs) + nvidiaCards=$(nvidia-smi -L 2>/dev/null || true) + + if [[ -n "$nvidiaCards" ]]; then + local cardCount=$(echo "$nvidiaCards" | wc -l) + echo "NVIDIA GPU(s) detected ($cardCount):" + echo "$nvidiaCards" | while IFS= read -r line; do + # Expected format: GPU 0: NVIDIA GeForce RTX 3080 (UUID: GPU-xxxxx) + if [[ $line =~ ^GPU\ ([0-9]+):\ (.+)\ \(UUID:\ (.+)\)$ ]]; then + local gpuIndex="${BASH_REMATCH[1]}" + local gpuModel="${BASH_REMATCH[2]}" + local gpuUUID="${BASH_REMATCH[3]}" + + echo " - GPU $gpuIndex" + echo " Model: $gpuModel" + echo " UUID: $gpuUUID" + else + # Fallback: just show the line as-is + echo " $line" + fi + done + ENABLE_NVIDIA_GPU_INFO=true + ENABLE_GPU_INFO=true + else + warn "No NVIDIA GPUs detected by nvidia-smi." + ENABLE_NVIDIA_GPU_INFO=false + fi + else + warn "nvidia-smi command not found. Skipping NVIDIA GPU information detection." + ENABLE_NVIDIA_GPU_INFO=false + fi #endregion NVIDIA GPU setup #region AMD GPU setup From 8ac29cab930f0b3b48f22eb61706e51820338125 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 14:13:24 +0200 Subject: [PATCH 35/48] renaming and change js code to only contain affected code block --- PveSensorInfoMod.pm => PveMod_SensorInfo.pm | 4 +- PveMod_pvemanagerlib.js | 958 + pve-mod-gui-sensors.sh | 4 +- pvemanagerlib.js | 68907 ------------------ 4 files changed, 963 insertions(+), 68910 deletions(-) rename PveSensorInfoMod.pm => PveMod_SensorInfo.pm (99%) create mode 100644 PveMod_pvemanagerlib.js delete mode 100644 pvemanagerlib.js diff --git a/PveSensorInfoMod.pm b/PveMod_SensorInfo.pm similarity index 99% rename from PveSensorInfoMod.pm rename to PveMod_SensorInfo.pm index 7cfdb44..6ff9e82 100644 --- a/PveSensorInfoMod.pm +++ b/PveMod_SensorInfo.pm @@ -9,7 +9,7 @@ use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); use File::Path qw(remove_tree); # debug configuration - set to 0 to disable all _debug output -my $debug_ENABLED = 1; +my $DEBUG_ENABLED = 1; my $VERSION = '1.0.0'; # ============================================================================ @@ -72,7 +72,7 @@ my %collectors = (); # key: device/card name, value: PID # debug function showing line number and call chain # Usage: _debug(__LINE__, "message") sub _debug { - return unless $debug_ENABLED; + return unless $DEBUG_ENABLED; my ($line, $message) = @_; diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js new file mode 100644 index 0000000..dd6c077 --- /dev/null +++ b/PveMod_pvemanagerlib.js @@ -0,0 +1,958 @@ +Ext.define('PVE.node.StatusView', { + extend: 'Proxmox.panel.StatusView', + alias: 'widget.pveNodeStatus', + + minHeight: 360, + flex: 1, + collapsible: true, + titleCollapse: true, + bodyPadding: '20 15 20 15', + + layout: { + type: 'table', + columns: 2, + trAttrs: { valign: 'top' }, + tableAttrs: { + style: { + width: '100%', + }, + }, + }, + + defaults: { + xtype: 'pmxInfoWidget', + padding: '0 10 2 10', + }, + + items: [ + // ========== PRIMARY KPIs (Tier 1) ========== + { + xtype: 'box', + colspan: 2, + padding: '0', + html: '
    Primary Metrics
    ', + }, + { + itemId: 'cpu', + iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', + title: gettext('CPU Usage'), + valueField: 'cpu', + maxField: 'cpuinfo', + renderer: function(value, record) { + let result = Proxmox.Utils.render_node_cpu_usage(value, record); + // Append CPU model if available + if (record && record.cpuinfo && record.cpuinfo.model) { + result += ` (${record.cpuinfo.model})`; + } + return result; + }, + }, + { + iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', + itemId: 'memory', + title: gettext('Memory Usage'), + valueField: 'memory', + maxField: 'memory', + warningThreshold: 0.9, + criticalThreshold: 0.975, + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + itemId: 'ksm', + iconCls: 'fa fa-fw fa-clone', + printBar: false, + title: gettext('KSM sharing'), + textField: 'ksm', + renderer: function (record) { + return Proxmox.Utils.render_size(record.shared); + }, + }, + { + itemId: 'gpu', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Usage'), + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + let hasActiveGPU = false; + let gpuName = ''; + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + hasActiveGPU = true; + gpuName = gpuData.name; + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + hasActiveGPU = true; + gpuName = stats.name; + } + } + + return hasActiveGPU ? gpuName : ''; + }, + }, + { + itemId: 'gpu_usage', + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU 0'), + valueField: 'gpuStats', + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + // Check Intel GPUs + if (gpuStats.Graphics.Intel) { + const keys = Object.keys(gpuStats.Graphics.Intel).sort(); + if (keys.length > 0) { + const gpuData = gpuStats.Graphics.Intel[keys[0]]; + if (gpuData.stats.engines && gpuData.stats.engines['Render/3D']) { + const usage = gpuData.stats.engines['Render/3D'].busy; + return `${usage}%`; + } + } + } + + // Check NVIDIA GPUs + if (gpuStats.Graphics.NVIDIA) { + const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); + if (keys.length > 0) { + const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; + if (stats.utilization) { + return `${stats.utilization.gpu}%`; + } + } + } + + return ''; + }, + }, + { + iconCls: 'fa fa-fw fa-hdd-o', + itemId: 'rootfs', + title: gettext('Disk (/) Usage'), + valueField: 'rootfs', + maxField: 'rootfs', + renderer: Proxmox.Utils.render_node_size_usage, + }, + { + iconCls: 'fa fa-fw fa-refresh', + itemId: 'swap', + title: gettext('SWAP Usage'), + valueField: 'swap', + maxField: 'swap', + warningThreshold: 0.4, + criticalThreshold: 0.8, + renderer: Proxmox.Utils.render_node_size_usage, + }, + // Fill the remaining cell so the next colspan:2 section header starts on a new row. + { + xtype: 'box', + html: '', + padding: 0, + }, + + // ========== SECONDARY DETAILS (Tier 2) ========== + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
    Secondary Details
    ', + }, + { + itemId: 'load', + iconCls: 'fa fa-fw fa-tasks', + title: gettext('CPU Load Average'), + printBar: false, + textField: 'loadavg', + }, + { + itemId: 'wait', + iconCls: 'fa fa-fw fa-clock-o', + title: gettext('CPU I/O Delay'), + valueField: 'wait', + }, + { + itemId: 'thermalCpu', + colspan: 2, + printBar: false, + title: gettext('CPU Thermal State'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'sensorsOutput', + renderer: function(value){ + // sensors configuration + const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort(); + const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort(); + const bINTEL = cpuKeysI.length > 0 ? true : false; + const INTELPackagePrefix = 'Core' == 'Core' ? 'Core ' : 'Package id'; + const INTELPackageCaption = 'Core' == 'Core' ? 'Core' : 'Package'; + let AMDPackagePrefix = 'Tccd'; + let AMDPackageCaption = 'CCD'; + + if (cpuKeysA.length > 0) { + let bTccd = false; + let bTctl = false; + let bTdie = false; + let bCpuCoreTemp = false; + cpuKeysA.forEach((cpuKey, cpuIndex) => { + let items = objValue[cpuKey]; + bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; + bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; + bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; + bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; + }); + if (bTccd && 'Core' == 'Core') { + AMDPackagePrefix = 'Tccd'; + AMDPackageCaption = 'ccd'; + } else if (bCpuCoreTemp && 'Core' == 'Package') { + AMDPackagePrefix = 'CPU Core Temp'; + AMDPackageCaption = 'CPU Core Temp'; + } else if (bTdie) { + AMDPackagePrefix = 'Tdie'; + AMDPackageCaption = 'die'; + } else if (bTctl) { + AMDPackagePrefix = 'Tctl'; + AMDPackageCaption = 'ctl'; + } else { + AMDPackagePrefix = 'temp'; + AMDPackageCaption = 'Temp'; + } + } + + const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; + const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; + const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; + const formatTemp = bINTEL ? '0' : '0.0'; + const cpuCount = cpuKeys.length; + let temps = []; + + cpuKeys.forEach((cpuKey, cpuIndex) => { + let cpuTemps = []; + const items = objValue[cpuKey]; + const cpuModel = items.cpu_model || ''; + + const itemKeys = Object.keys(items).filter(item => { + if ('Core' == 'Core') { + // In Core mode: only show individual cores/CCDs, exclude overall CPU temp + return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); + } else { + // In Package mode: show overall CPU temp and package-level readings + return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; + } + }).sort((a, b) => { + // Sort cores numerically + let numA = parseInt(a.match(/\d+/)?.[0] || '0', 10); + let numB = parseInt(b.match(/\d+/)?.[0] || '0', 10); + return numA - numB; + }); + + itemKeys.forEach((coreKey) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN; + Object.keys(items[coreKey]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); + } + }); + + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + + let tempStr = ''; + + // Enhanced parsing for AMD temperatures + if (coreKey.startsWith('Tccd')) { + let tempIndex = coreKey.match(/Tccd(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + // Handle CPU Core Temp (single overall temperature) + else if (coreKey === 'CPU Core Temp') { + tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) + else { + let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); + if (tempIndex !== null && tempIndex.length > 1) { + tempIndex = tempIndex[1]; + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } else { + // fallback for CPUs which do not have a core index + let coreType = coreKey.startsWith('P Core') ? 'P Core' : + coreKey.startsWith('E Core') ? 'E Core' : + cpuTempCaption; + tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; + } + } + + cpuTemps.push(tempStr); + } + } catch (e) { /*_*/ } + }); + + if(cpuTemps.length > 0) { + temps.push({ model: cpuModel, temps: cpuTemps }); + } + }); + + let html = ''; + temps.forEach((cpuData, cpuIndex) => { + const strCoreTemps = cpuData.temps.map((strTemp, index, arr) => { + return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); + }); + if(strCoreTemps.length > 0) { + let cpuLabel = cpuCount > 1 ? `Socket ${cpuIndex + 1}` : 'Socket 1'; + let cpuModelStr = cpuData.model || 'Unknown CPU'; + + html += ''; + html += ``; + html += ``; + html += ''; + } + }); + html += '
    ${cpuModelStr}${strCoreTemps.join('')}
    '; + + return html.indexOf('') > 0 + ? '
    ' + html + '
    ' + : 'N/A'; + } + }, + { + itemId: 'gpu_details', + colspan: 2, + iconCls: 'fa fa-fw fa-desktop', + title: gettext('GPU Details'), + printBar: false, + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics) { + return ''; + } + + let html = ''; + + // Intel GPUs - Secondary details + if (gpuStats.Graphics.Intel) { + Object.keys(gpuStats.Graphics.Intel).sort().forEach(key => { + const gpuData = gpuStats.Graphics.Intel[key]; + + let details = []; + + // All engine details + if (gpuData.stats.engines) { + if (gpuData.stats.engines['Render/3D']) { + details.push(`Render/3D: ${gpuData.stats.engines['Render/3D'].busy}%`); + } + if (gpuData.stats.engines['Video']) { + details.push(`Video: ${gpuData.stats.engines['Video'].busy}%`); + } + if (gpuData.stats.engines['Blitter']) { + details.push(`Blitter: ${gpuData.stats.engines['Blitter'].busy}%`); + } + if (gpuData.stats.engines['VideoEnhance']) { + details.push(`VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}%`); + } + } + + // Power + if (gpuData.stats.power) { + details.push(`Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`); + } + + // Frequency + if (gpuData.stats.frequency) { + details.push(`Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.stats.frequency?.unit || 'MHz'}`); + } + + html += ''; + html += ``; + html += ``; + html += ''; + }); + } + + // NVIDIA GPUs - Secondary details + if (gpuStats.Graphics.NVIDIA) { + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData.stats; + + let details = []; + + // Memory Utilization + if (stats.utilization && stats.utilization.memory) { + const memUsage = parseInt(stats.utilization.memory); + let memStyle = ''; + if (memUsage >= 90) memStyle = 'color: #d9534f; font-weight: bold;'; + else if (memUsage >= 70) memStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`MEM: ${stats.utilization.memory}%`); + } + + // VRAM Usage + if (stats.memory) { + const vramUsedGB = parseInt(stats.memory.used); + const vramTotalGB = parseInt(stats.memory.total); + const vramPercent = (vramUsedGB / vramTotalGB) * 100; + let vramStyle = ''; + if (vramPercent >= 90) vramStyle = 'color: #d9534f; font-weight: bold;'; + else if (vramPercent >= 70) vramStyle = 'color: #f0ad4e; font-weight: bold;'; + details.push(`VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit}`); + } + + // Temperature + if (stats.temperature) { + let tempStyle = ''; + if (stats.temperature.gpu >= 80) { + tempStyle = 'color: red; font-weight: bold;'; + } else if (stats.temperature.gpu >= 70) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + details.push(`Temp: ${stats.temperature.gpu}${stats.temperature.unit}`); + } + + // Power + if (stats.power) { + details.push(`Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`); + } + + html += ''; + html += ``; + html += ``; + html += ''; + }); + } + + html += '
    ${gpuData.name}${details.join(' | ')}
    ${stats.name}${details.join(' | ')}
    '; + return html.indexOf('') > 0 + ? '
    ' + html + '
    ' + : ''; + }, + }, + { + itemId: 'thermalNvme', + colspan: 2, + printBar: false, + title: gettext('NVMe Temperatures'), + iconCls: 'fa fa-fw fa-thermometer-half', + textField: 'sensorsOutput', + renderer: function(value) { + // sensors configuration + const addressPrefix = "nvme-pci-"; + const sensorName = "Composite"; + const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); + // display configuration + const itemsPerRow = 0; + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); + let nvmeData = []; + nvmeKeys.forEach((nvmeKey, index) => { + try { + let tempVal = NaN, tempMax = NaN, tempCrit = NaN, model = '', serial = ''; + Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { + if (secondLevelKey.endsWith('_input')) { + tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_max')) { + tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } else if (secondLevelKey.endsWith('_crit')) { + tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); + } + }); + model = objValue[nvmeKey]['model'] || 'Unknown'; + serial = objValue[nvmeKey]['serial'] || ''; + + if (!isNaN(tempVal)) { + let tempStyle = ''; + if (!isNaN(tempMax) && tempVal >= tempMax) { + tempStyle = 'color: #FFC300; font-weight: bold;'; + } + if (!isNaN(tempCrit) && tempVal >= tempCrit) { + tempStyle = 'color: red; font-weight: bold;'; + } + nvmeData.push({ + model: model, + serial: serial, + temp: tempVal, + tempStyle: tempStyle, + unit: tempHelper.getUnit() + }); + } + } catch(e) { /*_*/ } + }); + + if (nvmeData.length === 0) { + return 'N/A'; + } + + let html = ''; + nvmeData.forEach((data) => { + let deviceName = data.model; + if (data.serial) { + deviceName += ` (${data.serial})`; + } + html += ''; + html += ``; + html += ``; + html += ''; + }); + html += '
    ${deviceName}${Ext.util.Format.number(data.temp, '0.0')}${data.unit}
    '; + return '
    ' + html + '
    '; + } + }, + + // ========== TERTIARY DIAGNOSTICS (Tier 3) ========== + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
    Diagnostics
    ', + }, + { + itemId: 'speedFan', + colspan: 2, + printBar: false, + title: gettext('System Fans'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'sensorsOutput', + renderer: function(value) { + // --- + let objValue; + try { + objValue = JSON.parse(value) || {}; + objValue = objValue[Object.keys(objValue)[0]] || {}; + } catch(e) { + objValue = {}; + } + + // Recursive function to find fan keys and values + function findFanKeys(obj, fanKeys, parentKey = null) { + Object.keys(obj).forEach(key => { + const value = obj[key]; + if (typeof value === 'object' && value !== null) { + // If the value is an object, recursively call the function + findFanKeys(value, fanKeys, key); + } else if (/^fan[0-9]+(_input)?$/.test(key)) { + if (true != true && value === 0) { + // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 + return; + } + // If the key matches the pattern, add the parent key and value to the fanKeys array + fanKeys.push({ key: parentKey, value: value }); + } + }); + } + + let speeds = []; + // Loop through the parent keys + Object.keys(objValue).forEach(parentKey => { + const parentObj = objValue[parentKey]; + // Array to store fan keys and values + const fanKeys = []; + // Call the recursive function to find fan keys and values + findFanKeys(parentObj, fanKeys); + // Sort the fan keys + fanKeys.sort((a, b) => { + if (a.key < b.key) return -1; + if (a.key > b.key) return 1; + return 0; + }); + // Process each fan key and value + fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { + try { + const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey + speeds.push(`${fan}: ${fanSpeed} RPM`); + } catch(e) { + console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error + } + }); + }); + return '
    ' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
    '; + } + }, + { + itemId: 'gpuFans', + colspan: 2, + printBar: false, + title: gettext('GPU Fans'), + iconCls: 'fa fa-fw fa-snowflake-o', + textField: 'gpuStats', + renderer: function(gpuStats) { + if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.NVIDIA) { + return 'N/A'; + } + + let rows = []; + + Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { + const gpuData = gpuStats.Graphics.NVIDIA[key]; + const stats = gpuData?.stats; + const fan = stats?.fan; + + if (!fan || fan.speed === undefined || fan.speed === null) { + return; + } + + const gpuName = stats?.name || key; + const unit = fan.unit || '%'; + rows.push( + '' + + `${gpuName}` + + `Fan: ${fan.speed}${unit}` + + '', + ); + }); + + if (rows.length === 0) { + return 'N/A'; + } + + return '
    ' + rows.join('') + '
    '; + }, + }, + { + itemId: 'upsc', + colspan: 2, + printBar: false, + title: gettext('UPS Status'), + iconCls: 'fa fa-fw fa-battery-three-quarters', + textField: 'upsStats', + renderer: function(value) { + let objValue = {}; + try { + // Parse the UPS data + if (typeof value === 'string') { + objValue = JSON.parse(value) || {}; + } else if (typeof value === 'object') { + objValue = value || {}; + } + } catch(e) { + objValue = {}; + } + + // If objValue is null or empty, return N/A + if (!objValue || Object.keys(objValue).length === 0) { + return 'N/A'; + } + + // Helper function to get status color + function getStatusColor(status) { + if (!status) return '#999'; + const statusUpper = status.toUpperCase(); + if (statusUpper.includes('OL')) return null; + if (statusUpper.includes('OB')) return '#d9534f'; + if (statusUpper.includes('LB')) return '#d9534f'; + return '#f0ad4e'; + } + + // Helper function to get load/charge color + function getPercentageColor(value, isLoad = false) { + if (!value || isNaN(value)) return '#999'; + const num = parseFloat(value); + if (isLoad) { + if (num >= 80) return '#d9534f'; + if (num >= 60) return '#f0ad4e'; + return null; + } else { + if (num <= 20) return '#d9534f'; + if (num <= 50) return '#f0ad4e'; + return null; + } + } + + // Helper function to format runtime + function formatRuntime(seconds) { + if (!seconds || isNaN(seconds)) return 'N/A'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}m ${secs}s`; + } + + // Process each UPS in the data + let allDisplayItems = []; + + Object.keys(objValue).forEach(upsKey => { + const upsData = objValue[upsKey]; + + // Extract key UPS information + const batteryCharge = upsData['battery.charge']; + const batteryRuntime = upsData['battery.runtime']; + const inputVoltage = upsData['input.voltage']; + const upsLoad = upsData['ups.load']; + const upsStatus = upsData['ups.status']; + const upsModel = upsData['ups.model'] || upsData['device.model']; + const testResult = upsData['ups.test.result']; + const batteryChargeLow = upsData['battery.charge.low']; + const batteryRuntimeLow = upsData['battery.runtime.low']; + const upsRealPowerNominal = upsData['ups.realpower.nominal']; + const batteryMfrDate = upsData['battery.mfr.date']; + + // Main status line with all metrics + let statusLine = ''; + + // Status + if (upsStatus) { + const statusUpper = upsStatus.toUpperCase(); + let statusText = 'Unknown'; + let statusColor = '#f0ad4e'; + + if (statusUpper.includes('OL')) { + statusText = 'Online'; + statusColor = null; + } else if (statusUpper.includes('OB')) { + statusText = 'On Battery'; + statusColor = '#d9534f'; + } else if (statusUpper.includes('LB')) { + statusText = 'Low Battery'; + statusColor = '#d9534f'; + } else { + statusText = upsStatus; + statusColor = '#f0ad4e'; + } + + let statusStyle = statusColor ? ('color: ' + statusColor + ';') : ''; + statusLine += 'Status: ' + statusText + ''; + } else { + statusLine += 'Status: N/A'; + } + + // Battery charge + if (statusLine) statusLine += ' | '; + if (batteryCharge) { + const chargeColor = getPercentageColor(batteryCharge, false); + let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; + statusLine += 'Battery: ' + batteryCharge + '%'; + } else { + statusLine += 'Battery: N/A'; + } + + // Load percentage + if (statusLine) statusLine += ' | '; + if (upsLoad) { + const loadColor = getPercentageColor(upsLoad, true); + let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; + statusLine += 'Load: ' + upsLoad + '%'; + } else { + statusLine += 'Load: N/A'; + } + + // Runtime + if (statusLine) statusLine += ' | '; + if (batteryRuntime) { + const runtime = parseInt(batteryRuntime); + const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600; + let runtimeColor = null; + if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; + else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; + let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : ''; + statusLine += 'Runtime: ' + formatRuntime(runtime) + ''; + } else { + statusLine += 'Runtime: N/A'; + } + + // Input voltage + if (statusLine) statusLine += ' | '; + if (inputVoltage) { + statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; + } else { + statusLine += 'Input: N/A'; + } + + // Calculate actual watt usage + if (statusLine) statusLine += ' | '; + let actualWattage = null; + if (upsLoad && upsRealPowerNominal) { + const load = parseFloat(upsLoad); + const nominal = parseFloat(upsRealPowerNominal); + if (!isNaN(load) && !isNaN(nominal)) { + actualWattage = Math.round((load / 100) * nominal); + } + } + + // Real power (calculated watt usage) + if (actualWattage !== null) { + statusLine += 'Output: ' + actualWattage + 'W'; + } else { + statusLine += 'Output: N/A'; + } + + // Append battery MFD + last test to the same line (single-line UPS summary) + statusLine += ' | Battery MFD: ' + (batteryMfrDate || 'N/A'); + if (testResult && !testResult.toLowerCase().includes('no test')) { + const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; + let testStyle = testColor ? ('color: ' + testColor + ';') : ''; + statusLine += ' | Test: ' + testResult + ''; + } else { + statusLine += ' | Test: N/A'; + } + + // Build UPS display with model on left, details on right + let upsHtml = ''; + upsHtml += '' + (upsModel || upsKey) + ''; + upsHtml += '' + statusLine + ''; + upsHtml += ''; + + allDisplayItems.push(upsHtml); + }); + + // Format the final output for all UPS devices + return '
    ' + allDisplayItems.join('') + '
    '; + } + }, + { + xtype: 'box', + colspan: 2, + padding: '15 0 5 0', + html: '
    System
    ', + }, + { + colspan: 2, + title: gettext('Kernel Version'), + printBar: false, + // TODO: remove with next major and only use newish current-kernel textfield + multiField: true, + //textField: 'current-kernel', + renderer: ({ data }) => { + if (!data['current-kernel']) { + return data.kversion; + } + let kernel = data['current-kernel']; + let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown'; + return `${kernel.sysname} ${kernel.release} (${buildDate})`; + }, + value: '', + }, + { + colspan: 2, + title: gettext('Boot Mode'), + printBar: false, + textField: 'boot-info', + renderer: (boot) => { + if (boot.mode === 'legacy-bios') { + return 'Legacy BIOS'; + } else if (boot.mode === 'efi') { + return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`; + } + return Proxmox.Utils.unknownText; + }, + value: '', + }, + { + itemId: 'version', + colspan: 2, + printBar: false, + title: gettext('Manager Version'), + textField: 'pveversion', + value: '', + }, + { + itemId: 'pve_mod_version', + colspan: 2, + printBar: false, + title: gettext('Sensor Mod Version'), + textField: 'pveModVersion', + value: '', + }, + ], + + updateTitle: function () { + var me = this; + var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); + me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); + }, + + initComponent: function () { + let me = this; + + let stateProvider = Ext.state.Manager.getProvider(); + let repoLink = stateProvider.encodeHToken({ + view: 'server', + rid: `node/${me.pveSelNode.data.node}`, + ltab: 'tasks', + nodetab: 'aptrepositories', + }); + + me.items.push({ + xtype: 'pmxNodeInfoRepoStatus', + itemId: 'repositoryStatus', + product: 'Proxmox VE', + repoLink: `#${repoLink}`, + }); + + me.callParent(); + }, +}); +Ext.define('PVE.node.SubscriptionKeyEdit', { + extend: 'Proxmox.window.Edit', + + title: gettext('Upload Subscription Key'), + width: 350, + + items: { + xtype: 'textfield', + name: 'key', + value: '', + fieldLabel: gettext('Subscription Key'), + labelWidth: 120, + getSubmitValue: function () { + return this.processRawValue(this.getRawValue())?.trim(); + }, + }, + + initComponent: function () { + var me = this; + + me.callParent(); + + me.load(); + }, +}); \ No newline at end of file diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 04ce4a7..c28345e 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -39,7 +39,7 @@ JSON_EXPORT_FILENAME="sensorsdata.json" # File paths PVE_MANAGER_LIB_JS_FILE="/usr/share/pve-manager/js/pvemanagerlib.js" NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm" -PVE_SENSOR_INFO_MOD_FILE="/usr/share/perl5/PVE/API2/PveSensorInfoMod.pm" +PVE_SENSOR_INFO_MOD_FILE="/usr/share/perl5/PVE/API2/PveMod_SensorInfo.pm" #region message tools # Section header (bold) @@ -480,6 +480,8 @@ function install_mod { install_sensor_monitor_module insert_sensor_monitor_into_pve + exit + #### Temperature helper parameters #### msgb "\n=== Creating temperature conversion helper ===" HELPERCTORPARAMS=$([[ "$TEMP_UNIT" = "F" ]] && \ diff --git a/pvemanagerlib.js b/pvemanagerlib.js deleted file mode 100644 index 2efa46c..0000000 --- a/pvemanagerlib.js +++ /dev/null @@ -1,68907 +0,0 @@ -const pveOnlineHelpInfo = { - "ceph_rados_block_devices" : { - "link" : "/pve-docs/chapter-pvesm.html#ceph_rados_block_devices", - "title" : "Ceph RADOS Block Devices (RBD)" - }, - "chapter_ha_manager" : { - "link" : "/pve-docs/chapter-ha-manager.html#chapter_ha_manager", - "title" : "High Availability" - }, - "chapter_lvm" : { - "link" : "/pve-docs/chapter-sysadmin.html#chapter_lvm", - "title" : "Logical Volume Manager (LVM)" - }, - "chapter_notifications" : { - "link" : "/pve-docs/chapter-notifications.html#chapter_notifications", - "title" : "Notifications" - }, - "chapter_pct" : { - "link" : "/pve-docs/chapter-pct.html#chapter_pct", - "title" : "Proxmox Container Toolkit" - }, - "chapter_pve_firewall" : { - "link" : "/pve-docs/chapter-pve-firewall.html#chapter_pve_firewall", - "title" : "Proxmox VE Firewall" - }, - "chapter_pveceph" : { - "link" : "/pve-docs/chapter-pveceph.html#chapter_pveceph", - "title" : "Deploy Hyper-Converged Ceph Cluster" - }, - "chapter_pvecm" : { - "link" : "/pve-docs/chapter-pvecm.html#chapter_pvecm", - "title" : "Cluster Manager" - }, - "chapter_pvesdn" : { - "link" : "/pve-docs/chapter-pvesdn.html#chapter_pvesdn", - "title" : "Software-Defined Network" - }, - "chapter_pvesr" : { - "link" : "/pve-docs/chapter-pvesr.html#chapter_pvesr", - "title" : "Storage Replication" - }, - "chapter_storage" : { - "link" : "/pve-docs/chapter-pvesm.html#chapter_storage", - "title" : "Proxmox VE Storage" - }, - "chapter_system_administration" : { - "link" : "/pve-docs/chapter-sysadmin.html#chapter_system_administration", - "title" : "Host System Administration" - }, - "chapter_user_management" : { - "link" : "/pve-docs/chapter-pveum.html#chapter_user_management", - "title" : "User Management" - }, - "chapter_virtual_machines" : { - "link" : "/pve-docs/chapter-qm.html#chapter_virtual_machines", - "title" : "QEMU/KVM Virtual Machines" - }, - "chapter_vzdump" : { - "link" : "/pve-docs/chapter-vzdump.html#chapter_vzdump", - "title" : "Backup and Restore" - }, - "chapter_zfs" : { - "link" : "/pve-docs/chapter-sysadmin.html#chapter_zfs", - "title" : "ZFS on Linux" - }, - "datacenter_configuration_file" : { - "link" : "/pve-docs/pve-admin-guide.html#datacenter_configuration_file", - "title" : "Datacenter Configuration" - }, - "external_metric_server" : { - "link" : "/pve-docs/chapter-sysadmin.html#external_metric_server", - "title" : "External Metric Server" - }, - "getting_help" : { - "link" : "/pve-docs/chapter-pve-intro.html#getting_help", - "title" : "Getting Help" - }, - "gui_consent_banner" : { - "link" : "/pve-docs/chapter-pve-gui.html#gui_consent_banner", - "subtitle" : "Consent Banner", - "title" : "Graphical User Interface" - }, - "gui_my_settings" : { - "link" : "/pve-docs/chapter-pve-gui.html#gui_my_settings", - "subtitle" : "My Settings", - "title" : "Graphical User Interface" - }, - "gui_tags" : { - "link" : "/pve-docs/chapter-pve-gui.html#gui_tags", - "subtitle" : "Tags", - "title" : "Graphical User Interface" - }, - "ha_manager_crs" : { - "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_crs", - "subtitle" : "Cluster Resource Scheduling", - "title" : "High Availability" - }, - "ha_manager_fencing" : { - "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_fencing", - "subtitle" : "Fencing", - "title" : "High Availability" - }, - "ha_manager_resource_config" : { - "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resource_config", - "subtitle" : "Resources", - "title" : "High Availability" - }, - "ha_manager_resources" : { - "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resources", - "subtitle" : "Resources", - "title" : "High Availability" - }, - "ha_manager_rules" : { - "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_rules", - "subtitle" : "Rules", - "title" : "High Availability" - }, - "ha_manager_shutdown_policy" : { - "link" : "/pve-docs/chapter-ha-manager.html#ha_manager_shutdown_policy", - "subtitle" : "Shutdown Policy", - "title" : "High Availability" - }, - "markdown_basics" : { - "link" : "/pve-docs/pve-admin-guide.html#markdown_basics", - "title" : "Markdown Primer" - }, - "metric_server_graphite" : { - "link" : "/pve-docs/chapter-sysadmin.html#metric_server_graphite", - "subtitle" : "Graphite server configuration", - "title" : "External Metric Server" - }, - "metric_server_influxdb" : { - "link" : "/pve-docs/chapter-sysadmin.html#metric_server_influxdb", - "subtitle" : "Influxdb plugin configuration", - "title" : "External Metric Server" - }, - "notification_matchers" : { - "link" : "/pve-docs/chapter-notifications.html#notification_matchers", - "subtitle" : "Notification Matchers", - "title" : "Notifications" - }, - "notification_targets_gotify" : { - "link" : "/pve-docs/chapter-notifications.html#notification_targets_gotify", - "subtitle" : "Gotify", - "title" : "Notifications" - }, - "notification_targets_sendmail" : { - "link" : "/pve-docs/chapter-notifications.html#notification_targets_sendmail", - "subtitle" : "Sendmail", - "title" : "Notifications" - }, - "notification_targets_smtp" : { - "link" : "/pve-docs/chapter-notifications.html#notification_targets_smtp", - "subtitle" : "SMTP", - "title" : "Notifications" - }, - "notification_targets_webhook" : { - "link" : "/pve-docs/chapter-notifications.html#notification_targets_webhook", - "subtitle" : "Webhook", - "title" : "Notifications" - }, - "pct_configuration" : { - "link" : "/pve-docs/chapter-pct.html#pct_configuration", - "subtitle" : "Configuration", - "title" : "Proxmox Container Toolkit" - }, - "pct_container_images" : { - "link" : "/pve-docs/chapter-pct.html#pct_container_images", - "subtitle" : "Container Images", - "title" : "Proxmox Container Toolkit" - }, - "pct_container_network" : { - "link" : "/pve-docs/chapter-pct.html#pct_container_network", - "subtitle" : "Network", - "title" : "Proxmox Container Toolkit" - }, - "pct_container_storage" : { - "link" : "/pve-docs/chapter-pct.html#pct_container_storage", - "subtitle" : "Container Storage", - "title" : "Proxmox Container Toolkit" - }, - "pct_cpu" : { - "link" : "/pve-docs/chapter-pct.html#pct_cpu", - "subtitle" : "CPU", - "title" : "Proxmox Container Toolkit" - }, - "pct_general" : { - "link" : "/pve-docs/chapter-pct.html#pct_general", - "subtitle" : "General Settings", - "title" : "Proxmox Container Toolkit" - }, - "pct_memory" : { - "link" : "/pve-docs/chapter-pct.html#pct_memory", - "subtitle" : "Memory", - "title" : "Proxmox Container Toolkit" - }, - "pct_migration" : { - "link" : "/pve-docs/chapter-pct.html#pct_migration", - "subtitle" : "Migration", - "title" : "Proxmox Container Toolkit" - }, - "pct_options" : { - "link" : "/pve-docs/chapter-pct.html#pct_options", - "subtitle" : "Options", - "title" : "Proxmox Container Toolkit" - }, - "pct_startup_and_shutdown" : { - "link" : "/pve-docs/chapter-pct.html#pct_startup_and_shutdown", - "subtitle" : "Automatic Start and Shutdown of Containers", - "title" : "Proxmox Container Toolkit" - }, - "proxmox_node_management" : { - "link" : "/pve-docs/chapter-sysadmin.html#proxmox_node_management", - "title" : "Proxmox Node Management" - }, - "pve_admin_guide" : { - "link" : "/pve-docs/pve-admin-guide.html", - "title" : "Proxmox VE Administration Guide" - }, - "pve_ceph_install" : { - "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_install", - "subtitle" : "CLI Installation of Ceph Packages", - "title" : "Deploy Hyper-Converged Ceph Cluster" - }, - "pve_ceph_osds" : { - "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_osds", - "subtitle" : "Ceph OSDs", - "title" : "Deploy Hyper-Converged Ceph Cluster" - }, - "pve_ceph_pools" : { - "link" : "/pve-docs/chapter-pveceph.html#pve_ceph_pools", - "subtitle" : "Ceph Pools", - "title" : "Deploy Hyper-Converged Ceph Cluster" - }, - "pve_documentation_index" : { - "link" : "/pve-docs/index.html", - "title" : "Proxmox VE Documentation Index" - }, - "pve_firewall_cluster_wide_setup" : { - "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_cluster_wide_setup", - "subtitle" : "Cluster Wide Setup", - "title" : "Proxmox VE Firewall" - }, - "pve_firewall_host_specific_configuration" : { - "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_host_specific_configuration", - "subtitle" : "Host Specific Configuration", - "title" : "Proxmox VE Firewall" - }, - "pve_firewall_ip_aliases" : { - "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_aliases", - "subtitle" : "IP Aliases", - "title" : "Proxmox VE Firewall" - }, - "pve_firewall_ip_sets" : { - "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_sets", - "subtitle" : "IP Sets", - "title" : "Proxmox VE Firewall" - }, - "pve_firewall_security_groups" : { - "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_security_groups", - "subtitle" : "Security Groups", - "title" : "Proxmox VE Firewall" - }, - "pve_firewall_vm_container_configuration" : { - "link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_vm_container_configuration", - "subtitle" : "VM/Container Configuration", - "title" : "Proxmox VE Firewall" - }, - "pve_service_daemons" : { - "link" : "/pve-docs/index.html#_service_daemons", - "title" : "Service Daemons" - }, - "pveceph_fs" : { - "link" : "/pve-docs/chapter-pveceph.html#pveceph_fs", - "subtitle" : "CephFS", - "title" : "Deploy Hyper-Converged Ceph Cluster" - }, - "pveceph_fs_create" : { - "link" : "/pve-docs/chapter-pveceph.html#pveceph_fs_create", - "subtitle" : "Create CephFS", - "title" : "Deploy Hyper-Converged Ceph Cluster" - }, - "pvecm_create_cluster" : { - "link" : "/pve-docs/chapter-pvecm.html#pvecm_create_cluster", - "subtitle" : "Create a Cluster", - "title" : "Cluster Manager" - }, - "pvecm_join_node_to_cluster" : { - "link" : "/pve-docs/chapter-pvecm.html#pvecm_join_node_to_cluster", - "subtitle" : "Adding Nodes to the Cluster", - "title" : "Cluster Manager" - }, - "pvesdn_config_controllers" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_controllers", - "subtitle" : "Controllers", - "title" : "Software-Defined Network" - }, - "pvesdn_config_fabrics" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_fabrics", - "subtitle" : "Fabrics", - "title" : "Software-Defined Network" - }, - "pvesdn_config_vnet" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_vnet", - "subtitle" : "VNets", - "title" : "Software-Defined Network" - }, - "pvesdn_config_zone" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_zone", - "subtitle" : "Zones", - "title" : "Software-Defined Network" - }, - "pvesdn_controller_plugin_evpn" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_controller_plugin_evpn", - "subtitle" : "EVPN Controller", - "title" : "Software-Defined Network" - }, - "pvesdn_dns_plugin_powerdns" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_dns_plugin_powerdns", - "subtitle" : "PowerDNS Plugin", - "title" : "Software-Defined Network" - }, - "pvesdn_firewall_integration" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_firewall_integration", - "subtitle" : "Firewall Integration", - "title" : "Software-Defined Network" - }, - "pvesdn_ipam_plugin_netbox" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_netbox", - "subtitle" : "NetBox IPAM Plugin", - "title" : "Software-Defined Network" - }, - "pvesdn_ipam_plugin_phpipam" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_phpipam", - "subtitle" : "phpIPAM Plugin", - "title" : "Software-Defined Network" - }, - "pvesdn_ipam_plugin_pveipam" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_pveipam", - "subtitle" : "PVE IPAM Plugin", - "title" : "Software-Defined Network" - }, - "pvesdn_openfabric_fabric" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_openfabric_fabric", - "subtitle" : "On the Fabric", - "title" : "Software-Defined Network" - }, - "pvesdn_ospf_fabric" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ospf_fabric", - "subtitle" : "On the Fabric", - "title" : "Software-Defined Network" - }, - "pvesdn_zone_plugin_evpn" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_evpn", - "subtitle" : "EVPN Zones", - "title" : "Software-Defined Network" - }, - "pvesdn_zone_plugin_qinq" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_qinq", - "subtitle" : "QinQ Zones", - "title" : "Software-Defined Network" - }, - "pvesdn_zone_plugin_simple" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_simple", - "subtitle" : "Simple Zones", - "title" : "Software-Defined Network" - }, - "pvesdn_zone_plugin_vlan" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vlan", - "subtitle" : "VLAN Zones", - "title" : "Software-Defined Network" - }, - "pvesdn_zone_plugin_vxlan" : { - "link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vxlan", - "subtitle" : "VXLAN Zones", - "title" : "Software-Defined Network" - }, - "pvesr_schedule_time_format" : { - "link" : "/pve-docs/chapter-pvesr.html#pvesr_schedule_time_format", - "subtitle" : "Schedule Format", - "title" : "Storage Replication" - }, - "pveum_authentication_realms" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_authentication_realms", - "subtitle" : "Authentication Realms", - "title" : "User Management" - }, - "pveum_configure_u2f" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_configure_u2f", - "subtitle" : "Server Side U2F Configuration", - "title" : "User Management" - }, - "pveum_configure_webauthn" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_configure_webauthn", - "subtitle" : "Server Side Webauthn Configuration", - "title" : "User Management" - }, - "pveum_groups" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_groups", - "subtitle" : "Groups", - "title" : "User Management" - }, - "pveum_ldap_sync" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_ldap_sync", - "subtitle" : "Syncing LDAP-Based Realms", - "title" : "User Management" - }, - "pveum_permission_management" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_permission_management", - "subtitle" : "Permission Management", - "title" : "User Management" - }, - "pveum_pools" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_pools", - "subtitle" : "Pools", - "title" : "User Management" - }, - "pveum_roles" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_roles", - "subtitle" : "Roles", - "title" : "User Management" - }, - "pveum_tokens" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_tokens", - "subtitle" : "API Tokens", - "title" : "User Management" - }, - "pveum_users" : { - "link" : "/pve-docs/chapter-pveum.html#pveum_users", - "subtitle" : "Users", - "title" : "User Management" - }, - "qm_audio_device" : { - "link" : "/pve-docs/chapter-qm.html#qm_audio_device", - "subtitle" : "Audio Device", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_bios_and_uefi" : { - "link" : "/pve-docs/chapter-qm.html#qm_bios_and_uefi", - "subtitle" : "BIOS and UEFI", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_bootorder" : { - "link" : "/pve-docs/chapter-qm.html#qm_bootorder", - "subtitle" : "Device Boot Order", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_cloud_init" : { - "link" : "/pve-docs/chapter-qm.html#qm_cloud_init", - "title" : "Cloud-Init Support" - }, - "qm_copy_and_clone" : { - "link" : "/pve-docs/chapter-qm.html#qm_copy_and_clone", - "subtitle" : "Copies and Clones", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_cpu" : { - "link" : "/pve-docs/chapter-qm.html#qm_cpu", - "subtitle" : "CPU", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_display" : { - "link" : "/pve-docs/chapter-qm.html#qm_display", - "subtitle" : "Display", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_general_settings" : { - "link" : "/pve-docs/chapter-qm.html#qm_general_settings", - "subtitle" : "General Settings", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_hard_disk" : { - "link" : "/pve-docs/chapter-qm.html#qm_hard_disk", - "subtitle" : "Hard Disk", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_import_virtual_machines" : { - "link" : "/pve-docs/chapter-qm.html#qm_import_virtual_machines", - "subtitle" : "Importing Virtual Machines", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_machine_type" : { - "link" : "/pve-docs/chapter-qm.html#qm_machine_type", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_memory" : { - "link" : "/pve-docs/chapter-qm.html#qm_memory", - "subtitle" : "Memory", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_migration" : { - "link" : "/pve-docs/chapter-qm.html#qm_migration", - "subtitle" : "Migration", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_network_device" : { - "link" : "/pve-docs/chapter-qm.html#qm_network_device", - "subtitle" : "Network Device", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_options" : { - "link" : "/pve-docs/chapter-qm.html#qm_options", - "subtitle" : "Options", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_os_settings" : { - "link" : "/pve-docs/chapter-qm.html#qm_os_settings", - "subtitle" : "OS Settings", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_pci_passthrough_vm_config" : { - "link" : "/pve-docs/chapter-qm.html#qm_pci_passthrough_vm_config", - "subtitle" : "VM Configuration", - "title" : "PCI(e) Passthrough" - }, - "qm_qemu_agent" : { - "link" : "/pve-docs/chapter-qm.html#qm_qemu_agent", - "subtitle" : "QEMU Guest Agent", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_spice_enhancements" : { - "link" : "/pve-docs/chapter-qm.html#qm_spice_enhancements", - "subtitle" : "SPICE Enhancements", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_startup_and_shutdown" : { - "link" : "/pve-docs/chapter-qm.html#qm_startup_and_shutdown", - "subtitle" : "Automatic Start and Shutdown of Virtual Machines", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_system_settings" : { - "link" : "/pve-docs/chapter-qm.html#qm_system_settings", - "subtitle" : "System Settings", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_tpm" : { - "link" : "/pve-docs/chapter-qm.html#qm_tpm", - "subtitle" : "Trusted Platform Module (TPM)", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_usb_passthrough" : { - "link" : "/pve-docs/chapter-qm.html#qm_usb_passthrough", - "subtitle" : "USB Passthrough", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_virtio_rng" : { - "link" : "/pve-docs/chapter-qm.html#qm_virtio_rng", - "subtitle" : "VirtIO RNG", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_virtiofs" : { - "link" : "/pve-docs/chapter-qm.html#qm_virtiofs", - "subtitle" : "Virtiofs", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_virtual_machines_settings" : { - "link" : "/pve-docs/chapter-qm.html#qm_virtual_machines_settings", - "subtitle" : "Virtual Machines Settings", - "title" : "QEMU/KVM Virtual Machines" - }, - "qm_vmstatestorage" : { - "link" : "/pve-docs/chapter-qm.html#qm_vmstatestorage", - "title" : "QEMU/KVM Virtual Machines" - }, - "resource_mapping" : { - "link" : "/pve-docs/chapter-qm.html#resource_mapping", - "subtitle" : "Resource Mapping", - "title" : "QEMU/KVM Virtual Machines" - }, - "storage_btrfs" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_btrfs", - "title" : "BTRFS Backend" - }, - "storage_cephfs" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_cephfs", - "title" : "Ceph Filesystem (CephFS)" - }, - "storage_cifs" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_cifs", - "title" : "CIFS Backend" - }, - "storage_directory" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_directory", - "title" : "Directory Backend" - }, - "storage_lvm" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_lvm", - "title" : "LVM Backend" - }, - "storage_lvmthin" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_lvmthin", - "title" : "LVM thin Backend" - }, - "storage_nfs" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_nfs", - "title" : "NFS Backend" - }, - "storage_open_iscsi" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_open_iscsi", - "title" : "Open-iSCSI initiator" - }, - "storage_pbs" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_pbs", - "title" : "Proxmox Backup Server" - }, - "storage_pbs_encryption" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_pbs_encryption", - "subtitle" : "Encryption", - "title" : "Proxmox Backup Server" - }, - "storage_zfspool" : { - "link" : "/pve-docs/chapter-pvesm.html#storage_zfspool", - "title" : "Local ZFS Pool Backend" - }, - "sysadmin_certificate_management" : { - "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certificate_management", - "title" : "Certificate Management" - }, - "sysadmin_certs_acme_account" : { - "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certs_acme_account", - "subtitle" : "ACME Account", - "title" : "Certificate Management" - }, - "sysadmin_network_configuration" : { - "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_network_configuration", - "title" : "Network Configuration" - }, - "sysadmin_package_repositories" : { - "link" : "/pve-docs/chapter-sysadmin.html#sysadmin_package_repositories", - "title" : "Package Repositories" - }, - "user-realms-ad" : { - "link" : "/pve-docs/chapter-pveum.html#user-realms-ad", - "subtitle" : "Microsoft Active Directory (AD)", - "title" : "User Management" - }, - "user-realms-ldap" : { - "link" : "/pve-docs/chapter-pveum.html#user-realms-ldap", - "subtitle" : "LDAP", - "title" : "User Management" - }, - "user-realms-pam" : { - "link" : "/pve-docs/chapter-pveum.html#user-realms-pam", - "subtitle" : "Linux PAM Standard Authentication", - "title" : "User Management" - }, - "user_mgmt" : { - "link" : "/pve-docs/chapter-pveum.html#user_mgmt", - "title" : "User Management" - }, - "vzdump_retention" : { - "link" : "/pve-docs/chapter-vzdump.html#vzdump_retention", - "subtitle" : "Backup Retention", - "title" : "Backup and Restore" - } -}; -// Some configuration values are complex strings - so we need parsers/generators for them. -Ext.define('PVE.Parser', { - statics: { - // this class only contains static functions - - printACME: function (value) { - if (Ext.isArray(value.domains)) { - value.domains = value.domains.join(';'); - } - return PVE.Parser.printPropertyString(value); - }, - - parseACME: function (value) { - if (!value) { - return {}; - } - - let res = {}; - try { - value.split(',').forEach((property) => { - let [k, v] = property.split('=', 2); - if (Ext.isDefined(v)) { - res[k] = v; - } else { - throw `Failed to parse key-value pair: ${property}`; - } - }); - } catch (err) { - console.warn(err); - return undefined; - } - - if (res.domains !== undefined) { - res.domains = res.domains.split(/;/); - } - - return res; - }, - - parseBoolean: function (value, default_value) { - if (!Ext.isDefined(value)) { - return default_value; - } - value = value.toLowerCase(); - return value === '1' || value === 'on' || value === 'yes' || value === 'true'; - }, - - parsePropertyString: function (value, defaultKey) { - let res = {}; - - if (typeof value !== 'string' || value === '') { - return res; - } - - try { - value.split(',').forEach((property) => { - let [k, v] = property.split('=', 2); - if (Ext.isDefined(v)) { - res[k] = v; - } else if (Ext.isDefined(defaultKey)) { - if (Ext.isDefined(res[defaultKey])) { - throw 'defaultKey may be only defined once in propertyString'; - } - res[defaultKey] = k; // k is the value in this case - } else { - throw `Failed to parse key-value pair: ${property}`; - } - }); - } catch (err) { - console.warn(err); - return undefined; - } - - return res; - }, - - printPropertyString: function (data, defaultKey) { - var stringparts = [], - gotDefaultKeyVal = false, - defaultKeyVal; - - Ext.Object.each(data, function (key, value) { - if (defaultKey !== undefined && key === defaultKey) { - gotDefaultKeyVal = true; - defaultKeyVal = value; - } else if (value !== '') { - stringparts.push(key + '=' + value); - } - }); - - stringparts = stringparts.sort(); - if (gotDefaultKeyVal) { - stringparts.unshift(defaultKeyVal); - } - - return stringparts.join(','); - }, - - parseQemuNetwork: function (key, value) { - if (!(key && value)) { - return undefined; - } - - let res = {}, - errors = false; - Ext.Array.each(value.split(','), function (p) { - if (!p || p.match(/^\s*$/)) { - return undefined; // continue - } - - let match_res; - - if ( - (match_res = p.match( - /^(ne2k_pci|e1000e?|e1000-82540em|e1000-82544gc|e1000-82545em|vmxnet3|rtl8139|pcnet|virtio|ne2k_isa|i82551|i82557b|i82559er)(=([0-9a-f]{2}(:[0-9a-f]{2}){5}))?$/i, - )) !== null - ) { - res.model = match_res[1].toLowerCase(); - if (match_res[3]) { - res.macaddr = match_res[3]; - } - } else if ((match_res = p.match(/^bridge=(\S+)$/)) !== null) { - res.bridge = match_res[1]; - } else if ((match_res = p.match(/^rate=(\d+(\.\d+)?|\.\d+)$/)) !== null) { - res.rate = match_res[1]; - } else if ((match_res = p.match(/^tag=(\d+(\.\d+)?)$/)) !== null) { - res.tag = match_res[1]; - } else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) { - res.firewall = match_res[1]; - } else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) { - res.disconnect = match_res[1]; - } else if ((match_res = p.match(/^queues=(\d+)$/)) !== null) { - res.queues = match_res[1]; - } else if ( - (match_res = p.match(/^trunks=(\d+(?:-\d+)?(?:;\d+(?:-\d+)?)*)$/)) !== null - ) { - res.trunks = match_res[1]; - } else if ((match_res = p.match(/^mtu=(\d+)$/)) !== null) { - res.mtu = match_res[1]; - } else { - errors = true; - return false; // break - } - return undefined; // continue - }); - - if (errors || !res.model) { - return undefined; - } - - return res; - }, - - printQemuNetwork: function (net) { - var netstr = net.model; - if (net.macaddr) { - netstr += '=' + net.macaddr; - } - if (net.bridge) { - netstr += ',bridge=' + net.bridge; - if (net.tag) { - netstr += ',tag=' + net.tag; - } - if (net.firewall) { - netstr += ',firewall=' + net.firewall; - } - } - if (net.rate) { - netstr += ',rate=' + net.rate; - } - if (net.queues) { - netstr += ',queues=' + net.queues; - } - if (net.disconnect) { - netstr += ',link_down=' + net.disconnect; - } - if (net.trunks) { - netstr += ',trunks=' + net.trunks; - } - if (net.mtu) { - netstr += ',mtu=' + net.mtu; - } - return netstr; - }, - - parseQemuDrive: function (key, value) { - if (!(key && value)) { - return undefined; - } - - const [, bus, index] = key.match(/^([a-z]+)(\d+)$/); - if (!bus) { - return undefined; - } - let res = { - interface: bus, - index, - }; - - var errors = false; - Ext.Array.each(value.split(','), function (p) { - if (!p || p.match(/^\s*$/)) { - return undefined; // continue - } - let match = p.match(/^([a-z_]+)=(\S+)$/); - if (!match) { - if (!p.match(/[=]/)) { - res.file = p; - return undefined; // continue - } - errors = true; - return false; // break - } - let [, k, v] = match; - if (k === 'volume') { - k = 'file'; - } - - if (Ext.isDefined(res[k])) { - errors = true; - return false; // break - } - - if (k === 'cache' && v === 'off') { - v = 'none'; - } - - res[k] = v; - - return undefined; // continue - }); - - if (errors || !res.file) { - return undefined; - } - - return res; - }, - - printQemuDrive: function (drive) { - var drivestr = drive.file; - - Ext.Object.each(drive, function (key, value) { - if ( - !Ext.isDefined(value) || - key === 'file' || - key === 'index' || - key === 'interface' - ) { - return; // continue - } - drivestr += ',' + key + '=' + value; - }); - - return drivestr; - }, - - parseIPConfig: function (key, value) { - if (!(key && value)) { - return undefined; // continue - } - - let res = {}; - try { - value.split(',').forEach((p) => { - if (!p || p.match(/^\s*$/)) { - return; // continue - } - - const match = p.match(/^(ip|gw|ip6|gw6)=(\S+)$/); - if (!match) { - throw `could not parse as IP config: ${p}`; - } - let [, k, v] = match; - res[k] = v; - }); - } catch (err) { - console.warn(err); - return undefined; // continue - } - - return res; - }, - - printIPConfig: function (cfg) { - return Object.entries(cfg) - .filter(([k, v]) => v && k.match(/^(ip|gw|ip6|gw6)$/)) - .map(([k, v]) => `${k}=${v}`) - .join(','); - }, - - parseLxcNetwork: function (value) { - if (!value) { - return undefined; - } - - let data = {}; - value.split(',').forEach((p) => { - if (!p || p.match(/^\s*$/)) { - return; // continue - } - let match_res = p.match(/^(bridge|hwaddr|mtu|name|ip|ip6|gw|gw6|tag|rate)=(\S+)$/); - if (match_res) { - data[match_res[1]] = match_res[2]; - } else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) { - data.firewall = PVE.Parser.parseBoolean(match_res[1]); - } else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) { - data.link_down = PVE.Parser.parseBoolean(match_res[1]); - } else if ((match_res = p.match(/^host-managed=(\d+)$/)) !== null) { - data['host-managed'] = PVE.Parser.parseBoolean(match_res[1]); - } else if (!p.match(/^type=\S+$/)) { - console.warn(`could not parse LXC network string ${p}`); - } - }); - - return data; - }, - - printLxcNetwork: function (config) { - let knownKeys = { - bridge: 1, - firewall: 1, - gw6: 1, - gw: 1, - hwaddr: 1, - ip6: 1, - ip: 1, - mtu: 1, - name: 1, - rate: 1, - tag: 1, - link_down: 1, - 'host-managed': 1, - }; - return Object.entries(config) - .filter(([k, v]) => v !== undefined && v !== '' && knownKeys[k]) - .map(([k, v]) => `${k}=${v}`) - .join(','); - }, - - parseLxcMountPoint: function (value) { - if (!value) { - return undefined; - } - - let res = {}; - let errors = false; - Ext.Array.each(value.split(','), function (p) { - if (!p || p.match(/^\s*$/)) { - return undefined; // continue - } - let match = p.match(/^([a-z_]+)=(.+)$/); - if (!match) { - if (!p.match(/[=]/)) { - res.file = p; - return undefined; // continue - } - errors = true; - return false; // break - } - let [, k, v] = match; - if (k === 'volume') { - k = 'file'; - } - - if (Ext.isDefined(res[k])) { - errors = true; - return false; // break - } - - res[k] = v; - - return undefined; - }); - - if (errors || !res.file) { - return undefined; - } - - const match = res.file.match(/^([a-z][a-z0-9\-_.]*[a-z0-9]):/i); - if (match) { - res.storage = match[1]; - res.type = 'volume'; - } else if (res.file.match(/^\/dev\//)) { - res.type = 'device'; - } else { - res.type = 'bind'; - } - - return res; - }, - - printLxcMountPoint: function (mp) { - let drivestr = mp.file; - for (const [key, value] of Object.entries(mp)) { - if ( - !Ext.isDefined(value) || - key === 'file' || - key === 'type' || - key === 'storage' - ) { - continue; - } - drivestr += `,${key}=${value}`; - } - return drivestr; - }, - - parseStartup: function (value) { - if (value === undefined) { - return undefined; - } - - let res = {}; - try { - value.split(',').forEach((p) => { - if (!p || p.match(/^\s*$/)) { - return; // continue - } - - let match_res; - if ((match_res = p.match(/^(order)?=(\d+)$/)) !== null) { - res.order = match_res[2]; - } else if ((match_res = p.match(/^up=(\d+)$/)) !== null) { - res.up = match_res[1]; - } else if ((match_res = p.match(/^down=(\d+)$/)) !== null) { - res.down = match_res[1]; - } else { - throw `could not parse startup config ${p}`; - } - }); - } catch (err) { - console.warn(err); - return undefined; - } - - return res; - }, - - printStartup: function (startup) { - let arr = []; - if (startup.order !== undefined && startup.order !== '') { - arr.push('order=' + startup.order); - } - if (startup.up !== undefined && startup.up !== '') { - arr.push('up=' + startup.up); - } - if (startup.down !== undefined && startup.down !== '') { - arr.push('down=' + startup.down); - } - - return arr.join(','); - }, - - parseQemuSmbios1: function (value) { - let res = value.split(',').reduce((acc, currentValue) => { - const [k, v] = currentValue.split(/[=](.+)/); - acc[k] = v; - return acc; - }, {}); - - if (PVE.Parser.parseBoolean(res.base64, false)) { - for (const [k, v] of Object.entries(res)) { - if (k !== 'uuid') { - res[k] = Ext.util.Base64.decode(v); - } - } - } - - return res; - }, - - printQemuSmbios1: function (data) { - let base64 = false; - let datastr = Object.entries(data) - .map(([key, value]) => { - if (value === '') { - return undefined; - } - if (key !== 'uuid') { - base64 = true; // smbios values can be arbitrary, so encode and mark config as such - value = Ext.util.Base64.encode(value); - } - return `${key}=${value}`; - }) - .filter((v) => v !== undefined) - .join(','); - - if (base64) { - datastr += ',base64=1'; - } - return datastr; - }, - - parseTfaConfig: function (value) { - let res = {}; - value.split(',').forEach((p) => { - const [k, v] = p.split('=', 2); - res[k] = v; - }); - - return res; - }, - - parseTfaType: function (value) { - let match; - if (!value || !value.length) { - return undefined; - } else if (value === 'x!oath') { - return 'totp'; - } else if ((match = value.match(/^x!(.+)$/)) !== null) { - return match[1]; - } else { - return 1; - } - }, - - parseQemuCpu: function (value) { - if (!value) { - return {}; - } - - let res = {}; - let errors = false; - Ext.Array.each(value.split(','), function (p) { - if (!p || p.match(/^\s*$/)) { - return undefined; // continue - } - - if (!p.match(/[=]/)) { - if (Ext.isDefined(res.cpu)) { - errors = true; - return false; // break - } - res.cputype = p; - return undefined; // continue - } - - let match = p.match(/^([a-z_-]+)=(\S+)$/); - if (!match || Ext.isDefined(res[match[1]])) { - errors = true; - return false; // break - } - - let [, k, v] = match; - res[k] = v; - - return undefined; - }); - - if (errors || !res.cputype) { - return undefined; - } - - return res; - }, - - printQemuCpu: function (cpu) { - let cpustr = cpu.cputype; - let optstr = ''; - - Ext.Object.each(cpu, function (key, value) { - if (!Ext.isDefined(value) || key === 'cputype') { - return; // continue - } - optstr += ',' + key + '=' + value; - }); - - if (!cpustr) { - if (optstr) { - return 'kvm64' + optstr; - } else { - return undefined; - } - } - - return cpustr + optstr; - }, - - parseSSHKey: function (key) { - // |--- options can have quotes--| type key comment - let keyre = /^(?:((?:[^\s"]|"(?:\\.|[^"\\])*")+)\s+)?(\S+)\s+(\S+)(?:\s+(.*))?$/; - let typere = - /^(?:(?:sk-)?(?:ssh-(?:dss|rsa|ed25519)|ecdsa-sha2-nistp\d+)(?:@(?:[a-z0-9_-]+\.)+[a-z]{2,})?)$/; - - let m = key.match(keyre); - if (!m || m.length < 3 || !m[2]) { - // [2] is always either type or key - return null; - } - if (m[1] && m[1].match(typere)) { - return { - type: m[1], - key: m[2], - comment: m[3], - }; - } - if (m[2].match(typere)) { - return { - options: m[1], - type: m[2], - key: m[3], - comment: m[4], - }; - } - return null; - }, - - parseACMEPluginData: function (data) { - let res = {}; - let extradata = []; - data.split('\n').forEach((line) => { - // capture everything after the first = as value - let [key, value] = line.split(/[=](.+)/); - if (value !== undefined) { - res[key] = value; - } else { - extradata.push(line); - } - }); - return [res, extradata]; - }, - - filterPropertyStringList: function (list, filterFn, defaultKey) { - return list.filter((entry) => - filterFn(PVE.Parser.parsePropertyString(entry, defaultKey)), - ); - }, - }, -}); -/* This state provider keeps part of the state inside the browser history. - * - * We compress (shorten) url using dictionary based compression, i.e., we use - * column separated list instead of url encoded hash: - * #v\d* version/format - * := indicates string values - * :\d+ lookup value in dictionary hash - * #v1:=value1:5:=value2:=value3:... - */ - -Ext.define('PVE.StateProvider', { - extend: 'Ext.state.LocalStorageProvider', - - // private - setHV: function (name, newvalue, fireEvents) { - let me = this; - - let changes = false; - let oldtext = Ext.encode(me.UIState[name]); - let newtext = Ext.encode(newvalue); - if (newtext !== oldtext) { - changes = true; - me.UIState[name] = newvalue; - if (fireEvents) { - me.fireEvent('statechange', me, name, { value: newvalue }); - } - } - return changes; - }, - - // private - hslist: [ - // order is important for notifications - // [ name, default ] - ['view', 'server'], - ['rid', 'root'], - ['ltab', 'tasks'], - ['nodetab', ''], - ['storagetab', ''], - ['sdntab', ''], - ['pooltab', ''], - ['kvmtab', ''], - ['lxctab', ''], - ['dctab', ''], - ], - - hprefix: 'v1', - - compDict: { - tfa: 54, - sdn: 53, - cloudinit: 52, - replication: 51, - system: 50, - monitor: 49, - 'ha-fencing': 48, - 'ha-rules': 47, - 'ha-resources': 46, - 'ceph-log': 45, - 'ceph-crushmap': 44, - 'ceph-pools': 43, - 'ceph-osdtree': 42, - 'ceph-disklist': 41, - 'ceph-monlist': 40, - 'ceph-config': 39, - ceph: 38, - 'firewall-fwlog': 37, - 'firewall-options': 36, - 'firewall-ipset': 35, - 'firewall-aliases': 34, - 'firewall-sg': 33, - firewall: 32, - apt: 31, - members: 30, - snapshot: 29, - ha: 28, - support: 27, - pools: 26, - syslog: 25, - ubc: 24, - initlog: 23, - openvz: 22, - backup: 21, - resources: 20, - content: 19, - root: 18, - domains: 17, - roles: 16, - groups: 15, - users: 14, - time: 13, - dns: 12, - network: 11, - services: 10, - options: 9, - console: 8, - hardware: 7, - permissions: 6, - summary: 5, - tasks: 4, - clog: 3, - storage: 2, - folder: 1, - server: 0, - }, - - decodeHToken: function (token) { - let me = this; - - let state = {}; - if (!token) { - me.hslist.forEach(([k, v]) => { - state[k] = v; - }); - return state; - } - - let [prefix, ...items] = token.split(':'); - - if (prefix !== me.hprefix) { - return me.decodeHToken(); - } - - Ext.Array.each(me.hslist, function (rec) { - let value = items.shift(); - if (value) { - if (value[0] === '=') { - value = decodeURIComponent(value.slice(1)); - } - for (const [key, hash] of Object.entries(me.compDict)) { - if (String(value) === String(hash)) { - value = key; - break; - } - } - } - state[rec[0]] = value; - }); - - return state; - }, - - encodeHToken: function (state) { - let me = this; - - let ctoken = me.hprefix; - Ext.Array.each(me.hslist, function (rec) { - let value = state[rec[0]]; - if (!Ext.isDefined(value)) { - value = rec[1]; - } - value = encodeURIComponent(value); - if (!value) { - ctoken += ':'; - } else if (Ext.isDefined(me.compDict[value])) { - ctoken += ':' + me.compDict[value]; - } else { - ctoken += ':=' + value; - } - }); - - return ctoken; - }, - - constructor: function (config) { - let me = this; - - me.callParent([config]); - - me.UIState = me.decodeHToken(); // set default - - let history_change_cb = function (token) { - if (!token) { - Ext.History.back(); - return; - } - - let newstate = me.decodeHToken(token); - Ext.Array.each(me.hslist, function (rec) { - if (typeof newstate[rec[0]] === 'undefined') { - return; - } - me.setHV(rec[0], newstate[rec[0]], true); - }); - }; - - let start_token = Ext.History.getToken(); - if (start_token) { - history_change_cb(start_token); - } else { - let htext = me.encodeHToken(me.UIState); - Ext.History.add(htext); - } - - Ext.History.on('change', history_change_cb); - }, - - get: function (name, defaultValue) { - let me = this; - - let data; - if (typeof me.UIState[name] !== 'undefined') { - data = { value: me.UIState[name] }; - } else { - data = me.callParent(arguments); - if (!data && name === 'GuiCap') { - data = { - vms: {}, - storage: {}, - access: {}, - nodes: {}, - dc: {}, - sdn: {}, - }; - } - } - return data; - }, - - clear: function (name) { - let me = this; - - if (typeof me.UIState[name] !== 'undefined') { - me.UIState[name] = null; - } - me.callParent(arguments); - }, - - set: function (name, value, fireevent) { - let me = this; - - if (typeof me.UIState[name] !== 'undefined') { - let newvalue = value ? value.value : null; - if (me.setHV(name, newvalue, fireevent)) { - let htext = me.encodeHToken(me.UIState); - Ext.History.add(htext); - } - } else { - me.callParent(arguments); - } - }, -}); -Ext.ns('PVE'); - -console.log('Starting Proxmox VE Manager'); - -Ext.Ajax.defaultHeaders = { - Accept: 'application/json', -}; - -Ext.define('PVE.Utils', { - utilities: { - // this singleton contains miscellaneous utilities - - toolkit: undefined, // (extjs|touch), set inside Toolkit.js - - bus_match: /^(ide|sata|virtio|scsi)(\d+)$/, - - log_severity_hash: { - 0: 'panic', - 1: 'alert', - 2: 'critical', - 3: 'error', - 4: 'warning', - 5: 'notice', - 6: 'info', - 7: 'debug', - }, - - support_level_hash: { - c: gettext('Community'), - b: gettext('Basic'), - s: gettext('Standard'), - p: gettext('Premium'), - }, - - noSubKeyHtml: - 'You do not have a valid subscription for this server. Please visit ' + - '' + - 'www.proxmox.com to get a list of available options.', - - getClusterSubscriptionLevel: async function () { - let { result } = await Proxmox.Async.api2({ url: '/cluster/status' }); - let levelMap = Object.fromEntries( - result.data.filter((v) => v.type === 'node').map((v) => [v.name, v.level]), - ); - return levelMap; - }, - - kvm_ostypes: { - Linux: [ - { desc: '6.x - 2.6 Kernel', val: 'l26' }, - { desc: '2.4 Kernel', val: 'l24' }, - ], - 'Microsoft Windows': [ - { desc: '11/2022/2025', val: 'win11' }, - { desc: '10/2016/2019', val: 'win10' }, - { desc: '8.x/2012/2012r2', val: 'win8' }, - { desc: '7/2008r2', val: 'win7' }, - { desc: 'Vista/2008', val: 'w2k8' }, - { desc: 'XP/2003', val: 'wxp' }, - { desc: '2000', val: 'w2k' }, - ], - 'Solaris Kernel': [{ desc: '-', val: 'solaris' }], - Other: [{ desc: '-', val: 'other' }], - }, - - is_windows: function (ostype) { - for (let entry of PVE.Utils.kvm_ostypes['Microsoft Windows']) { - if (entry.val === ostype) { - return true; - } - } - return false; - }, - - get_health_icon: function (state, circle) { - if (circle === undefined) { - circle = false; - } - - if (state === undefined) { - state = 'uknown'; - } - - var icon = 'faded fa-question'; - switch (state) { - case 'good': - icon = 'good fa-check'; - break; - case 'upgrade': - icon = 'warning fa-upload'; - break; - case 'old': - icon = 'warning fa-refresh'; - break; - case 'warning': - icon = 'warning fa-exclamation'; - break; - case 'critical': - icon = 'critical fa-times'; - break; - default: - break; - } - - if (circle) { - icon += '-circle'; - } - - return icon; - }, - - parse_ceph_version: function (service) { - if (service.ceph_version_short) { - return service.ceph_version_short; - } - - if (service.ceph_version) { - // See PVE/Ceph/Tools.pm - get_local_version - const match = service.ceph_version.match(/^ceph.*\sv?(\d+(?:\.\d+)+)/); - if (match) { - return match[1]; - } - } - - return undefined; - }, - - compare_ceph_versions: function (a, b) { - let avers = []; - let bvers = []; - - if (a === b) { - return 0; - } - - if (Ext.isArray(a)) { - avers = a.slice(); // copy array - } else { - avers = a.toString().split('.'); - } - - if (Ext.isArray(b)) { - bvers = b.slice(); // copy array - } else { - bvers = b.toString().split('.'); - } - - for (;;) { - let av = avers.shift(); - let bv = bvers.shift(); - - if (av === undefined && bv === undefined) { - return 0; - } else if (av === undefined) { - return -1; - } else if (bv === undefined) { - return 1; - } else { - let diff = parseInt(av, 10) - parseInt(bv, 10); - if (diff !== 0) { - return diff; - } - // else we need to look at the next parts - } - } - }, - - get_ceph_icon_html: function (health, fw) { - var state = PVE.Utils.map_ceph_health[health]; - var cls = PVE.Utils.get_health_icon(state); - if (fw) { - cls += ' fa-fw'; - } - return " "; - }, - - map_ceph_health: { - HEALTH_OK: 'good', - HEALTH_UPGRADE: 'upgrade', - HEALTH_OLD: 'old', - HEALTH_WARN: 'warning', - HEALTH_ERR: 'critical', - }, - - render_sdn_pending: function (rec, value, key, index) { - if (rec.data.state === undefined || rec.data.state === null) { - return Ext.htmlEncode(value); - } - - if (rec.data.state === 'deleted') { - if (value === undefined) { - return ' '; - } else { - return `${Ext.htmlEncode(value)}`; - } - } else if (rec.data.pending[key] !== undefined && rec.data.pending[key] !== null) { - if (rec.data.pending[key] === 'deleted') { - return ' '; - } else { - return Ext.htmlEncode(rec.data.pending[key]); - } - } - return Ext.htmlEncode(value); - }, - - render_sdn_pending_state: function (rec, value) { - if (value === undefined || value === null) { - return ' '; - } - - let icon = ``; - - if (value === 'deleted') { - return `${icon}${Ext.htmlEncode(value)}`; - } - - let tip = gettext('Pending Changes') + ':
    '; - - for (const [key, keyvalue] of Object.entries(rec.data.pending)) { - if ( - (rec.data[key] !== undefined && rec.data.pending[key] !== rec.data[key]) || - rec.data[key] === undefined - ) { - tip += `${Ext.htmlEncode(key)}: ${Ext.htmlEncode(keyvalue)}
    `; - } - } - return `${icon}${Ext.htmlEncode(value)}`; - }, - - render_ceph_health: function (healthObj) { - var state = { - iconCls: PVE.Utils.get_health_icon(), - text: '', - }; - - if (!healthObj || !healthObj.status) { - return state; - } - - var health = PVE.Utils.map_ceph_health[healthObj.status]; - - state.iconCls = PVE.Utils.get_health_icon(health, true); - state.text = healthObj.status; - - return state; - }, - - render_zfs_health: function (value) { - if (typeof value === 'undefined') { - return ''; - } - var iconCls = 'question-circle'; - switch (value) { - case 'AVAIL': - case 'ONLINE': - iconCls = 'check-circle good'; - break; - case 'REMOVED': - case 'DEGRADED': - iconCls = 'exclamation-circle warning'; - break; - case 'UNAVAIL': - case 'FAULTED': - case 'OFFLINE': - iconCls = 'times-circle critical'; - break; - default: //unknown - } - - return ' ' + value; - }, - - render_pbs_fingerprint: (fp) => fp.substring(0, 23), - - render_backup_encryption: function (v, meta, record) { - if (!v) { - return gettext('No'); - } - - let tip = ''; - if (v.match(/^[a-fA-F0-9]{2}:/)) { - // fingerprint - tip = `Key fingerprint ${PVE.Utils.render_pbs_fingerprint(v)}`; - } - let icon = ``; - return `${icon} ${gettext('Encrypted')}`; - }, - - render_backup_verification: function (v, meta, record) { - let i = (cls, txt) => ` ${txt}`; - if (v === undefined || v === null) { - return i('question-circle-o warning', gettext('None')); - } - let tip = ''; - let txt = gettext('Failed'); - let iconCls = 'times critical'; - if (v.state === 'ok') { - txt = gettext('OK'); - iconCls = 'check good'; - let now = Date.now() / 1000; - let task = Proxmox.Utils.parse_task_upid(v.upid); - let verify_time = Proxmox.Utils.render_timestamp(task.starttime); - tip = `Last verify task started on ${verify_time}`; - if (now - v.starttime > 30 * 24 * 60 * 60) { - tip = `Last verify task over 30 days ago: ${verify_time}`; - iconCls = 'check warning'; - } - } - return ` ${i(iconCls, txt)} `; - }, - - render_backup_status: function (value, meta, record) { - if (typeof value === 'undefined') { - return ''; - } - - let iconCls = 'check-circle good'; - let text = gettext('Yes'); - - if (!PVE.Parser.parseBoolean(value.toString())) { - iconCls = 'times-circle critical'; - - text = gettext('No'); - - let reason = record.get('reason'); - if (typeof reason !== 'undefined') { - if (reason in PVE.Utils.backup_reasons_table) { - reason = PVE.Utils.backup_reasons_table[record.get('reason')]; - } - text = `${text} - ${reason}`; - } - } - - return ` ${text}`; - }, - - render_backup_days_of_week: function (val) { - var dows = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; - var selected = []; - var cur = -1; - val.split(',').forEach(function (day) { - cur++; - var dow = (dows.indexOf(day) + 6) % 7; - if (cur === dow) { - if (selected.length === 0 || selected[selected.length - 1] === 0) { - selected.push(1); - } else { - selected[selected.length - 1]++; - } - } else { - while (cur < dow) { - cur++; - selected.push(0); - } - selected.push(1); - } - }); - - cur = -1; - var days = []; - selected.forEach(function (item) { - cur++; - if (item > 2) { - days.push( - Ext.Date.dayNames[cur + 1] + '-' + Ext.Date.dayNames[(cur + item) % 7], - ); - cur += item - 1; - } else if (item === 2) { - days.push(Ext.Date.dayNames[cur + 1]); - days.push(Ext.Date.dayNames[(cur + 2) % 7]); - cur++; - } else if (item === 1) { - days.push(Ext.Date.dayNames[(cur + 1) % 7]); - } - }); - return days.join(', '); - }, - - render_backup_selection: function (value, metaData, record) { - let allExceptText = gettext('All except {0}'); - let allText = '-- ' + gettext('All') + ' --'; - if (record.data.all) { - if (record.data.exclude) { - return Ext.String.format(allExceptText, record.data.exclude); - } - return allText; - } - if (record.data.vmid) { - return record.data.vmid; - } - - if (record.data.pool) { - return "Pool '" + record.data.pool + "'"; - } - - return '-'; - }, - - backup_reasons_table: { - 'backup=yes': gettext('Enabled'), - 'backup=no': gettext('Disabled'), - enabled: gettext('Enabled'), - disabled: gettext('Disabled'), - 'not a volume': gettext('Not a volume'), - 'efidisk but no OMVF BIOS': gettext('EFI Disk without OMVF BIOS'), - }, - - renderNotFound: (what) => Ext.String.format(gettext('No {0} found'), what), - - get_kvm_osinfo: function (value) { - var info = { base: 'Other' }; // default - if (value) { - Ext.each(Object.keys(PVE.Utils.kvm_ostypes), function (k) { - Ext.each(PVE.Utils.kvm_ostypes[k], function (e) { - if (e.val === value) { - info = { desc: e.desc, base: k }; - } - }); - }); - } - return info; - }, - - render_kvm_ostype: function (value) { - var osinfo = PVE.Utils.get_kvm_osinfo(value); - if (osinfo.desc && osinfo.desc !== '-') { - return osinfo.base + ' ' + osinfo.desc; - } else { - return osinfo.base; - } - }, - - render_hotplug_features: function (value) { - var fa = []; - - if (!value || value === '0') { - return gettext('Disabled'); - } - - if (value === '1') { - value = 'disk,network,usb'; - } - - Ext.each(value.split(','), function (el) { - if (el === 'disk') { - fa.push(gettext('Disk')); - } else if (el === 'network') { - fa.push(gettext('Network')); - } else if (el === 'usb') { - fa.push('USB'); - } else if (el === 'memory') { - fa.push(gettext('Memory')); - } else if (el === 'cpu') { - fa.push(gettext('CPU')); - } else { - fa.push(el); - } - }); - - return fa.join(', '); - }, - - render_localtime: function (value) { - if (value === '__default__') { - return Proxmox.Utils.defaultText + ' (' + gettext('Enabled for Windows') + ')'; - } - return Proxmox.Utils.format_boolean(value); - }, - - render_qga_features: function (config) { - if (!config) { - return Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')'; - } - let qga = PVE.Parser.parsePropertyString(config, 'enabled'); - if (!PVE.Parser.parseBoolean(qga.enabled)) { - return Proxmox.Utils.disabledText; - } - delete qga.enabled; - - let agentstring = Proxmox.Utils.enabledText; - - for (const [key, value] of Object.entries(qga)) { - let displayText = Proxmox.Utils.disabledText; - if (key === 'type') { - let map = { - isa: 'ISA', - virtio: 'VirtIO', - }; - displayText = map[value] || Proxmox.Utils.unknownText; - } else if (key === 'freeze-fs-on-backup' && PVE.Parser.parseBoolean(value)) { - continue; - } else if (PVE.Parser.parseBoolean(value)) { - displayText = Proxmox.Utils.enabledText; - } - agentstring += `, ${key}: ${displayText}`; - } - - return agentstring; - }, - - render_qemu_machine: function (value) { - return value || Proxmox.Utils.defaultText + ' (i440fx)'; - }, - - render_qemu_bios: function (value) { - if (!value) { - return Proxmox.Utils.defaultText + ' (SeaBIOS)'; - } else if (value === 'seabios') { - return 'SeaBIOS'; - } else if (value === 'ovmf') { - return 'OVMF (UEFI)'; - } else { - return value; - } - }, - - render_dc_ha_opts: function (value) { - if (!value) { - return Proxmox.Utils.defaultText; - } else { - return PVE.Parser.printPropertyString(value); - } - }, - render_as_property_string: (v) => - !v ? Proxmox.Utils.defaultText : PVE.Parser.printPropertyString(v), - - render_scsihw: function (value) { - if (!value || value === '__default__') { - return Proxmox.Utils.defaultText + ' (LSI 53C895A)'; - } else if (value === 'lsi') { - return 'LSI 53C895A'; - } else if (value === 'lsi53c810') { - return 'LSI 53C810'; - } else if (value === 'megasas') { - return 'MegaRAID SAS 8708EM2'; - } else if (value === 'virtio-scsi-pci') { - return 'VirtIO SCSI'; - } else if (value === 'virtio-scsi-single') { - return 'VirtIO SCSI single'; - } else if (value === 'pvscsi') { - return 'VMware PVSCSI'; - } else { - return value; - } - }, - - render_spice_enhancements: function (values) { - let props = PVE.Parser.parsePropertyString(values); - if (Ext.Object.isEmpty(props)) { - return Proxmox.Utils.noneText; - } - - let output = []; - if (PVE.Parser.parseBoolean(props.foldersharing)) { - output.push('Folder Sharing: ' + gettext('Enabled')); - } - if (props.videostreaming === 'all' || props.videostreaming === 'filter') { - output.push('Video Streaming: ' + props.videostreaming); - } - return output.join(', '); - }, - - // fixme: auto-generate this - // for now, please keep in sync with PVE::Tools::kvmkeymaps - kvm_keymaps: { - __default__: Proxmox.Utils.defaultText, - //ar: 'Arabic', - da: 'Danish', - de: 'German', - 'de-ch': 'German (Swiss)', - 'en-gb': 'English (UK)', - 'en-us': 'English (USA)', - es: 'Spanish', - //et: 'Estonia', - fi: 'Finnish', - //fo: 'Faroe Islands', - fr: 'French', - 'fr-be': 'French (Belgium)', - 'fr-ca': 'French (Canada)', - 'fr-ch': 'French (Swiss)', - //hr: 'Croatia', - hu: 'Hungarian', - is: 'Icelandic', - it: 'Italian', - ja: 'Japanese', - lt: 'Lithuanian', - //lv: 'Latvian', - mk: 'Macedonian', - nl: 'Dutch', - //'nl-be': 'Dutch (Belgium)', - no: 'Norwegian', - pl: 'Polish', - pt: 'Portuguese', - 'pt-br': 'Portuguese (Brazil)', - //ru: 'Russian', - sl: 'Slovenian', - sv: 'Swedish', - //th: 'Thai', - tr: 'Turkish', - }, - - kvm_vga_drivers: { - __default__: Proxmox.Utils.defaultText, - std: gettext('Standard VGA'), - vmware: gettext('VMware compatible'), - qxl: 'SPICE', - qxl2: 'SPICE dual monitor', - qxl3: 'SPICE three monitors', - qxl4: 'SPICE four monitors', - serial0: gettext('Serial terminal') + ' 0', - serial1: gettext('Serial terminal') + ' 1', - serial2: gettext('Serial terminal') + ' 2', - serial3: gettext('Serial terminal') + ' 3', - virtio: 'VirtIO-GPU', - 'virtio-gl': 'VirGL GPU', - none: Proxmox.Utils.noneText, - }, - - render_kvm_language: function (value) { - if (!value || value === '__default__') { - return Proxmox.Utils.defaultText; - } - let text = PVE.Utils.kvm_keymaps[value]; - return text ? `${text} (${value})` : value; - }, - - console_map: { - __default__: Proxmox.Utils.defaultText + ' (xterm.js)', - vv: 'SPICE (remote-viewer)', - html5: 'HTML5 (noVNC)', - xtermjs: 'xterm.js', - }, - - render_console_viewer: function (value) { - value = value || '__default__'; - return PVE.Utils.console_map[value] || value; - }, - - render_kvm_vga_driver: function (value) { - if (!value) { - return Proxmox.Utils.defaultText; - } - let vga = PVE.Parser.parsePropertyString(value, 'type'); - let text = PVE.Utils.kvm_vga_drivers[vga.type]; - if (!vga.type) { - text = Proxmox.Utils.defaultText; - } - return text ? `${text} (${value})` : value; - }, - - render_kvm_startup: function (value) { - var startup = PVE.Parser.parseStartup(value); - - var res = 'order='; - if (startup.order === undefined) { - res += 'any'; - } else { - res += startup.order; - } - if (startup.up !== undefined) { - res += ',up=' + startup.up; - } - if (startup.down !== undefined) { - res += ',down=' + startup.down; - } - - return res; - }, - - extractFormActionError: function (action) { - var msg; - switch (action.failureType) { - case Ext.form.action.Action.CLIENT_INVALID: - msg = gettext('Form fields may not be submitted with invalid values'); - break; - case Ext.form.action.Action.CONNECT_FAILURE: { - msg = gettext('Connection error'); - let resp = action.response; - if (resp.status && resp.statusText) { - msg += ' ' + resp.status + ': ' + resp.statusText; - } - break; - } - case Ext.form.action.Action.LOAD_FAILURE: - case Ext.form.action.Action.SERVER_INVALID: - msg = Proxmox.Utils.extractRequestError(action.result, true); - break; - } - return msg; - }, - - contentTypes: { - images: gettext('Disk image'), - backup: gettext('Backup'), - vztmpl: gettext('Container template'), - iso: gettext('ISO image'), - rootdir: gettext('Container'), - snippets: gettext('Snippets'), - import: gettext('Import'), - }, - - // volume can be a full volume info object, in which case the format parameter is ignored, or - // you can pass the volume ID and format as separate string parameters. - volume_is_qemu_backup: function (volume, format) { - let volid, subtype; - if (typeof volume === 'string') { - volid = volume; - } else if (typeof volume === 'object') { - ({ volid, format, subtype } = volume); - } else { - console.error('internal error - unexpected type', volume); - } - return format === 'pbs-vm' || volid.match(':backup/vzdump-qemu-') || subtype === 'qemu'; - }, - - volume_is_lxc_backup: function (volume) { - return ( - volume.format === 'pbs-ct' || - volume.volid.match(':backup/vzdump-(lxc|openvz)-') || - volume.subtype === 'lxc' - ); - }, - - authSchema: { - ad: { - name: gettext('Active Directory Server'), - ipanel: 'pveAuthADPanel', - syncipanel: 'pveAuthLDAPSyncPanel', - add: true, - tfa: true, - pwchange: true, - }, - ldap: { - name: gettext('LDAP Server'), - ipanel: 'pveAuthLDAPPanel', - syncipanel: 'pveAuthLDAPSyncPanel', - add: true, - tfa: true, - pwchange: true, - }, - openid: { - name: gettext('OpenID Connect Server'), - ipanel: 'pveAuthOpenIDPanel', - add: true, - tfa: false, - pwchange: false, - iconCls: 'pmx-itype-icon-openid-logo', - }, - pam: { - name: 'Linux PAM', - ipanel: 'pveAuthBasePanel', - add: false, - tfa: true, - pwchange: true, - }, - pve: { - name: 'Proxmox VE authentication server', - ipanel: 'pveAuthBasePanel', - add: false, - tfa: true, - pwchange: true, - }, - }, - - storageSchema: { - dir: { - name: Proxmox.Utils.directoryText, - ipanel: 'DirInputPanel', - faIcon: 'folder', - backups: true, - }, - lvm: { - name: 'LVM', - ipanel: 'LVMInputPanel', - faIcon: 'folder', - backups: false, - }, - lvmthin: { - name: 'LVM-Thin', - ipanel: 'LvmThinInputPanel', - faIcon: 'folder', - backups: false, - }, - btrfs: { - name: 'BTRFS', - ipanel: 'BTRFSInputPanel', - faIcon: 'folder', - backups: true, - }, - nfs: { - name: 'NFS', - ipanel: 'NFSInputPanel', - faIcon: 'building', - backups: true, - }, - cifs: { - name: 'SMB/CIFS', - ipanel: 'CIFSInputPanel', - faIcon: 'building', - backups: true, - }, - iscsi: { - name: 'iSCSI', - ipanel: 'IScsiInputPanel', - faIcon: 'building', - backups: false, - }, - cephfs: { - name: 'CephFS', - ipanel: 'CephFSInputPanel', - faIcon: 'building', - backups: true, - }, - pvecephfs: { - name: 'CephFS (PVE)', - ipanel: 'CephFSInputPanel', - hideAdd: true, - faIcon: 'building', - backups: true, - }, - rbd: { - name: 'RBD', - ipanel: 'RBDInputPanel', - faIcon: 'building', - backups: false, - }, - pveceph: { - name: 'RBD (PVE)', - ipanel: 'RBDInputPanel', - hideAdd: true, - faIcon: 'building', - backups: false, - }, - zfs: { - name: 'ZFS over iSCSI', - ipanel: 'ZFSInputPanel', - faIcon: 'building', - backups: false, - }, - zfspool: { - name: 'ZFS', - ipanel: 'ZFSPoolInputPanel', - faIcon: 'folder', - backups: false, - }, - pbs: { - name: 'Proxmox Backup Server', - ipanel: 'PBSInputPanel', - faIcon: 'floppy-o', - backups: true, - }, - drbd: { - name: 'DRBD', - hideAdd: true, - backups: false, - }, - esxi: { - name: 'ESXi', - ipanel: 'ESXIInputPanel', - faIcon: 'cloud-download', - backups: false, - }, - }, - - sdnvnetSchema: { - vnet: { - name: 'vnet', - faIcon: 'folder', - }, - }, - - sdnzoneSchema: { - zone: { - name: 'zone', - hideAdd: true, - }, - simple: { - name: 'Simple', - ipanel: 'SimpleInputPanel', - faIcon: 'th', - }, - vlan: { - name: 'VLAN', - ipanel: 'VlanInputPanel', - faIcon: 'th', - }, - qinq: { - name: 'QinQ', - ipanel: 'QinQInputPanel', - faIcon: 'th', - }, - vxlan: { - name: 'VXLAN', - ipanel: 'VxlanInputPanel', - faIcon: 'th', - }, - evpn: { - name: 'EVPN', - ipanel: 'EvpnInputPanel', - faIcon: 'th', - }, - }, - - sdncontrollerSchema: { - controller: { - name: 'controller', - hideAdd: true, - }, - evpn: { - name: 'evpn', - ipanel: 'EvpnInputPanel', - faIcon: 'crosshairs', - }, - bgp: { - name: 'bgp', - ipanel: 'BgpInputPanel', - faIcon: 'crosshairs', - }, - isis: { - name: 'isis', - ipanel: 'IsisInputPanel', - faIcon: 'crosshairs', - }, - }, - - sdnipamSchema: { - ipam: { - name: 'ipam', - hideAdd: true, - }, - pve: { - name: 'PVE', - ipanel: 'PVEIpamInputPanel', - faIcon: 'th', - hideAdd: true, - }, - netbox: { - name: 'Netbox', - ipanel: 'NetboxInputPanel', - faIcon: 'th', - }, - phpipam: { - name: 'PhpIpam', - ipanel: 'PhpIpamInputPanel', - faIcon: 'th', - }, - }, - - sdndnsSchema: { - dns: { - name: 'dns', - hideAdd: true, - }, - powerdns: { - name: 'powerdns', - ipanel: 'PowerdnsInputPanel', - faIcon: 'th', - }, - }, - - format_sdnvnet_type: function (value, md, record) { - var schema = PVE.Utils.sdnvnetSchema[value]; - if (schema) { - return schema.name; - } - return Proxmox.Utils.unknownText; - }, - - format_sdnzone_type: function (value, md, record) { - var schema = PVE.Utils.sdnzoneSchema[value]; - if (schema) { - return schema.name; - } - return Proxmox.Utils.unknownText; - }, - - format_sdncontroller_type: function (value, md, record) { - var schema = PVE.Utils.sdncontrollerSchema[value]; - if (schema) { - return schema.name; - } - return Proxmox.Utils.unknownText; - }, - - format_sdnipam_type: function (value, md, record) { - var schema = PVE.Utils.sdnipamSchema[value]; - if (schema) { - return schema.name; - } - return Proxmox.Utils.unknownText; - }, - - format_sdndns_type: function (value, md, record) { - var schema = PVE.Utils.sdndnsSchema[value]; - if (schema) { - return schema.name; - } - return Proxmox.Utils.unknownText; - }, - - format_storage_type: function (value, md, record) { - if (value === 'rbd') { - value = !record || record.get('monhost') ? 'rbd' : 'pveceph'; - } else if (value === 'cephfs') { - value = !record || record.get('monhost') ? 'cephfs' : 'pvecephfs'; - } - - let schema = PVE.Utils.storageSchema[value]; - return schema?.name ?? value; - }, - - format_ha: function (value) { - var text = Proxmox.Utils.noneText; - - if (value.managed) { - text = value.state || Proxmox.Utils.noneText; - } - - return text; - }, - - format_content_types: function (value) { - return value - .split(',') - .sort() - .map(function (ct) { - return PVE.Utils.contentTypes[ct] || ct; - }) - .join(', '); - }, - - render_storage_content: function (value, metaData, record) { - let data = record.data; - let result; - if (Ext.isNumber(data.channel) && Ext.isNumber(data.id) && Ext.isNumber(data.lun)) { - result = - 'CH ' + - Ext.String.leftPad(data.channel, 2, '0') + - ' ID ' + - data.id + - ' LUN ' + - data.lun; - } else if (data.content === 'import') { - if (data.volid.match(/^.*?:import\//)) { - // dir-based storages - result = data.volid.replace(/^.*?:import\//, ''); - } else { - // esxi storage - result = data.volid.replace(/^.*?:/, ''); - } - } else { - result = data.volid.replace(/^.*?:(.*?\/)?/, ''); - } - return Ext.String.htmlEncode(result); - }, - - render_serverity: function (value) { - return PVE.Utils.log_severity_hash[value] || value; - }, - - calculate_hostcpu: function (data) { - if (!(data.uptime && Ext.isNumeric(data.cpu))) { - return -1; - } - - if (data.type !== 'qemu' && data.type !== 'lxc') { - return -1; - } - - let node = PVE.data.ResourceStore.getNodeById(data.node); - if (!Ext.isDefined(node) || node === null) { - return -1; - } - var maxcpu = node.data.maxcpu || 1; - - if (!Ext.isNumeric(maxcpu) && maxcpu >= 1) { - return -1; - } - - return (data.cpu / maxcpu) * data.maxcpu; - }, - - render_hostcpu: function (value, metaData, record, rowIndex, colIndex, store) { - if (!(record.data.uptime && Ext.isNumeric(record.data.cpu))) { - return ''; - } - - if (record.data.type !== 'qemu' && record.data.type !== 'lxc') { - return ''; - } - - let node = PVE.data.ResourceStore.getNodeById(record.data.node); - if (!Ext.isDefined(node) || node === null) { - return ''; - } - var maxcpu = node.data.maxcpu || 1; - - if (!Ext.isNumeric(maxcpu) || maxcpu < 1) { - return ''; - } - - var per = (record.data.cpu / maxcpu) * record.data.maxcpu * 100; - const cpu_label = maxcpu > 1 ? 'CPUs' : 'CPU'; - - return `${per.toFixed(1)}% of ${maxcpu} ${cpu_label}`; - }, - - render_bandwidth: function (value) { - if (!Ext.isNumeric(value)) { - return ''; - } - - return Proxmox.Utils.format_size(value) + '/s'; - }, - - render_timestamp_human_readable: function (value) { - return Ext.Date.format(new Date(value * 1000), 'l d F Y H:i:s'); - }, - - // render a timestamp or pending - render_next_event: function (value) { - if (!value) { - return '-'; - } - let now = new Date(), - next = new Date(value * 1000); - if (next < now) { - return gettext('pending'); - } - return Proxmox.Utils.render_timestamp(value); - }, - - calculate_mem_usage: function (data) { - if (!Ext.isNumeric(data.mem) || data.maxmem === 0 || data.uptime < 1) { - return -1; - } - - return data.mem / data.maxmem; - }, - - calculate_hostmem_usage: function (data) { - if (data.type !== 'qemu' && data.type !== 'lxc') { - return -1; - } - - let node = PVE.data.ResourceStore.getNodeById(data.node); - - if (!Ext.isDefined(node) || node === null) { - return -1; - } - var maxmem = node.data.maxmem || 0; - - if (!Ext.isNumeric(data.mem) || maxmem === 0 || data.uptime < 1) { - return -1; - } - - if (data.type === 'qemu' && Ext.isNumeric(data.memhost)) { - return data.memhost / maxmem; - } - return data.mem / maxmem; - }, - - render_mem_usage_percent: function (value, metaData, record, rowIndex, colIndex, store) { - if (!Ext.isNumeric(value) || value === -1) { - return ''; - } - if (value > 1) { - // we got no percentage but bytes - let mem = value; - let maxmem = record.data.maxmem; - if (!record.data.uptime || maxmem === 0 || !Ext.isNumeric(mem)) { - return ''; - } - - return ((mem * 100) / maxmem).toFixed(1) + ' %'; - } - return (value * 100).toFixed(1) + ' %'; - }, - - render_hostmem_usage_percent: function ( - value, - metaData, - record, - rowIndex, - colIndex, - store, - ) { - if (!Ext.isNumeric(record.data.mem) || value === -1) { - return ''; - } - - if (record.data.type !== 'qemu' && record.data.type !== 'lxc') { - return ''; - } - - let node = PVE.data.ResourceStore.getNodeById(record.data.node); - var maxmem = node.data.maxmem || 0; - - if (record.data.mem > 1) { - // we got no percentage but bytes - let mem = record.data.mem; - if (record.data.type === 'qemu' && Ext.isNumeric(record.data.memhost)) { - mem = record.data.memhost; - } - if (!record.data.uptime || maxmem === 0 || !Ext.isNumeric(mem)) { - return ''; - } - - return ((mem * 100) / maxmem).toFixed(1) + ' %'; - } - return (value * 100).toFixed(1) + ' %'; - }, - - render_mem_usage: function (value, metaData, record, rowIndex, colIndex, store) { - var mem = value; - var maxmem = record.data.maxmem; - - if (!record.data.uptime) { - return ''; - } - - if (!(Ext.isNumeric(mem) && maxmem)) { - return ''; - } - - return Proxmox.Utils.render_size(value); - }, - - calculate_disk_usage: function (data) { - if ( - !Ext.isNumeric(data.disk) || - ((data.type === 'qemu' || data.type === 'lxc') && data.uptime === 0) || - data.maxdisk === 0 - ) { - return -1; - } - - return data.disk / data.maxdisk; - }, - - render_disk_usage_percent: function (value, metaData, record, rowIndex, colIndex, store) { - if (!Ext.isNumeric(value) || value === -1) { - return ''; - } - - return (value * 100).toFixed(1) + ' %'; - }, - - render_disk_usage: function (value, metaData, record, rowIndex, colIndex, store) { - var disk = value; - var maxdisk = record.data.maxdisk; - var type = record.data.type; - - if ( - !Ext.isNumeric(disk) || - maxdisk === 0 || - ((type === 'qemu' || type === 'lxc') && record.data.uptime === 0) - ) { - return ''; - } - - return Proxmox.Utils.render_size(value); - }, - - get_object_icon_class: function (type, record) { - var status = ''; - var objType = type; - - if (type === 'type') { - // for folder view - objType = record.groupbyid; - } else if (record.template) { - // templates - objType = 'template'; - status = type; - } else if (type === 'network') { - const networkTypeMapping = { - fabric: 'fa fa-road', - zone: 'fa fa-th', - }; - - return networkTypeMapping[record['network-type']] ?? ''; - } else if (type === 'storage' && record.content === 'import') { - return 'fa fa-cloud-download'; - } else { - // everything else - status = record.status + ' ha-' + record.hastate; - } - - if (record.lock) { - status += ' locked lock-' + record.lock; - } - - var defaults = PVE.tree.ResourceTree.typeDefaults[objType]; - if (defaults && defaults.iconCls) { - return defaults.iconCls + ' ' + status; - } - - return ''; - }, - - render_resource_type: function (value, metaData, record, rowIndex, colIndex, store) { - var cls = PVE.Utils.get_object_icon_class(value, record.data); - - var fa = ' '; - - if (value === 'network') { - return fa + record.data['network-type']; - } - - return fa + value; - }, - - render_support_level: function (value, metaData, record) { - return PVE.Utils.support_level_hash[value] || '-'; - }, - - render_upid: function (value, metaData, record) { - var type = record.data.type; - var id = record.data.id; - - return Ext.htmlEncode(Proxmox.Utils.format_task_description(type, id)); - }, - - render_optional_url: function (value) { - if (value && value.match(/^https?:\/\//)) { - return '' + value + ''; - } - return value; - }, - - render_san: function (value) { - var names = []; - if (Ext.isArray(value)) { - value.forEach(function (val) { - if (!Ext.isNumber(val)) { - names.push(val); - } - }); - return names.join('
    '); - } - return value; - }, - - render_full_name: function (firstname, metaData, record) { - var first = firstname || ''; - var last = record.data.lastname || ''; - return Ext.htmlEncode(first + ' ' + last); - }, - - // expecting the following format: - // [v2:10.10.10.1:6802/2008,v1:10.10.10.1:6803/2008] - render_ceph_osd_addr: function (value) { - value = value.trim(); - if (value.startsWith('[') && value.endsWith(']')) { - value = value.slice(1, -1); // remove [] - } - value = value.replaceAll(',', '\n'); // split IPs in lines - let retVal = ''; - for (const i of value.matchAll(/^(v[0-9]):(.*):([0-9]*)\/([0-9]*)$/gm)) { - retVal += `${i[1]}: ${i[2]}:${i[3]}
    `; - } - return retVal.length < 1 ? value : retVal; - }, - - windowHostname: function () { - return window.location.hostname.replace( - Proxmox.Utils.IP6_bracket_match, - function (m, addr, offset, original) { - return addr; - }, - ); - }, - - openDefaultConsoleWindow: function (consoles, consoleType, vmid, nodename, vmname, cmd) { - var dv = PVE.Utils.defaultViewer(consoles, consoleType); - PVE.Utils.openConsoleWindow(dv, consoleType, vmid, nodename, vmname, cmd); - }, - - openConsoleWindow: function (viewer, consoleType, vmid, nodename, vmname, cmd) { - if (vmid === undefined && (consoleType === 'kvm' || consoleType === 'lxc')) { - throw 'missing vmid'; - } - if (!nodename) { - throw 'no nodename specified'; - } - - if (viewer === 'html5') { - PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd); - } else if (viewer === 'xtermjs') { - Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd); - } else if (viewer === 'vv') { - let url = '/nodes/' + nodename + '/spiceshell'; - let params = { - proxy: PVE.Utils.windowHostname(), - }; - if (consoleType === 'kvm') { - url = '/nodes/' + nodename + '/qemu/' + vmid.toString() + '/spiceproxy'; - } else if (consoleType === 'lxc') { - url = '/nodes/' + nodename + '/lxc/' + vmid.toString() + '/spiceproxy'; - } else if (consoleType === 'upgrade') { - params.cmd = 'upgrade'; - } else if (consoleType === 'cmd') { - params.cmd = cmd; - } else if (consoleType !== 'shell') { - throw `unknown spice viewer type '${consoleType}'`; - } - PVE.Utils.openSpiceViewer(url, params); - } else { - throw `unknown viewer type '${viewer}'`; - } - }, - - defaultViewer: function (consoles, type) { - var allowSpice, allowXtermjs; - - if (consoles === true) { - allowSpice = true; - allowXtermjs = true; - } else if (typeof consoles === 'object') { - allowSpice = consoles.spice; - allowXtermjs = !!consoles.xtermjs; - } - let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs'); - if (dv === 'vv' && !allowSpice) { - dv = allowXtermjs ? 'xtermjs' : 'html5'; - } else if (dv === 'xtermjs' && !allowXtermjs) { - dv = allowSpice ? 'vv' : 'html5'; - } - - return dv; - }, - - openVNCViewer: function (vmtype, vmid, nodename, vmname, cmd) { - let scaling = 'off'; - if (Proxmox.Utils.toolkit !== 'touch') { - let sp = Ext.state.Manager.getProvider(); - scaling = sp.get('novnc-scaling', 'off'); - } - var url = Ext.Object.toQueryString({ - console: vmtype, // kvm, lxc, upgrade or shell - novnc: 1, - vmid: vmid, - vmname: vmname, - node: nodename, - resize: scaling, - cmd: cmd, - }); - var nw = window.open('?' + url, '_blank', 'innerWidth=745,innerheight=427'); - if (nw) { - nw.focus(); - } - }, - - openSpiceViewer: function (url, params) { - var downloadWithName = function (uri, name) { - var link = Ext.DomHelper.append(document.body, { - tag: 'a', - href: uri, - css: 'display:none;visibility:hidden;height:0px;', - }); - - // Note: we need to tell Android, AppleWebKit and Chrome - // the correct file name extension - // but we do not set 'download' tag for other environments, because - // It can have strange side effects (additional user prompt on firefox) - if (navigator.userAgent.match(/Android|AppleWebKit|Chrome/i)) { - link.download = name; - } - - if (link.fireEvent) { - link.fireEvent('onclick'); - } else { - let evt = document.createEvent('MouseEvents'); - evt.initMouseEvent( - 'click', - true, - true, - window, - 1, - 0, - 0, - 0, - 0, - false, - false, - false, - false, - 0, - null, - ); - link.dispatchEvent(evt); - } - }; - - Proxmox.Utils.API2Request({ - url: url, - params: params, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - success: function (response, opts) { - let cfg = response.result.data; - let raw = Object.entries(cfg).reduce( - (acc, [k, v]) => acc + `${k}=${v}\n`, - '[virt-viewer]\n', - ); - let spiceDownload = - 'data:application/x-virt-viewer;charset=UTF-8,' + encodeURIComponent(raw); - downloadWithName(spiceDownload, 'pve-spice.vv'); - }, - }); - }, - - openTreeConsole: function (tree, record, item, index, e) { - e.stopEvent(); - let nodename = record.data.node; - let vmid = record.data.vmid; - let vmname = record.data.name; - if (record.data.type === 'qemu' && !record.data.template) { - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/qemu/${vmid}/status/current`, - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - success: function (response, opts) { - let conf = response.result.data; - let consoles = { - spice: !!conf.spice, - xtermjs: !!conf.serial, - }; - PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname); - }, - }); - } else if (record.data.type === 'lxc' && !record.data.template) { - PVE.Utils.openDefaultConsoleWindow(true, 'lxc', vmid, nodename, vmname); - } - }, - - // test automation helper - call_menu_handler: function (menu, text) { - let item = menu.query('menuitem').find((el) => el.text === text); - if (item && item.handler) { - item.handler(); - } - }, - - createCmdMenu: function (v, record, item, index, event) { - event.stopEvent(); - if (!(v instanceof Ext.tree.View)) { - v.select(record); - } - let menu; - let type = record.data.type; - - if (record.data.template) { - if (type === 'qemu' || type === 'lxc') { - menu = Ext.create('PVE.menu.TemplateMenu', { - pveSelNode: record, - }); - } - } else if (type === 'qemu' || type === 'lxc' || type === 'node') { - menu = Ext.create('PVE.' + type + '.CmdMenu', { - pveSelNode: record, - nodename: record.data.node, - }); - } else if (type === 'tag') { - menu = Ext.create('PVE.dc.TagCmdMenu', { - tag: record.data.tag, - }); - } else if (record?.isRoot()) { - menu = Ext.create('PVE.dc.CmdMenu', { - pveSelNode: record, - nodename: record.data.node, - }); - } else { - return undefined; - } - - menu.showAt(event.getXY()); - return menu; - }, - - // helper for deleting field which are set to there default values - delete_if_default: function (values, fieldname, default_val, create) { - if (values[fieldname] === '' || values[fieldname] === default_val) { - if (!create) { - if (values.delete) { - if (Ext.isArray(values.delete)) { - values.delete.push(fieldname); - } else { - values.delete += ',' + fieldname; - } - } else { - values.delete = fieldname; - } - } - - delete values[fieldname]; - } - }, - - loadSSHKeyFromFile: function (file, callback) { - // ssh-keygen produces ~ 740 bytes for a 4096 bit RSA key, current max is 16 kbit, so assume: - // 740 * 8 for max. 32kbit (5920 bytes), round upwards to 8192 bytes, leaves lots of comment space - PVE.Utils.loadFile(file, callback, 8192); - }, - - loadFile: function (file, callback, maxSize) { - maxSize = maxSize || 32 * 1024; - if (file.size > maxSize) { - Ext.Msg.alert( - gettext('Error'), - `${gettext('Invalid file size')}: ${file.size} > ${maxSize}`, - ); - return; - } - let reader = new FileReader(); - reader.onload = (evt) => callback(evt.target.result); - reader.readAsText(file); - }, - - loadTextFromFile: function (file, callback, maxBytes) { - let maxSize = maxBytes || 8192; - if (file.size > maxSize) { - Ext.Msg.alert(gettext('Error'), gettext('Invalid file size: ') + file.size); - return; - } - let reader = new FileReader(); - reader.onload = (evt) => callback(evt.target.result); - reader.readAsText(file); - }, - - diskControllerMaxIDs: { - ide: 4, - sata: 6, - scsi: 31, - virtio: 16, - unused: 256, - }, - - // types is either undefined (all busses), an array of busses, or a single bus - forEachBus: function (types, func) { - let busses = Object.keys(PVE.Utils.diskControllerMaxIDs); - - if (Ext.isArray(types)) { - busses = types; - } else if (Ext.isDefined(types)) { - busses = [types]; - } - - // check if we only have valid busses - for (let i = 0; i < busses.length; i++) { - if (!PVE.Utils.diskControllerMaxIDs[busses[i]]) { - throw "invalid bus: '" + busses[i] + "'"; - } - } - - for (let i = 0; i < busses.length; i++) { - let count = PVE.Utils.diskControllerMaxIDs[busses[i]]; - for (let j = 0; j < count; j++) { - let cont = func(busses[i], j); - if (!cont && cont !== undefined) { - return; - } - } - } - }, - - lxc_mp_counts: { - mp: 256, - unused: 256, - }, - - forEachLxcMP: function (func, includeUnused) { - for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) { - let cont = func('mp', i, `mp${i}`); - if (!cont && cont !== undefined) { - return; - } - } - - if (!includeUnused) { - return; - } - - for (let i = 0; i < PVE.Utils.lxc_mp_counts.unused; i++) { - let cont = func('unused', i, `unused${i}`); - if (!cont && cont !== undefined) { - return; - } - } - }, - - lxc_dev_count: 256, - - forEachLxcDev: function (func) { - for (let i = 0; i < PVE.Utils.lxc_dev_count; i++) { - let cont = func(i, `dev${i}`); - if (!cont && cont !== undefined) { - return; - } - } - }, - - hardware_counts: { - net: 32, - usb: 14, - usb_old: 5, - hostpci: 16, - audio: 1, - efidisk: 1, - serial: 4, - rng: 1, - tpmstate: 1, - virtiofs: 10, - }, - - // we can have usb6 and up only for specific machine/ostypes - get_max_usb_count: function (ostype, machine) { - if (!ostype) { - return PVE.Utils.hardware_counts.usb_old; - } - - let match = /-(\d+).(\d+)/.exec(machine ?? ''); - if (!match || PVE.Utils.qemu_min_version([match[1], match[2]], [7, 1])) { - if (ostype === 'l26') { - return PVE.Utils.hardware_counts.usb; - } - let os_match = /^win(\d+)$/.exec(ostype); - if (os_match && os_match[1] > 7) { - return PVE.Utils.hardware_counts.usb; - } - } - - return PVE.Utils.hardware_counts.usb_old; - }, - - // parameters are expected to be arrays, e.g. [7,1], [4,0,1] - // returns true if toCheck is equal or greater than minVersion - qemu_min_version: function (toCheck, minVersion) { - let i; - for (i = 0; i < toCheck.length && i < minVersion.length; i++) { - if (toCheck[i] < minVersion[i]) { - return false; - } - } - - if (minVersion.length > toCheck.length) { - for (; i < minVersion.length; i++) { - if (minVersion[i] !== 0) { - return false; - } - } - } - - return true; - }, - - cleanEmptyObjectKeys: function (obj) { - for (const propName of Object.keys(obj)) { - if (obj[propName] === null || obj[propName] === undefined) { - delete obj[propName]; - } - } - }, - - acmedomain_count: 5, - - add_domain_to_acme: function (acme, domain) { - if (acme.domains === undefined) { - acme.domains = [domain]; - } else { - acme.domains.push(domain); - acme.domains = acme.domains.filter( - (value, index, self) => self.indexOf(value) === index, - ); - } - return acme; - }, - - remove_domain_from_acme: function (acme, domain) { - if (acme.domains !== undefined) { - acme.domains = acme.domains.filter( - (value, index, self) => self.indexOf(value) === index && value !== domain, - ); - } - return acme; - }, - - handleStoreErrorOrMask: function (view, store, regex, callback) { - view.mon(store, 'load', function (proxy, response, success, operation) { - if (success) { - Proxmox.Utils.setErrorMask(view, false); - return; - } - let msg; - if (operation.error.statusText) { - if (operation.error.statusText.match(regex)) { - callback(view, operation.error); - return; - } else { - msg = operation.error.statusText + ' (' + operation.error.status + ')'; - } - } else { - msg = gettext('Connection error'); - } - Proxmox.Utils.setErrorMask(view, Ext.htmlEncode(msg)); - }); - }, - - showCephInstallOrMask: function (container, msg, nodename, callback) { - if (msg.match(/not (installed|initialized)/i)) { - if (Proxmox.UserName === 'root@pam') { - container.el.mask(); - if (!container.down('pveCephInstallWindow')) { - let isInstalled = !!msg.match(/not initialized/i); - let win = Ext.create('PVE.ceph.Install', { - nodename: nodename, - }); - win.getViewModel().set('isInstalled', isInstalled); - container.add(win); - win.on('close', () => { - container.el.unmask(); - }); - win.show(); - callback(win); - } - } else { - container.mask( - Ext.String.format( - gettext('{0} not installed.') + - ' ' + - gettext('Log in as root to install.'), - 'Ceph', - ), - ['pve-static-mask'], - ); - } - return true; - } else { - return false; - } - }, - - monitor_ceph_installed: function (view, rstore, nodename, maskOwnerCt) { - PVE.Utils.handleStoreErrorOrMask( - view, - rstore, - /not (installed|initialized)/i, - (_, error) => { - nodename = nodename || Proxmox.NodeName; - let maskTarget = maskOwnerCt ? view.ownerCt : view; - rstore.stopUpdate(); - PVE.Utils.showCephInstallOrMask( - maskTarget, - error.statusText, - nodename, - (win) => { - view.mon(win, 'cephInstallWindowClosed', () => rstore.startUpdate()); - }, - ); - }, - ); - }, - - propertyStringSet: function (target, source, name, value) { - if (source) { - if (value === undefined) { - target[name] = source; - } else { - target[name] = value; - } - } else { - delete target[name]; - } - }, - - forEachCorosyncLink: function (nodeinfo, cb) { - let re = /(?:ring|link)(\d+)_addr/; - Ext.iterate(nodeinfo, (prop, val) => { - let match = re.exec(prop); - if (match) { - cb(Number(match[1]), val); - } - }); - }, - - cpu_vendor_map: { - default: 'QEMU', - AuthenticAMD: 'AMD', - GenuineIntel: 'Intel', - }, - - cpu_vendor_order: { - AMD: 1, - Intel: 2, - QEMU: 3, - Host: 4, - _default_: 5, // includes custom models - }, - - verify_ip64_address_list: function (value, with_suffix) { - for (let addr of value.split(/[ ,;]+/)) { - if (addr === '') { - continue; - } - - if (with_suffix) { - let parts = addr.split('%'); - addr = parts[0]; - - if (parts.length > 2) { - return false; - } - - if (parts.length > 1 && !addr.startsWith('fe80:')) { - return false; - } - } - - if (!Proxmox.Utils.IP64_match.test(addr)) { - return false; - } - } - - return true; - }, - - sortByPreviousUsage: function (vmconfig, controllerList) { - if (!controllerList) { - controllerList = ['ide', 'virtio', 'scsi', 'sata']; - } - let usedControllers = {}; - for (const type of Object.keys(PVE.Utils.diskControllerMaxIDs)) { - usedControllers[type] = 0; - } - - for (const property of Object.keys(vmconfig)) { - if ( - property.match(PVE.Utils.bus_match) && - !vmconfig[property].match(/media=cdrom/) - ) { - const foundController = property.match(PVE.Utils.bus_match)[1]; - usedControllers[foundController]++; - } - } - - let sortPriority = PVE.qemu.OSDefaults.getDefaults(vmconfig.ostype).busPriority; - - let sortedList = Ext.clone(controllerList); - sortedList.sort(function (a, b) { - if (usedControllers[b] === usedControllers[a]) { - return sortPriority[b] - sortPriority[a]; - } - return usedControllers[b] - usedControllers[a]; - }); - - return sortedList; - }, - - nextFreeDisk: function (controllers, config) { - for (const controller of controllers) { - for (let i = 0; i < PVE.Utils.diskControllerMaxIDs[controller]; i++) { - let confid = controller + i.toString(); - if (!Ext.isDefined(config[confid])) { - return { - controller, - id: i, - confid, - }; - } - } - } - - return undefined; - }, - - nextFreeLxcMP: function (type, config) { - for (let i = 0; i < PVE.Utils.lxc_mp_counts[type]; i++) { - let confid = `${type}${i}`; - if (!Ext.isDefined(config[confid])) { - return { - type, - id: i, - confid, - }; - } - } - - return undefined; - }, - - escapeNotesTemplate: function (value) { - let replace = { - '\\': '\\\\', - '\n': '\\n', - }; - return value.replace(/(\\|[\n])/g, (match) => replace[match]); - }, - - unEscapeNotesTemplate: function (value) { - let replace = { - '\\\\': '\\', - '\\n': '\n', - }; - return value.replace(/(\\\\|\\n)/g, (match) => replace[match]); - }, - - notesTemplateVars: ['cluster', 'guestname', 'node', 'vmid'], - - renderTags: function (tagstext, overrides) { - let text = ''; - if (tagstext) { - let tags = (tagstext.split(/[,; ]/) || []).filter((t) => !!t); - if (PVE.UIOptions.shouldSortTags()) { - tags = tags.sort((a, b) => { - let alc = a.toLowerCase(); - let blc = b.toLowerCase(); - return alc < blc ? -1 : blc < alc ? 1 : a.localeCompare(b); - }); - } - text += ' '; - tags.forEach((tag) => { - text += Proxmox.Utils.getTagElement(tag, overrides); - }); - } - return text; - }, - - tagCharRegex: /^[a-z0-9+_.-]+$/i, - - verificationStateOrder: { - failed: 0, - none: 1, - ok: 2, - __default__: 3, - }, - - isStandaloneNode: function () { - return PVE.data.ResourceStore.getNodes().length < 2; - }, - - // main use case of this helper is the login window - getUiLanguage: function () { - let languageCookie = Ext.util.Cookies.get('PVELangCookie'); - if (languageCookie === 'kr') { - // fix-up 'kr' being used for Korean by mistake FIXME: remove with PVE 9 - let dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10); - languageCookie = 'ko'; - Ext.util.Cookies.set('PVELangCookie', languageCookie, dt); - } - return languageCookie || Proxmox.defaultLang || 'en'; - }, - - getFormattedGuestIdentifier: function (vmid, guestName) { - if (PVE.UIOptions.getTreeSortingValue('sort-field') === 'vmid') { - return guestName ? `${vmid} (${guestName})` : vmid; - } else { - return guestName ? `${guestName} (${vmid})` : vmid; - } - }, - - formatGuestTaskConfirmation: function (taskType, vmid, guestName) { - let description = Proxmox.Utils.format_task_description( - taskType, - this.getFormattedGuestIdentifier(vmid, guestName), - ); - return Ext.htmlEncode(description); - }, - }, - - singleton: true, - constructor: function () { - var me = this; - Ext.apply(me, me.utilities); - - Proxmox.Utils.override_task_descriptions({ - acmedeactivate: ['ACME Account', gettext('Deactivate')], - acmenewcert: ['SRV', gettext('Order Certificate')], - acmerefresh: ['ACME Account', gettext('Refresh')], - acmeregister: ['ACME Account', gettext('Register')], - acmerenew: ['SRV', gettext('Renew Certificate')], - acmerevoke: ['SRV', gettext('Revoke Certificate')], - acmeupdate: ['ACME Account', gettext('Update')], - 'auth-realm-sync': [gettext('Realm'), gettext('Sync')], - 'auth-realm-sync-test': [gettext('Realm'), gettext('Sync Preview')], - 'bulk-migrate': ['', gettext('Bulk migrate VMs and Containers')], - 'bulk-start': ['', gettext('Bulk start VMs and Containers')], - 'bulk-shutdown': ['', gettext('Bulk shutdown VMs and Containers')], - 'bulk-suspend': ['', gettext('Bulk shutdown VMs and Containers')], - cephcreatemds: ['Ceph Metadata Server', gettext('Create')], - cephcreatemgr: ['Ceph Manager', gettext('Create')], - cephcreatemon: ['Ceph Monitor', gettext('Create')], - cephcreateosd: ['Ceph OSD', gettext('Create')], - cephcreatepool: ['Ceph Pool', gettext('Create')], - cephdestroymds: ['Ceph Metadata Server', gettext('Destroy')], - cephdestroymgr: ['Ceph Manager', gettext('Destroy')], - cephdestroymon: ['Ceph Monitor', gettext('Destroy')], - cephdestroyosd: ['Ceph OSD', gettext('Destroy')], - cephdestroypool: ['Ceph Pool', gettext('Destroy')], - cephdestroyfs: ['CephFS', gettext('Destroy')], - cephfscreate: ['CephFS', gettext('Create')], - cephsetpool: ['Ceph Pool', gettext('Edit')], - cephsetflags: ['', gettext('Change global Ceph flags')], - clustercreate: ['', gettext('Create Cluster')], - clusterjoin: ['', gettext('Join Cluster')], - dircreate: [gettext('Directory Storage'), gettext('Create')], - dirremove: [gettext('Directory'), gettext('Remove')], - download: [gettext('File'), gettext('Download')], - hamigrate: ['HA', gettext('Migrate')], - hashutdown: ['HA', gettext('Shutdown')], - hastart: ['HA', gettext('Start')], - hastop: ['HA', gettext('Stop')], - imgcopy: ['', gettext('Copy data')], - imgdel: ['', gettext('Erase data')], - lvmcreate: [gettext('LVM Storage'), gettext('Create')], - lvmremove: ['Volume Group', gettext('Remove')], - lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')], - lvmthinremove: ['Thinpool', gettext('Remove')], - migrateall: ['', gettext('Bulk migrate VMs and Containers')], - move_volume: ['CT', gettext('Move Volume')], - 'pbs-download': ['VM/CT', gettext('File Restore Download')], - pull_file: ['CT', gettext('Pull file')], - push_file: ['CT', gettext('Push file')], - qmclone: ['VM', gettext('Clone')], - qmconfig: ['VM', gettext('Configure')], - qmcreate: ['VM', gettext('Create')], - qmdelsnapshot: ['VM', gettext('Delete Snapshot')], - qmdestroy: ['VM', gettext('Destroy')], - qmigrate: ['VM', gettext('Migrate')], - qmmove: ['VM', gettext('Move disk')], - qmpause: ['VM', gettext('Pause')], - qmreboot: ['VM', gettext('Reboot')], - qmreset: ['VM', gettext('Reset')], - qmrestore: ['VM', gettext('Restore')], - qmresume: ['VM', gettext('Resume')], - qmrollback: ['VM', gettext('Rollback')], - qmshutdown: ['VM', gettext('Shutdown')], - qmsnapshot: ['VM', gettext('Snapshot')], - qmstart: ['VM', gettext('Start')], - qmstop: ['VM', gettext('Stop')], - qmsuspend: ['VM', gettext('Hibernate')], - qmtemplate: ['VM', gettext('Convert to template')], - resize: ['VM/CT', gettext('Resize')], - reloadnetworkall: ['', gettext('Reload network configuration on all nodes')], - spiceproxy: ['VM/CT', gettext('Console') + ' (Spice)'], - spiceshell: ['', gettext('Shell') + ' (Spice)'], - startall: ['', gettext('Bulk start VMs and Containers')], - stopall: ['', gettext('Bulk shutdown VMs and Containers')], - suspendall: ['', gettext('Suspend all VMs')], - unknownimgdel: ['', gettext('Destroy image from unknown guest')], - wipedisk: ['Device', gettext('Wipe Disk')], - vncproxy: ['VM/CT', gettext('Console')], - vncshell: ['', gettext('Shell')], - vzclone: ['CT', gettext('Clone')], - vzcreate: ['CT', gettext('Create')], - vzdelsnapshot: ['CT', gettext('Delete Snapshot')], - vzdestroy: ['CT', gettext('Destroy')], - vzdump: (type, id) => - id ? `VM/CT ${id} - ${gettext('Backup')}` : gettext('Backup Job'), - vzmigrate: ['CT', gettext('Migrate')], - vzmount: ['CT', gettext('Mount')], - vzreboot: ['CT', gettext('Reboot')], - vzrestore: ['CT', gettext('Restore')], - vzresume: ['CT', gettext('Resume')], - vzrollback: ['CT', gettext('Rollback')], - vzshutdown: ['CT', gettext('Shutdown')], - vzsnapshot: ['CT', gettext('Snapshot')], - vzstart: ['CT', gettext('Start')], - vzstop: ['CT', gettext('Stop')], - vzsuspend: ['CT', gettext('Suspend')], - vztemplate: ['CT', gettext('Convert to template')], - vzumount: ['CT', gettext('Unmount')], - zfscreate: [gettext('ZFS Storage'), gettext('Create')], - zfsremove: ['ZFS Pool', gettext('Remove')], - }); - - Proxmox.Utils.overrideNotificationFieldName({ - 'job-id': gettext('Job ID'), - }); - - Proxmox.Utils.overrideNotificationFieldValue({ - 'package-updates': gettext('Package updates are available'), - vzdump: gettext('Backup notifications'), - replication: gettext('Replication job notifications'), - fencing: gettext('Node fencing notifications'), - }); - }, -}); -Ext.define('PVE.UIOptions', { - singleton: true, - - options: { - 'allowed-tags': [], - }, - - update: function () { - Proxmox.Utils.API2Request({ - url: '/cluster/options', - method: 'GET', - success: function (response) { - for (const option of ['allowed-tags', 'console', 'tag-style']) { - PVE.UIOptions.options[option] = response?.result?.data?.[option]; - } - - PVE.UIOptions.updateTagList(PVE.UIOptions.options['allowed-tags']); - PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']); - PVE.UIOptions.fireUIConfigChanged(); - }, - }); - }, - - tagList: [], - - updateTagList: function (tags) { - PVE.UIOptions.tagList = [...new Set([...tags])].sort(); - }, - - parseTagOverrides: function (overrides) { - let colors = {}; - (overrides || '').split(';').forEach((color) => { - if (!color) { - return; - } - let [tag, color_hex, font_hex] = color.split(':'); - let r = parseInt(color_hex.slice(0, 2), 16); - let g = parseInt(color_hex.slice(2, 4), 16); - let b = parseInt(color_hex.slice(4, 6), 16); - colors[tag] = [r, g, b]; - if (font_hex) { - colors[tag].push(parseInt(font_hex.slice(0, 2), 16)); - colors[tag].push(parseInt(font_hex.slice(2, 4), 16)); - colors[tag].push(parseInt(font_hex.slice(4, 6), 16)); - } - }); - return colors; - }, - - tagOverrides: {}, - - updateTagOverrides: function (colors) { - let sp = Ext.state.Manager.getProvider(); - let color_state = sp.get('colors', ''); - let browser_colors = PVE.UIOptions.parseTagOverrides(color_state); - PVE.UIOptions.tagOverrides = Ext.apply({}, browser_colors, colors); - }, - - updateTagSettings: function (style) { - let overrides = style?.['color-map']; - PVE.UIOptions.updateTagOverrides(PVE.UIOptions.parseTagOverrides(overrides ?? '')); - - let shape = style?.shape ?? 'circle'; - if (shape === '__default__') { - style = 'circle'; - } - - Ext.ComponentQuery.query('pveResourceTree')[0].setUserCls(`proxmox-tags-${shape}`); - }, - - tagTreeStyles: { - __default__: `${Proxmox.Utils.defaultText} (${gettext('Circle')})`, - full: gettext('Full'), - circle: gettext('Circle'), - dense: gettext('Dense'), - none: Proxmox.Utils.NoneText, - }, - - tagOrderOptions: { - __default__: `${Proxmox.Utils.defaultText} (${gettext('Alphabetical')})`, - config: gettext('Configuration'), - alphabetical: gettext('Alphabetical'), - }, - - shouldSortTags: function () { - return !(PVE.UIOptions.options['tag-style']?.ordering === 'config'); - }, - - getTreeSortingValue: function (key) { - let localStorage = Ext.state.Manager.getProvider(); - let browserValues = localStorage.get('pve-tree-sorting'); - let defaults = { - 'sort-field': 'vmid', - 'group-templates': true, - 'group-guest-types': true, - }; - - return browserValues?.[key] ?? defaults[key]; - }, - - fireUIConfigChanged: function () { - PVE.data.ResourceStore.refresh(); - Ext.GlobalEvents.fireEvent('loadedUiOptions'); - }, -}); -// ExtJS related things - -Proxmox.Utils.toolkit = 'extjs'; - -// custom PVE specific VTypes -Ext.apply(Ext.form.field.VTypes, { - QemuStartDate: function (v) { - return /^(now|\d{4}-\d{1,2}-\d{1,2}(T\d{1,2}:\d{1,2}:\d{1,2})?)$/.test(v); - }, - QemuStartDateText: gettext('Format') + ': "now" or "2006-06-17T16:01:21" or "2006-06-17"', - IP64AddressList: (v) => PVE.Utils.verify_ip64_address_list(v, false), - IP64AddressWithSuffixList: (v) => PVE.Utils.verify_ip64_address_list(v, true), - IP64AddressListText: gettext('Example') + ': 192.168.1.1,192.168.1.2', - IP64AddressListMask: /[A-Fa-f0-9,:.; ]/, - PciIdText: gettext('Example') + ': 0x8086', - PciId: (v) => /^0x[0-9a-fA-F]{4}$/.test(v), -}); - -Ext.define('PVE.form.field.Display', { - override: 'Ext.form.field.Display', - - setSubmitValue: function (value) { - // do nothing, this is only to allow generalized bindings for the: - // `me.isCreate ? 'textfield' : 'displayfield'` cases we have. - }, -}); -Ext.define('PVE.noVncConsole', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveNoVncConsole', - - nodename: undefined, - vmid: undefined, - cmd: undefined, - - consoleType: undefined, // lxc, kvm, shell, cmd - xtermjs: false, - - layout: 'fit', - border: false, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.consoleType) { - throw 'no console type specified'; - } - - if (!me.vmid && me.consoleType !== 'shell' && me.consoleType !== 'cmd') { - throw 'no VM ID specified'; - } - - // always use same iframe, to avoid running several noVnc clients - // at same time (to avoid performance problems) - var box = Ext.create('Ext.ux.IFrame', { itemid: 'vncconsole', flex: 1 }); - - let warning = Ext.create('Ext.Component', { - userCls: 'pmx-hint', - padding: 5, - hidden: true, - style: { - 'text-align': 'center', - }, - html: gettext( - 'Application container detected - console might not be fully functional.', - ), - }); - - var type = me.xtermjs ? 'xtermjs' : 'novnc'; - Ext.apply(me, { - layout: { - type: 'vbox', - align: 'stretch', - }, - items: [warning, box], - listeners: { - activate: function () { - let sp = Ext.state.Manager.getProvider(); - if (Ext.isFunction(me.beforeLoad)) { - me.beforeLoad(); - } - let queryDict = { - console: me.consoleType, // kvm, lxc, upgrade or shell - vmid: me.vmid, - node: me.nodename, - cmd: me.cmd, - 'cmd-opts': me.cmdOpts, - resize: sp.get('novnc-scaling', 'scale'), - }; - queryDict[type] = 1; - PVE.Utils.cleanEmptyObjectKeys(queryDict); - var url = '/?' + Ext.Object.toQueryString(queryDict); - box.load(url); - }, - }, - }); - - me.callParent(); - - // check for app container - if (me.consoleType === 'lxc') { - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/lxc/${me.vmid}/config`, - success: function (response) { - let consoleMode = response?.result?.data?.cmode; - let entryPoint = response?.result?.data?.entrypoint; - let customEntryPoint = entryPoint !== undefined && entryPoint !== '/sbin/init'; - - if (customEntryPoint && consoleMode === 'console') { - warning.setVisible(true); - setTimeout(() => { - warning.setVisible(false); - }, 8_000); - } - }, - }); - } - - me.on('afterrender', function () { - box.focus(); - }); - }, - - reload: function () { - // reload IFrame content to forcibly reconnect VNC/xterm.js to VM - var box = this.down('[itemid=vncconsole]'); - box.getWin().location.reload(); - }, -}); -Ext.define('PVE.button.ConsoleButton', { - extend: 'Ext.button.Split', - alias: 'widget.pveConsoleButton', - - consoleType: 'shell', // one of 'shell', 'kvm', 'lxc', 'upgrade', 'cmd' - - cmd: undefined, - - consoleName: undefined, - - iconCls: 'fa fa-terminal', - - enableSpice: true, - enableXtermjs: true, - - nodename: undefined, - - vmid: 0, - - text: gettext('Console'), - - setEnableSpice: function (enable) { - var me = this; - - me.enableSpice = enable; - me.down('#spicemenu').setDisabled(!enable); - }, - - setEnableXtermJS: function (enable) { - var me = this; - - me.enableXtermjs = enable; - me.down('#xtermjs').setDisabled(!enable); - }, - - handler: function () { - // main, general, handler - let me = this; - PVE.Utils.openDefaultConsoleWindow( - { - spice: me.enableSpice, - xtermjs: me.enableXtermjs, - }, - me.consoleType, - me.vmid, - me.nodename, - me.consoleName, - me.cmd, - ); - }, - - openConsole: function (types) { - // used by split-menu buttons - let me = this; - PVE.Utils.openConsoleWindow( - types, - me.consoleType, - me.vmid, - me.nodename, - me.consoleName, - me.cmd, - ); - }, - - menu: [ - { - xtype: 'menuitem', - text: 'noVNC', - iconCls: 'pve-itype-icon-novnc', - type: 'html5', - handler: function (button) { - let view = this.up('button'); - view.openConsole(button.type); - }, - }, - { - xterm: 'menuitem', - itemId: 'spicemenu', - text: 'SPICE', - type: 'vv', - iconCls: 'pve-itype-icon-virt-viewer', - handler: function (button) { - let view = this.up('button'); - view.openConsole(button.type); - }, - }, - { - text: 'xterm.js', - itemId: 'xtermjs', - iconCls: 'pve-itype-icon-xtermjs', - type: 'xtermjs', - handler: function (button) { - let view = this.up('button'); - view.openConsole(button.type); - }, - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.callParent(); - }, -}); -Ext.define('PVE.button.PendingRevert', { - extend: 'Proxmox.button.Button', - alias: 'widget.pvePendingRevertButton', - - text: gettext('Revert'), - disabled: true, - config: { - pendingGrid: null, - apiurl: undefined, - }, - - handler: function () { - if (!this.pendingGrid) { - this.pendingGrid = this.up('proxmoxPendingObjectGrid'); - if (!this.pendingGrid) { - throw 'revert button requires a pendingGrid'; - } - } - let view = this.pendingGrid; - - let rec = view.getSelectionModel().getSelection()[0]; - if (!rec) { - return; - } - - let rowdef = view.rows[rec.data.key] || {}; - let keys = rowdef.multiKey || [rec.data.key]; - - Proxmox.Utils.API2Request({ - url: this.apiurl || view.editorConfig.url, - waitMsgTarget: view, - selModel: view.getSelectionModel(), - method: 'PUT', - params: { - revert: keys.join(','), - }, - callback: () => view.reload(), - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - }); - }, -}); -/* Button features: - * - observe selection changes to enable/disable the button using enableFn() - * - pop up confirmation dialog using confirmMsg() - * - * does this for the button and every menu item - */ -Ext.define('PVE.button.Split', { - extend: 'Ext.button.Split', - alias: 'widget.pveSplitButton', - - // the selection model to observe - selModel: undefined, - - // if 'false' handler will not be called (button disabled) - enableFn: function (record) { - // do nothing - }, - - // function(record) or text - confirmMsg: false, - - // take special care in confirm box (select no as default). - dangerous: false, - - handlerWrapper: function (button, event) { - var me = this; - var rec, msg; - if (me.selModel) { - rec = me.selModel.getSelection()[0]; - if (!rec || me.enableFn(rec) === false) { - return; - } - } - - if (me.confirmMsg) { - msg = me.confirmMsg; - // confirMsg can be boolean or function - if (Ext.isFunction(me.confirmMsg)) { - msg = me.confirmMsg(rec); - } - Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1; - Ext.Msg.show({ - title: gettext('Confirm'), - icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION, - msg: msg, - buttons: Ext.Msg.YESNO, - callback: function (btn) { - if (btn !== 'yes') { - return; - } - me.realHandler(button, event, rec); - }, - }); - } else { - me.realHandler(button, event, rec); - } - }, - - initComponent: function () { - var me = this; - - if (me.handler) { - me.realHandler = me.handler; - me.handler = me.handlerWrapper; - } - - if (me.menu && me.menu.items) { - me.menu.items.forEach(function (item) { - if (item.handler) { - item.realHandler = item.handler; - item.handler = me.handlerWrapper; - } - - if (item.selModel) { - me.mon(item.selModel, 'selectionchange', function () { - var rec = item.selModel.getSelection()[0]; - if (!rec || item.enableFn(rec) === false) { - item.setDisabled(true); - } else { - item.setDisabled(false); - } - }); - } - }); - } - - me.callParent(); - - if (me.selModel) { - me.mon(me.selModel, 'selectionchange', function () { - var rec = me.selModel.getSelection()[0]; - if (!rec || me.enableFn(rec) === false) { - me.setDisabled(true); - } else { - me.setDisabled(false); - } - }); - } - }, -}); -Ext.define('PVE.controller.StorageEdit', { - extend: 'Ext.app.ViewController', - alias: 'controller.storageEdit', - control: { - 'field[name=content]': { - change: function (field, value) { - const hasImages = Ext.Array.contains(value, 'images'); - const prealloc = field.up('form').getForm().findField('preallocation'); - if (prealloc) { - prealloc.setDisabled(!hasImages); - } - }, - }, - }, -}); -Ext.define('PVE.data.PermPathStore', { - extend: 'Ext.data.Store', - alias: 'store.pvePermPath', - fields: ['value'], - autoLoad: false, - data: [ - { value: '/' }, - { value: '/access' }, - { value: '/access/groups' }, - { value: '/access/realm' }, - { value: '/mapping' }, - { value: '/mapping/hwrng' }, - { value: '/mapping/notifications' }, - { value: '/mapping/pci' }, - { value: '/mapping/usb' }, - { value: '/nodes' }, - { value: '/pool' }, - { value: '/sdn/fabrics' }, - { value: '/sdn/zones' }, - { value: '/storage' }, - { value: '/vms' }, - ], - - constructor: function (config) { - var me = this; - - config = config || {}; - - me.callParent([config]); - - let donePaths = {}; - me.suspendEvents(); - PVE.data.ResourceStore.each(function (record) { - let path; - switch (record.get('type')) { - case 'node': - path = '/nodes/' + record.get('text'); - break; - case 'qemu': - path = '/vms/' + record.get('vmid'); - break; - case 'lxc': - path = '/vms/' + record.get('vmid'); - break; - case 'sdn': - path = '/sdn/zones/' + record.get('sdn'); - break; - case 'storage': - path = '/storage/' + record.get('storage'); - break; - case 'pool': - path = '/pool/' + record.get('pool'); - break; - } - if (path !== undefined && !donePaths[path]) { - me.add({ value: path }); - donePaths[path] = 1; - } - }); - me.resumeEvents(); - - me.fireEvent('refresh', me); - me.fireEvent('datachanged', me); - - me.sort({ - property: 'value', - direction: 'ASC', - }); - }, -}); -Ext.define('PVE.data.ResourceStore', { - extend: 'Proxmox.data.UpdateStore', - singleton: true, - - nodeCache: {}, - - findVMID: function (vmid) { - let me = this; - return me.findExact('vmid', parseInt(vmid, 10)) >= 0; - }, - - // returns the cached data from all nodes - getNodes: function () { - let me = this; - - let nodes = []; - me.each(function (record) { - if (record.get('type') === 'node') { - nodes.push(record.getData()); - } - }); - - return nodes; - }, - - getNodeById: function (id) { - let me = this; - - if (!me.nodeCache[id]) { - let idx = me.findExact('id', `node/${id}`); - me.nodeCache[id] = me.getAt(idx); - } - - return me.nodeCache[id]; - }, - - clearCache: function () { - let me = this; - me.nodeCache = {}; - }, - - storageIsShared: function (storage_path) { - let me = this; - - let index = me.findExact('id', storage_path); - if (index >= 0) { - return me.getAt(index).data.shared; - } else { - return undefined; - } - }, - - guestNode: function (vmid) { - let me = this; - - let index = me.findExact('vmid', parseInt(vmid, 10)); - - return me.getAt(index).data.node; - }, - - guestName: function (vmid) { - let me = this; - let index = me.findExact('vmid', parseInt(vmid, 10)); - if (index < 0) { - return '-'; - } - let rec = me.getAt(index).data; - if ('name' in rec) { - return rec.name; - } - return ''; - }, - - refresh: function () { - let me = this; - // can only refresh if we're loaded at least once and are not currently loading - if (!me.isLoading() && me.isLoaded()) { - let records = (me.getData().getSource() || me.getData()).getRange(); - me.fireEvent('load', me, records); - } - }, - - constructor: function (config) { - let me = this; - - config = config || {}; - - let field_defaults = { - type: { - header: gettext('Type'), - type: 'string', - renderer: PVE.Utils.render_resource_type, - sortable: true, - hideable: false, - width: 100, - }, - id: { - header: 'ID', - type: 'string', - hidden: true, - sortable: true, - width: 80, - }, - running: { - header: gettext('Online'), - type: 'boolean', - renderer: Proxmox.Utils.format_boolean, - hidden: true, - convert: function (value, record) { - var info = record.data; - return Ext.isNumeric(info.uptime) && info.uptime > 0; - }, - }, - text: { - header: gettext('Description'), - type: 'string', - sortable: true, - width: 200, - convert: function (value, record) { - if (value) { - return value; - } - - let info = record.data, - text; - if (Ext.isNumeric(info.vmid) && info.vmid > 0) { - text = String(info.vmid); - if (info.name) { - text += ' (' + info.name + ')'; - } - } else { - // node, pool, storage - text = info[info.type] || info.id; - if (info.node && info.type !== 'node') { - text += ' (' + info.node + ')'; - } - } - - return text; - }, - }, - vmid: { - header: 'VMID', - type: 'integer', - hidden: true, - sortable: true, - width: 80, - }, - name: { - header: gettext('Name'), - hidden: true, - sortable: true, - type: 'string', - }, - disk: { - header: gettext('Disk usage'), - type: 'integer', - renderer: PVE.Utils.render_disk_usage, - sortable: true, - width: 100, - hidden: true, - }, - diskuse: { - header: gettext('Disk usage') + ' %', - type: 'number', - sortable: true, - renderer: PVE.Utils.render_disk_usage_percent, - width: 100, - calculate: PVE.Utils.calculate_disk_usage, - sortType: 'asFloat', - }, - maxdisk: { - header: gettext('Disk size'), - type: 'integer', - renderer: Proxmox.Utils.render_size, - sortable: true, - hidden: true, - width: 100, - }, - mem: { - header: gettext('Memory usage'), - type: 'integer', - renderer: PVE.Utils.render_mem_usage, - sortable: true, - hidden: true, - width: 100, - }, - memhost: { - header: gettext('Host Memory usage'), - type: 'integer', - renderer: PVE.Utils.render_mem_usage, - sortable: true, - hidden: true, - width: 100, - }, - memuse: { - header: gettext('Memory usage') + ' %', - type: 'number', - renderer: PVE.Utils.render_mem_usage_percent, - calculate: PVE.Utils.calculate_mem_usage, - sortType: 'asFloat', - sortable: true, - width: 100, - }, - maxmem: { - header: gettext('Memory size'), - type: 'integer', - renderer: Proxmox.Utils.render_size, - hidden: true, - sortable: true, - width: 100, - }, - cpu: { - header: gettext('CPU usage'), - type: 'float', - renderer: Proxmox.Utils.render_cpu, - sortable: true, - width: 100, - }, - maxcpu: { - header: gettext('maxcpu'), - type: 'integer', - hidden: true, - sortable: true, - width: 60, - }, - diskread: { - header: gettext('Total Disk Read'), - type: 'integer', - hidden: true, - sortable: true, - renderer: Proxmox.Utils.format_size, - width: 100, - }, - diskwrite: { - header: gettext('Total Disk Write'), - type: 'integer', - hidden: true, - sortable: true, - renderer: Proxmox.Utils.format_size, - width: 100, - }, - netin: { - header: gettext('Total NetIn'), - type: 'integer', - hidden: true, - sortable: true, - renderer: Proxmox.Utils.format_size, - width: 100, - }, - netout: { - header: gettext('Total NetOut'), - type: 'integer', - hidden: true, - sortable: true, - renderer: Proxmox.Utils.format_size, - width: 100, - }, - template: { - header: gettext('Template'), - type: 'integer', - hidden: true, - sortable: true, - width: 60, - }, - uptime: { - header: gettext('Uptime'), - type: 'integer', - renderer: Proxmox.Utils.render_uptime, - sortable: true, - width: 110, - }, - node: { - header: gettext('Node'), - type: 'string', - hidden: true, - sortable: true, - width: 110, - }, - storage: { - header: gettext('Storage'), - type: 'string', - hidden: true, - sortable: true, - width: 110, - }, - pool: { - header: gettext('Pool'), - type: 'string', - hidden: true, - sortable: true, - width: 110, - }, - hastate: { - header: gettext('HA State'), - type: 'string', - defaultValue: 'unmanaged', - hidden: true, - sortable: true, - }, - status: { - header: gettext('Status'), - type: 'string', - hidden: true, - sortable: true, - width: 110, - }, - lock: { - header: gettext('Lock'), - type: 'string', - hidden: true, - sortable: true, - width: 110, - }, - hostcpu: { - header: gettext('Host CPU usage'), - type: 'float', - renderer: PVE.Utils.render_hostcpu, - calculate: PVE.Utils.calculate_hostcpu, - sortType: 'asFloat', - sortable: true, - width: 100, - }, - hostmemuse: { - header: gettext('Host Memory usage') + ' %', - type: 'number', - renderer: PVE.Utils.render_hostmem_usage_percent, - calculate: PVE.Utils.calculate_hostmem_usage, - sortType: 'asFloat', - sortable: true, - width: 100, - }, - tags: { - header: gettext('Tags'), - renderer: (value) => PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), - type: 'string', - sortable: true, - flex: 1, - }, - // note: flex only last column to keep info closer together - }; - - let fields = []; - let fieldNames = []; - Ext.Object.each(field_defaults, function (key, value) { - var field = { name: key, type: value.type }; - if (Ext.isDefined(value.convert)) { - field.convert = value.convert; - } - - if (Ext.isDefined(value.calculate)) { - field.calculate = value.calculate; - } - - if (Ext.isDefined(value.defaultValue)) { - field.defaultValue = value.defaultValue; - } - - fields.push(field); - fieldNames.push(key); - }); - - Ext.define('PVEResources', { - extend: 'Ext.data.Model', - fields: fields, - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/resources', - }, - }); - - Ext.define('PVETree', { - extend: 'Ext.data.Model', - fields: fields, - proxy: { type: 'memory' }, - }); - - Ext.apply(config, { - storeid: 'PVEResources', - model: 'PVEResources', - defaultColumns: function () { - let res = []; - Ext.Object.each(field_defaults, function (field, info) { - let fieldInfo = Ext.apply({ dataIndex: field }, info); - res.push(fieldInfo); - }); - return res; - }, - fieldNames: fieldNames, - }); - - me.callParent([config]); - - me.on('beforeload', me.clearCache, me); - }, -}); -Ext.define('pve-rrd-node', { - extend: 'Ext.data.Model', - fields: [ - { - name: 'cpu', - // percentage - convert: function (value) { - return value * 100; - }, - }, - { - name: 'iowait', - // percentage - convert: function (value) { - return value * 100; - }, - }, - 'loadavg', - 'maxcpu', - 'memtotal', - 'memused', - 'netin', - 'netout', - 'roottotal', - 'rootused', - 'swaptotal', - 'swapused', - 'memavailable', - 'arcsize', - 'pressurecpusome', - 'pressureiosome', - 'pressureiofull', - 'pressurememorysome', - 'pressurememoryfull', - { type: 'date', dateFormat: 'timestamp', name: 'time' }, - ], -}); - -Ext.define('pve-rrd-guest', { - extend: 'Ext.data.Model', - fields: [ - { - name: 'cpu', - // percentage - convert: function (value) { - return value * 100; - }, - }, - 'maxcpu', - 'netin', - 'netout', - { name: 'mem', defaultValue: null }, - 'maxmem', - 'disk', - 'maxdisk', - 'diskread', - 'diskwrite', - 'memhost', - 'pressurecpusome', - 'pressurecpufull', - 'pressureiosome', - 'pressurecpufull', - 'pressurememorysome', - 'pressurememoryfull', - { type: 'date', dateFormat: 'timestamp', name: 'time' }, - ], -}); - -Ext.define('pve-rrd-storage', { - extend: 'Ext.data.Model', - fields: ['used', 'total', { type: 'date', dateFormat: 'timestamp', name: 'time' }], -}); -// This is a container intended to show a field on the first column and one on the second column. -// One can set a ratio for the field sizes. -// -// Works around a limitation of our input panel column1/2 handling that entries are not vertically -// aligned when one of them has wrapping text (like it happens sometimes with such longer -// descriptions) -Ext.define('PVE.container.TwoColumnContainer', { - extend: 'Ext.container.Container', - alias: 'widget.pveTwoColumnContainer', - - layout: { - type: 'hbox', - align: 'begin', - }, - - // The default ratio of the start widget. It an be an integer or a floating point number - startFlex: 1, - - // The default ratio of the end widget. It an be an integer or a floating point number - endFlex: 1, - - // the padding between the two columns - columnPadding: 20, - - // the config of the first widget - startColumn: undefined, - - // the config of the second widget - endColumn: undefined, - - // same as fields in a panel - padding: '0 0 5 0', - - initComponent: function () { - let me = this; - - if (!me.startColumn) { - throw 'no start widget configured'; - } - if (!me.endColumn) { - throw 'no end widget configured'; - } - - Ext.apply(me, { - items: [ - Ext.applyIf({ flex: me.startFlex }, me.startColumn), - { - xtype: 'box', - width: me.columnPadding, - }, - Ext.applyIf({ flex: me.endFlex }, me.endColumn), - ], - }); - - me.callParent(); - }, -}); -Ext.define('pve-acme-challenges', { - extend: 'Ext.data.Model', - fields: ['id', 'type', 'schema'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/acme/challenge-schema', - }, - idProperty: 'id', -}); - -Ext.define('PVE.form.ACMEApiSelector', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveACMEApiSelector', - - fieldLabel: gettext('DNS API'), - displayField: 'name', - valueField: 'id', - - store: { - model: 'pve-acme-challenges', - autoLoad: true, - }, - - triggerAction: 'all', - queryMode: 'local', - allowBlank: false, - editable: true, - forceSelection: true, - anyMatch: true, - selectOnFocus: true, - - getSchema: function () { - let me = this; - let val = me.getValue(); - if (val) { - let record = me.getStore().findRecord('id', val, 0, false, true, true); - if (record) { - return record.data.schema; - } - } - return {}; - }, -}); -Ext.define('PVE.form.ACMEAccountSelector', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveACMEAccountSelector', - - displayField: 'name', - valueField: 'name', - - store: { - model: 'pve-acme-accounts', - autoLoad: true, - }, - - triggerAction: 'all', - queryMode: 'local', - allowBlank: false, - editable: false, - forceSelection: true, - - isEmpty: function () { - return this.getStore().getData().length === 0; - }, -}); -Ext.define('PVE.form.ACMEPluginSelector', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveACMEPluginSelector', - - fieldLabel: gettext('Plugin'), - displayField: 'plugin', - valueField: 'plugin', - - store: { - model: 'pve-acme-plugins', - autoLoad: true, - filters: (item) => item.data.type === 'dns', - }, - - triggerAction: 'all', - queryMode: 'local', - allowBlank: false, - editable: false, -}); -Ext.define('PVE.form.AgentFeatureSelector', { - extend: 'Proxmox.panel.InputPanel', - alias: ['widget.pveAgentFeatureSelector'], - - viewModel: {}, - - items: [ - { - xtype: 'proxmoxcheckbox', - boxLabel: Ext.String.format(gettext('Use {0}'), 'QEMU Guest Agent'), - name: 'enabled', - reference: 'enabled', - uncheckedValue: 0, - }, - { - xtype: 'proxmoxcheckbox', - boxLabel: gettext('Run guest-trim after a disk move or VM migration'), - name: 'fstrim_cloned_disks', - bind: { - disabled: '{!enabled.checked}', - }, - disabled: true, - }, - { - xtype: 'proxmoxcheckbox', - boxLabel: gettext('Freeze/thaw guest filesystems on backup for consistency'), - name: 'freeze-fs-on-backup', - reference: 'freeze_fs_on_backup', - bind: { - disabled: '{!enabled.checked}', - }, - disabled: true, - uncheckedValue: '0', - defaultValue: '1', - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - 'Freeze/thaw for guest filesystems disabled. This can lead to inconsistent disk backups.', - ), - bind: { - hidden: '{freeze_fs_on_backup.checked}', - }, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext('Make sure the QEMU Guest Agent is installed in the VM'), - bind: { - hidden: '{!enabled.checked}', - }, - }, - ], - - advancedItems: [ - { - xtype: 'proxmoxKVComboBox', - name: 'type', - value: '__default__', - deleteEmpty: false, - fieldLabel: 'Type', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (VirtIO)'], - ['virtio', 'VirtIO'], - ['isa', 'ISA'], - ], - }, - ], - - onGetValues: function (values) { - if (PVE.Parser.parseBoolean(values['freeze-fs-on-backup'])) { - delete values['freeze-fs-on-backup']; - } - - const agentstr = PVE.Parser.printPropertyString(values, 'enabled'); - return { agent: agentstr }; - }, - - setValues: function (values) { - let res = PVE.Parser.parsePropertyString(values.agent, 'enabled'); - if (!Ext.isDefined(res['freeze-fs-on-backup'])) { - res['freeze-fs-on-backup'] = 1; - } - - this.callParent([res]); - }, -}); -Ext.define('PVE.form.BackupCompressionSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveBackupCompressionSelector'], - comboItems: [ - ['0', Proxmox.Utils.noneText], - ['lzo', 'LZO (' + gettext('fast') + ')'], - ['gzip', 'GZIP (' + gettext('good') + ')'], - ['zstd', 'ZSTD (' + gettext('fast and good') + ')'], - ], -}); -Ext.define('PVE.form.BackupModeSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveBackupModeSelector'], - comboItems: [ - ['snapshot', gettext('Snapshot')], - ['suspend', gettext('Suspend')], - ['stop', gettext('Stop')], - ], -}); -Ext.define('PVE.form.SizeField', { - extend: 'Ext.form.FieldContainer', - alias: 'widget.pveSizeField', - - mixins: ['Proxmox.Mixin.CBind'], - - viewModel: { - data: { - unit: 'MiB', - unitPostfix: '', - }, - formulas: { - unitlabel: (get) => get('unit') + get('unitPostfix'), - }, - }, - - emptyText: '', - - layout: 'hbox', - defaults: { - hideLabel: true, - }, - - units: { - B: 1, - KiB: 1024, - MiB: 1024 * 1024, - GiB: 1024 * 1024 * 1024, - TiB: 1024 * 1024 * 1024 * 1024, - KB: 1000, - MB: 1000 * 1000, - GB: 1000 * 1000 * 1000, - TB: 1000 * 1000 * 1000 * 1000, - }, - - // display unit (TODO: make (optionally) selectable) - unit: 'MiB', - unitPostfix: '', - - // use this if the backend saves values in a unit other than bytes, e.g., - // for KiB set it to 'KiB' - backendUnit: undefined, - - // allow setting 0 and using it as a submit value - allowZero: false, - - emptyValue: null, - - items: [ - { - xtype: 'numberfield', - cbind: { - name: '{name}', - emptyText: '{emptyText}', - allowZero: '{allowZero}', - emptyValue: '{emptyValue}', - }, - minValue: 0, - step: 1, - submitLocaleSeparator: false, - fieldStyle: 'text-align: right', - flex: 1, - enableKeyEvents: true, - setValue: function (v) { - if (!this._transformed && v !== null) { - let fieldContainer = this.up('fieldcontainer'); - let vm = fieldContainer.getViewModel(); - let unit = vm.get('unit'); - - v /= fieldContainer.units[unit]; - v *= fieldContainer.backendFactor; - - this._transformed = true; - } - - if (Number(v) === 0 && !this.allowZero) { - v = undefined; - } - - return Ext.form.field.Text.prototype.setValue.call(this, v); - }, - getSubmitValue: function () { - let v = this.processRawValue(this.getRawValue()); - v = v.replace(this.decimalSeparator, '.'); - - if (v === undefined || v === '') { - return this.emptyValue; - } - - if (Number(v) === 0) { - return this.allowZero ? 0 : null; - } - - let fieldContainer = this.up('fieldcontainer'); - let vm = fieldContainer.getViewModel(); - let unit = vm.get('unit'); - - v = parseFloat(v) * fieldContainer.units[unit]; - v /= fieldContainer.backendFactor; - - return String(Math.floor(v)); - }, - listeners: { - // our setValue gets only called if we have a value, avoid - // transformation of the first user-entered value - keydown: function () { - this._transformed = true; - }, - }, - }, - { - xtype: 'displayfield', - name: 'unit', - submitValue: false, - padding: '0 0 0 10', - bind: { - value: '{unitlabel}', - }, - listeners: { - change: (f, v) => { - f.originalValue = v; - }, - }, - width: 40, - }, - ], - - initComponent: function () { - let me = this; - - me.unit = me.unit || 'MiB'; - if (!(me.unit in me.units)) { - throw 'unknown unit: ' + me.unit; - } - - me.backendFactor = 1; - if (me.backendUnit !== undefined) { - if (!(me.unit in me.units)) { - throw 'unknown backend unit: ' + me.backendUnit; - } - me.backendFactor = me.units[me.backendUnit]; - } - - me.callParent(arguments); - - me.getViewModel().set('unit', me.unit); - me.getViewModel().set('unitPostfix', me.unitPostfix); - }, -}); - -Ext.define('PVE.form.BandwidthField', { - extend: 'PVE.form.SizeField', - alias: 'widget.pveBandwidthField', - - unitPostfix: '/s', -}); -Ext.define('PVE.form.BridgeSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.PVE.form.BridgeSelector'], - - bridgeType: 'any_bridge', // bridge, OVSBridge or any_bridge - - store: { - fields: ['iface', 'active', 'type'], - filterOnLoad: true, - sorters: [ - { - property: 'iface', - direction: 'ASC', - }, - ], - }, - valueField: 'iface', - displayField: 'iface', - listConfig: { - columns: [ - { - header: gettext('Bridge'), - dataIndex: 'iface', - hideable: false, - width: 100, - }, - { - header: gettext('Active'), - width: 60, - dataIndex: 'active', - renderer: Proxmox.Utils.format_boolean, - }, - { - header: gettext('Comment'), - dataIndex: 'comments', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - }, - - setNodename: function (nodename) { - var me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.store.setProxy({ - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/network?type=' + me.bridgeType, - }); - - me.store.load(); - }, - - initComponent: function () { - var me = this; - - var nodename = me.nodename; - me.nodename = undefined; - - me.callParent(); - - me.setNodename(nodename); - }, -}); -Ext.define('PVE.form.BusTypeSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: 'widget.pveBusSelector', - - withVirtIO: true, - withUnused: false, - - initComponent: function () { - var me = this; - - me.comboItems = [ - ['ide', 'IDE'], - ['sata', 'SATA'], - ]; - - if (me.withVirtIO) { - me.comboItems.push(['virtio', 'VirtIO Block']); - } - - me.comboItems.push(['scsi', 'SCSI']); - - if (me.withUnused) { - me.comboItems.push(['unused', 'Unused']); - } - - me.callParent(); - }, -}); -Ext.define('PVE.data.CPUModel', { - extend: 'Ext.data.Model', - fields: [{ name: 'name' }, { name: 'vendor' }, { name: 'custom' }, { name: 'displayname' }], -}); - -Ext.define('PVE.form.CPUModelSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.CPUModelSelector'], - - valueField: 'name', - displayField: 'displayname', - - emptyText: Proxmox.Utils.defaultText + ' (kvm64)', - allowBlank: true, - - editable: true, - anyMatch: true, - forceSelection: true, - autoSelect: false, - - deleteEmpty: true, - - listConfig: { - columns: [ - { - header: gettext('Model'), - dataIndex: 'displayname', - hideable: false, - sortable: true, - flex: 3, - }, - { - header: gettext('Vendor'), - dataIndex: 'vendor', - hideable: false, - sortable: true, - flex: 2, - }, - ], - width: 360, - }, - - store: { - autoLoad: true, - model: 'PVE.data.CPUModel', - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/localhost/capabilities/qemu/cpu', - }, - sorters: [ - { - sorterFn: function (recordA, recordB) { - let a = recordA.data; - let b = recordB.data; - - let vendorOrder = PVE.Utils.cpu_vendor_order; - let orderA = vendorOrder[a.vendor] || vendorOrder._default_; - let orderB = vendorOrder[b.vendor] || vendorOrder._default_; - - if (orderA > orderB) { - return 1; - } else if (orderA < orderB) { - return -1; - } - - // Within same vendor, sort alphabetically - return a.name.localeCompare(b.name); - }, - direction: 'ASC', - }, - ], - listeners: { - load: function (store, records, success) { - if (success) { - records.forEach((rec) => { - rec.data.displayname = rec.data.name.replace(/^custom-/, ''); - - let vendor = rec.data.vendor; - - if (rec.data.name === 'host') { - vendor = 'Host'; - } - - // We receive vendor names as given to QEMU as CPUID - vendor = PVE.Utils.cpu_vendor_map[vendor] || vendor; - - if (rec.data.custom) { - vendor = gettext('Custom') + ` (${vendor})`; - } - - rec.data.vendor = vendor; - }); - - store.sort(); - } - }, - }, - }, -}); -Ext.define('PVE.form.CacheTypeSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.CacheTypeSelector'], - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (' + gettext('No cache') + ')'], - ['directsync', 'Direct sync'], - ['writethrough', 'Write through'], - ['writeback', 'Write back'], - ['unsafe', 'Write back (' + gettext('unsafe') + ')'], - ['none', gettext('No cache')], - ], -}); -Ext.define('PVE.form.CalendarEvent', { - extend: 'Ext.form.field.ComboBox', - xtype: 'pveCalendarEvent', - - editable: true, - emptyText: gettext('Editable'), // FIXME: better way to convey that to confused users? - - valueField: 'value', - queryMode: 'local', - - matchFieldWidth: false, - listConfig: { - maxWidth: 450, - }, - - store: { - field: ['value', 'text'], - data: [ - { value: '*/30', text: Ext.String.format(gettext('Every {0} minutes'), 30) }, - { value: '*/2:00', text: gettext('Every two hours') }, - { value: '21:00', text: gettext('Every day') + ' 21:00' }, - { value: '2,22:30', text: gettext('Every day') + ' 02:30, 22:30' }, - { value: 'mon..fri 00:00', text: gettext('Monday to Friday') + ' 00:00' }, - { - value: 'mon..fri */1:00', - text: gettext('Monday to Friday') + ': ' + gettext('hourly'), - }, - { - value: 'mon..fri 7..18:00/15', - text: - gettext('Monday to Friday') + - ', ' + - Ext.String.format(gettext('{0} to {1}'), '07:00', '18:45') + - ': ' + - Ext.String.format(gettext('Every {0} minutes'), 15), - }, - { value: 'sun 01:00', text: gettext('Sunday') + ' 01:00' }, - { value: 'monthly', text: gettext('Every first day of the Month') + ' 00:00' }, - { value: 'sat *-1..7 15:00', text: gettext('First Saturday each month') + ' 15:00' }, - { value: 'yearly', text: gettext('First day of the year') + ' 00:00' }, - ], - }, - - tpl: [ - '
      ', - '
    • {text}
    • ', - '
    ', - ], - - displayTpl: ['', '{value}', ''], -}); -Ext.define('PVE.form.CephPoolSelector', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveCephPoolSelector', - - allowBlank: false, - valueField: 'pool_name', - displayField: 'pool_name', - listConfig: { - itemTpl: '{pool_name:htmlEncode}', - }, - editable: false, - queryMode: 'local', - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no nodename given'; - } - - let onlyRBDPools = ({ data }) => - !data?.application_metadata || !!data?.application_metadata?.rbd; - - var store = Ext.create('Ext.data.Store', { - fields: ['name'], - sorters: 'name', - filters: [onlyRBDPools], - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/ceph/pool', - }, - }); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - - store.load({ - callback: function (rec, op, success) { - let filteredRec = rec.filter(onlyRBDPools); - - if (success && filteredRec.length > 0) { - me.select(filteredRec[0]); - } - }, - }); - }, -}); -Ext.define('PVE.form.CephFSSelector', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveCephFSSelector', - - allowBlank: false, - valueField: 'name', - displayField: 'name', - editable: false, - queryMode: 'local', - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no nodename given'; - } - - var store = Ext.create('Ext.data.Store', { - fields: ['name'], - sorters: 'name', - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/ceph/fs', - }, - }); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - - store.load({ - callback: function (rec, op, success) { - if (success && rec.length > 0) { - me.select(rec[0]); - } - }, - }); - }, -}); -Ext.define('PVE.form.ComboBoxSetStoreNode', { - extend: 'Proxmox.form.ComboGrid', - config: { - apiBaseUrl: '/api2/json/nodes/', - apiSuffix: '', - }, - - showNodeSelector: false, - - setNodeName: function (value) { - let me = this; - value ||= Proxmox.NodeName; - - me.getStore().getProxy().setUrl(`${me.apiBaseUrl}${value}${me.apiSuffix}`); - me.clearValue(); - }, - - nodeChange: function (_field, value) { - let me = this; - // disable autoSelect if there is already a selection or we have the picker open - if (me.getValue() || me.isExpanded) { - let autoSelect = me.autoSelect; - me.autoSelect = false; - me.store.on( - 'afterload', - function () { - me.autoSelect = autoSelect; - }, - { single: true }, - ); - } - me.setNodeName(value); - me.fireEvent('nodechanged', value); - }, - - tbarMouseDown: function () { - this.topBarMousePress = true; - }, - - tbarMouseUp: function () { - let me = this; - delete this.topBarMousePress; - if (me.focusLeft) { - me.focus(); - delete me.focusLeft; - } - }, - - // conditionally prevent the focusLeave handler to continue, preventing collapsing of the picker - onFocusLeave: function () { - let me = this; - me.focusLeft = true; - if (!me.topBarMousePress) { - me.callParent(arguments); - } - - return undefined; - }, - - initComponent: function () { - let me = this; - - if (me.showNodeSelector && !PVE.Utils.isStandaloneNode()) { - me.errorHeight = 140; - Ext.apply(me.listConfig ?? {}, { - tbar: { - xtype: 'toolbar', - minHeight: 40, - listeners: { - mousedown: me.tbarMouseDown, - mouseup: me.tbarMouseUp, - element: 'el', - scope: me, - }, - items: [ - { - xtype: 'pveStorageScanNodeSelector', - autoSelect: false, - fieldLabel: gettext('Node to scan'), - listeners: { - change: (field, value) => me.nodeChange(field, value), - }, - }, - ], - }, - emptyText: me.listConfig?.emptyText ?? gettext('Nothing found'), - }); - } - - me.callParent(); - }, -}); -Ext.define('PVE.form.ContentTypeSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveContentTypeSelector'], - - cts: undefined, - - initComponent: function () { - var me = this; - - me.comboItems = []; - - if (me.cts === undefined) { - me.cts = ['images', 'iso', 'vztmpl', 'backup', 'rootdir', 'snippets', 'import']; - } - - Ext.Array.each(me.cts, function (ct) { - me.comboItems.push([ct, PVE.Utils.format_content_types(ct)]); - }); - - me.callParent(); - }, -}); -Ext.define('PVE.form.ControllerSelector', { - extend: 'Ext.form.FieldContainer', - alias: 'widget.pveControllerSelector', - - withVirtIO: true, - withUnused: false, - - vmconfig: {}, // used to check for existing devices - - setToFree: function (controllers, busField, deviceIDField) { - let me = this; - let freeId = PVE.Utils.nextFreeDisk(controllers, me.vmconfig); - - if (freeId !== undefined) { - busField?.setValue(freeId.controller); - deviceIDField.setValue(freeId.id); - } - }, - - updateVMConfig: function (vmconfig) { - let me = this; - me.vmconfig = Ext.apply({}, vmconfig); - - me.down('field[name=deviceid]').validate(); - }, - - setVMConfig: function (vmconfig, autoSelect) { - let me = this; - - me.vmconfig = Ext.apply({}, vmconfig); - - let bussel = me.down('field[name=controller]'); - let deviceid = me.down('field[name=deviceid]'); - - let clist; - if (autoSelect === 'cdrom') { - if (!Ext.isDefined(me.vmconfig.ide2)) { - bussel.setValue('ide'); - deviceid.setValue(2); - return; - } - clist = ['ide', 'scsi', 'sata']; - } else { - // in most cases we want to add a disk to the same controller we previously used - clist = PVE.Utils.sortByPreviousUsage(me.vmconfig); - } - - me.setToFree(clist, bussel, deviceid); - - deviceid.validate(); - }, - - getConfId: function () { - let me = this; - let controller = me.getComponent('controller').getValue() || 'ide'; - let id = me.getComponent('deviceid').getValue() || 0; - - return `${controller}${id}`; - }, - - initComponent: function () { - let me = this; - - Ext.apply(me, { - fieldLabel: gettext('Bus/Device'), - layout: 'hbox', - defaults: { - hideLabel: true, - }, - items: [ - { - xtype: 'pveBusSelector', - name: 'controller', - itemId: 'controller', - value: PVE.qemu.OSDefaults.generic.busType, - withVirtIO: me.withVirtIO, - withUnused: me.withUnused, - allowBlank: false, - flex: 2, - listeners: { - change: function (t, value) { - if (!value) { - return; - } - let field = me.down('field[name=deviceid]'); - me.setToFree([value], undefined, field); - field.setMaxValue(PVE.Utils.diskControllerMaxIDs[value] - 1); - field.validate(); - }, - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'deviceid', - itemId: 'deviceid', - minValue: 0, - maxValue: PVE.Utils.diskControllerMaxIDs.ide - 1, - value: '0', - flex: 1, - allowBlank: false, - validator: function (value) { - if (!me.rendered) { - return undefined; - } - let controller = me.down('field[name=controller]').getValue(); - let confid = controller + value; - if (Ext.isDefined(me.vmconfig[confid])) { - return 'This device is already in use.'; - } - return true; - }, - }, - ], - }); - - me.callParent(); - - if (me.selectFree) { - me.setVMConfig(me.vmconfig); - } - }, -}); -Ext.define('PVE.form.DayOfWeekSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveDayOfWeekSelector'], - comboItems: [], - initComponent: function () { - var me = this; - me.comboItems = [ - ['mon', Ext.util.Format.htmlDecode(Ext.Date.dayNames[1])], - ['tue', Ext.util.Format.htmlDecode(Ext.Date.dayNames[2])], - ['wed', Ext.util.Format.htmlDecode(Ext.Date.dayNames[3])], - ['thu', Ext.util.Format.htmlDecode(Ext.Date.dayNames[4])], - ['fri', Ext.util.Format.htmlDecode(Ext.Date.dayNames[5])], - ['sat', Ext.util.Format.htmlDecode(Ext.Date.dayNames[6])], - ['sun', Ext.util.Format.htmlDecode(Ext.Date.dayNames[0])], - ]; - this.callParent(); - }, -}); -Ext.define('PVE.form.DirMapSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: 'widget.pveDirMapSelector', - - store: { - fields: ['name', 'path'], - filterOnLoad: true, - sorters: [ - { - property: 'id', - direction: 'ASC', - }, - ], - }, - - allowBlank: false, - autoSelect: false, - displayField: 'id', - valueField: 'id', - - listConfig: { - columns: [ - { - header: gettext('Directory ID'), - dataIndex: 'id', - flex: 1, - }, - { - header: gettext('Comment'), - dataIndex: 'description', - flex: 1, - }, - ], - }, - - setNodename: function (nodename) { - var me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.store.setProxy({ - type: 'proxmox', - url: `/api2/json/cluster/mapping/dir?check-node=${nodename}`, - }); - - me.store.load(); - }, - - initComponent: function () { - var me = this; - - var nodename = me.nodename; - me.nodename = undefined; - - me.callParent(); - - me.setNodename(nodename); - }, -}); -Ext.define('PVE.form.DiskFormatSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: 'widget.pveDiskFormatSelector', - comboItems: [ - ['raw', gettext('Raw disk image') + ' (raw)'], - ['qcow2', gettext('QEMU image format') + ' (qcow2)'], - ['vmdk', gettext('VMware image format') + ' (vmdk)'], - ], -}); -Ext.define('PVE.form.DiskStorageSelector', { - extend: 'Ext.container.Container', - alias: 'widget.pveDiskStorageSelector', - - layout: 'fit', - defaults: { - margin: '0 0 5 0', - }, - - // the fieldLabel for the storageselector - storageLabel: gettext('Storage'), - - // the content to show (e.g., images or rootdir) - storageContent: undefined, - - // if true, selects the first available storage - autoSelect: false, - - allowBlank: false, - emptyText: '', - - // hides the selection field - // this is always hidden on creation, - // and only shown when the storage needs a selection and - // hideSelection is not true - hideSelection: undefined, - - // hides the size field (e.g, for the efi disk dialog) - hideSize: false, - - // hides the format field - hideFormat: false, - - // sets the initial size value - // string because else we get a type confusion - defaultSize: '32', - - changeStorage: function (f, value) { - var me = this; - var formatsel = me.getComponent('diskformat'); - var hdfilesel = me.getComponent('hdimage'); - var hdsizesel = me.getComponent('disksize'); - - // initial store load, and reset/deletion of the storage - if (!value) { - hdfilesel.setDisabled(true); - hdfilesel.setVisible(false); - - formatsel.setDisabled(true); - return; - } - - var rec = f.store.getById(value); - // if the storage is not defined, or valid, - // we cannot know what to enable/disable - if (!rec) { - return; - } - - let validFormats = {}; - let defaultFormat = 'raw'; - let selectFormat = defaultFormat; - if (rec.data.formats) { - for (const format of rec.data.formats.supported) { - validFormats[format] = true; - } - defaultFormat = rec.data.formats.default; - } else if (rec.data.format) { - // legacy api, just for compatibility - // 0 is the formats, 1 the default in the backend - validFormats = rec.data.format[0]; - defaultFormat = rec.data.format[1]; - } - - if (Object.keys(validFormats).length > 0) { - delete validFormats.subvol; // we never need subvol in the gui - if (validFormats.qcow2) { - selectFormat = 'qcow2'; - } else if (validFormats.raw) { - selectFormat = 'raw'; - } else { - selectFormat = defaultFormat; - } - } - - var select = !!rec.data.select_existing && !me.hideSelection; - - let numberOfValidFormats = Ext.Object.getValues(validFormats).filter( - (valid) => !!valid, - ).length; - formatsel.setDisabled(me.hideFormat || numberOfValidFormats <= 1); - formatsel.setValue(selectFormat); - - hdfilesel.setDisabled(!select); - hdfilesel.setVisible(select); - if (select) { - hdfilesel.setStorage(value); - } - - hdsizesel.setDisabled(select || me.hideSize); - hdsizesel.setVisible(!select && !me.hideSize); - }, - - setNodename: function (nodename) { - var me = this; - var hdstorage = me.getComponent('hdstorage'); - var hdfilesel = me.getComponent('hdimage'); - - hdstorage.setNodename(nodename); - hdfilesel.setNodename(nodename); - }, - - setDisabled: function (value) { - var me = this; - var hdstorage = me.getComponent('hdstorage'); - - // reset on disable - if (value) { - hdstorage.setValue(); - } - hdstorage.setDisabled(value); - - // disabling does not always fire this event and we do not need - // the value of the validity - hdstorage.fireEvent('validitychange'); - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'pveStorageSelector', - itemId: 'hdstorage', - name: 'hdstorage', - fieldLabel: me.storageLabel, - nodename: me.nodename, - storageContent: me.storageContent, - disabled: me.disabled, - autoSelect: me.autoSelect, - allowBlank: me.allowBlank, - emptyText: me.emptyText, - listeners: { - change: { - fn: me.changeStorage, - scope: me, - }, - }, - }, - { - xtype: 'pveFileSelector', - name: 'hdimage', - itemId: 'hdimage', - fieldLabel: gettext('Disk image'), - nodename: me.nodename, - disabled: true, - hidden: true, - }, - { - xtype: 'numberfield', - itemId: 'disksize', - name: 'disksize', - fieldLabel: `${gettext('Disk size')} (${gettext('GiB')})`, - hidden: me.hideSize, - disabled: me.hideSize, - minValue: 0.001, - maxValue: 128 * 1024, - decimalPrecision: 3, - value: me.defaultSize, - allowBlank: false, - }, - { - xtype: 'pveDiskFormatSelector', - itemId: 'diskformat', - name: 'diskformat', - fieldLabel: gettext('Format'), - nodename: me.nodename, - disabled: true, - hidden: me.hideFormat || me.storageContent === 'rootdir', - value: 'qcow2', - allowBlank: false, - }, - ]; - - // use it to disable the children but not ourself - me.disabled = false; - - me.callParent(); - }, -}); -Ext.define('PVE.form.FileSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: 'widget.pveFileSelector', - - editable: true, - anyMatch: true, - forceSelection: true, - - listeners: { - afterrender: function () { - var me = this; - if (!me.disabled) { - me.setStorage(me.storage, me.nodename); - } - }, - }, - - setStorage: function (storage, nodename) { - var me = this; - - var change = false; - if (storage && me.storage !== storage) { - me.storage = storage; - change = true; - } - - if (nodename && me.nodename !== nodename) { - me.nodename = nodename; - change = true; - } - - if (!(me.storage && me.nodename && change)) { - return; - } - - var url = '/api2/json/nodes/' + me.nodename + '/storage/' + me.storage + '/content'; - if (me.storageContent) { - url += '?content=' + me.storageContent; - } - - me.store.setProxy({ - type: 'proxmox', - url: url, - }); - - if (Ext.isFunction(me.filter)) { - me.store.clearFilter(); - me.store.addFilter([me.filter]); - } else { - me.store.clearFilter(); - } - - me.store.removeAll(); - me.store.load(); - }, - - setNodename: function (nodename) { - this.setStorage(undefined, nodename); - }, - - store: { - model: 'pve-storage-content', - }, - - allowBlank: false, - autoSelect: false, - valueField: 'volid', - displayField: 'text', - - // An optional filter function - filter: undefined, - - listConfig: { - width: 600, - columns: [ - { - header: gettext('Name'), - dataIndex: 'text', - hideable: false, - flex: 1, - }, - { - header: gettext('Format'), - width: 60, - dataIndex: 'format', - }, - { - header: gettext('Size'), - width: 100, - dataIndex: 'size', - renderer: Proxmox.Utils.format_size, - }, - ], - }, -}); -Ext.define('PVE.form.FirewallPolicySelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveFirewallPolicySelector'], - comboItems: [ - ['ACCEPT', 'ACCEPT'], - ['REJECT', 'REJECT'], - ['DROP', 'DROP'], - ], -}); -/* - * This is a global search field it loads the /cluster/resources on focus and displays the - * result in a floating grid. Filtering and sorting is done in the customFilter function - * - * Accepts key up/down and enter for input, and it opens to CTRL+SHIFT+F and CTRL+SPACE - */ -Ext.define('PVE.form.GlobalSearchField', { - extend: 'Ext.form.field.Text', - alias: 'widget.pveGlobalSearchField', - - emptyText: gettext('Search'), - enableKeyEvents: true, - selectOnFocus: true, - padding: '0 5 0 5', - - grid: { - xtype: 'gridpanel', - userCls: 'proxmox-tags-full', - focusOnToFront: false, - floating: true, - emptyText: Proxmox.Utils.noneText, - width: 600, - height: 400, - scrollable: { - xtype: 'scroller', - y: true, - x: true, - }, - store: { - model: 'PVEResources', - proxy: { - type: 'proxmox', - url: '/api2/extjs/cluster/resources', - }, - }, - plugins: { - ptype: 'bufferedrenderer', - trailingBufferZone: 20, - leadingBufferZone: 20, - }, - - hideMe: function () { - var me = this; - if (typeof me.ctxMenu !== 'undefined' && me.ctxMenu.isVisible()) { - return; - } - me.hasFocus = false; - if (!me.textfield.hasFocus) { - me.hide(); - } - }, - - setFocus: function () { - var me = this; - me.hasFocus = true; - }, - - listeners: { - rowclick: function (grid, record) { - var me = this; - me.textfield.selectAndHide(record.id); - }, - itemcontextmenu: function (v, record, item, index, event) { - var me = this; - me.ctxMenu = PVE.Utils.createCmdMenu(v, record, item, index, event); - }, - focusleave: 'hideMe', - focusenter: 'setFocus', - }, - - columns: [ - { - text: gettext('Type'), - dataIndex: 'type', - width: 100, - renderer: PVE.Utils.render_resource_type, - }, - { - text: gettext('Description'), - flex: 1, - dataIndex: 'text', - renderer: function (value, mD, rec) { - let overrides = PVE.UIOptions.tagOverrides; - let tags = PVE.Utils.renderTags(rec.data.tags, overrides); - return `${value}${tags}`; - }, - }, - { - text: gettext('Node'), - dataIndex: 'node', - }, - { - text: gettext('Pool'), - dataIndex: 'pool', - }, - ], - }, - - customFilter: function (item) { - let me = this; - - if (me.filterVal === '') { - item.data.relevance = 0; - return true; - } - // different types have different fields to search, e.g., a node will never have a pool - const fieldMap = { - pool: ['type', 'pool', 'text'], - node: ['type', 'node', 'text'], - storage: ['type', 'pool', 'node', 'storage'], - default: ['name', 'type', 'node', 'pool', 'vmid'], - }; - let fields = fieldMap[item.data.type] || fieldMap.default; - let fieldArr = fields.map((field) => item.data[field]?.toString().toLowerCase()); - if (item.data.tags) { - let tags = item.data.tags.split(/[;, ]/); - fieldArr.push(...tags); - } - - let filterWords = me.filterVal.split(/\s+/); - - // all text is case insensitive and each split-out word is searched for separately. - // a row gets 1 point for every partial match, and and additional point for every exact match - let match = 0; - for (let fieldValue of fieldArr) { - if (fieldValue === undefined || fieldValue === '') { - continue; - } - for (let filterWord of filterWords) { - if (fieldValue.indexOf(filterWord) !== -1) { - match++; // partial match - if (fieldValue === filterWord) { - match++; // exact match is worth more - } - } - } - } - item.data.relevance = match; // set the row's virtual 'relevance' value for ordering - return match > 0; - }, - - updateFilter: function (field, newValue, oldValue) { - let me = this; - // parse input and filter store, show grid - me.grid.store.filterVal = newValue.toLowerCase().trim(); - me.grid.store.clearFilter(true); - me.grid.store.filterBy(me.customFilter); - me.grid.getSelectionModel().select(0); - }, - - selectAndHide: function (id) { - var me = this; - me.tree.selectById(id); - me.grid.hide(); - me.setValue(''); - me.blur(); - }, - - onKey: function (field, e) { - var me = this; - var key = e.getKey(); - - switch (key) { - case Ext.event.Event.ENTER: - // go to first entry if there is one - if (me.grid.store.getCount() > 0) { - me.selectAndHide(me.grid.getSelection()[0].data.id); - } - break; - case Ext.event.Event.UP: - me.grid.getSelectionModel().selectPrevious(); - break; - case Ext.event.Event.DOWN: - me.grid.getSelectionModel().selectNext(); - break; - case Ext.event.Event.ESC: - me.grid.hide(); - me.blur(); - break; - } - }, - - loadValues: function (field) { - let me = this; - me.hasFocus = true; - me.grid.textfield = me; - me.grid.store.load(); - me.grid.showBy(me, 'tl-bl'); - }, - - hideGrid: function () { - let me = this; - me.hasFocus = false; - if (!me.grid.hasFocus) { - me.grid.hide(); - } - }, - - listeners: { - change: { - fn: 'updateFilter', - buffer: 250, - }, - specialkey: 'onKey', - focusenter: 'loadValues', - focusleave: { - fn: 'hideGrid', - delay: 100, - }, - }, - - toggleFocus: function () { - let me = this; - if (!me.hasFocus) { - me.focus(); - } else { - me.blur(); - } - }, - - initComponent: function () { - let me = this; - - if (!me.tree) { - throw 'no tree given'; - } - - me.grid = Ext.create(me.grid); - - me.callParent(); - - // bind CTRL + SHIFT + F and CTRL + SPACE to open/close the search - me.keymap = new Ext.KeyMap({ - target: Ext.get(document), - binding: [ - { - key: 'F', - ctrl: true, - shift: true, - fn: me.toggleFocus, - scope: me, - }, - { - key: ' ', - ctrl: true, - fn: me.toggleFocus, - scope: me, - }, - ], - }); - - // always select first item and sort by relevance after load - me.mon(me.grid.store, 'load', function () { - me.grid.getSelectionModel().select(0); - me.grid.store.sort({ - property: 'relevance', - direction: 'DESC', - }); - }); - }, -}); -Ext.define('pve-groups', { - extend: 'Ext.data.Model', - fields: ['groupid', 'comment', 'users'], - proxy: { - type: 'proxmox', - url: '/api2/json/access/groups', - }, - idProperty: 'groupid', -}); - -Ext.define('PVE.form.GroupSelector', { - extend: 'Proxmox.form.ComboGrid', - xtype: 'pveGroupSelector', - - editable: true, - anyMatch: true, - forceSelection: true, - - allowBlank: false, - autoSelect: false, - valueField: 'groupid', - displayField: 'groupid', - listConfig: { - columns: [ - { - header: gettext('Group'), - sortable: true, - dataIndex: 'groupid', - flex: 1, - }, - { - header: gettext('Comment'), - sortable: false, - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - { - header: gettext('Users'), - sortable: false, - dataIndex: 'users', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - }, - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-groups', - sorters: [ - { - property: 'groupid', - }, - ], - }); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - - store.load(); - }, -}); -Ext.define('PVE.form.GuestIDSelector', { - extend: 'Ext.form.field.Number', - alias: 'widget.pveGuestIDSelector', - - allowBlank: false, - - minValue: 100, - - maxValue: 999999999, - - validateExists: undefined, - - loadNextFreeID: false, - - guestType: undefined, - - validator: function (value) { - var me = this; - - if (!Ext.isNumeric(value) || value < me.minValue || value > me.maxValue) { - // check is done by ExtJS - return true; - } - - if (me.validateExists === true && !me.exists) { - return me.unknownID; - } - - if (me.validateExists === false && me.exists) { - return me.inUseID; - } - - return true; - }, - - initComponent: function () { - var me = this; - var label = '{0} ID'; - var unknownID = gettext('This {0} ID does not exist'); - var inUseID = gettext('This {0} ID is already in use'); - var type = 'CT/VM'; - - if (me.guestType === 'lxc') { - type = 'CT'; - } else if (me.guestType === 'qemu') { - type = 'VM'; - } - - me.label = Ext.String.format(label, type); - me.unknownID = Ext.String.format(unknownID, type); - me.inUseID = Ext.String.format(inUseID, type); - - Ext.apply(me, { - fieldLabel: me.label, - listeners: { - change: function (field, newValue, oldValue) { - if (!Ext.isDefined(me.validateExists)) { - return; - } - Proxmox.Utils.API2Request({ - params: { vmid: newValue }, - url: '/cluster/nextid', - method: 'GET', - success: function (response, opts) { - me.exists = false; - me.validate(); - }, - failure: function (response, opts) { - me.exists = true; - me.validate(); - }, - }); - }, - }, - }); - - me.callParent(); - - if (me.loadNextFreeID) { - Proxmox.Utils.API2Request({ - url: '/cluster/nextid', - method: 'GET', - success: function (response, opts) { - me.setRawValue(response.result.data); - }, - }); - } - }, -}); -Ext.define('PVE.form.hashAlgorithmSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveHashAlgorithmSelector'], - config: { - deleteEmpty: false, - }, - comboItems: [ - ['__default__', 'None'], - ['md5', 'MD5'], - ['sha1', 'SHA-1'], - ['sha224', 'SHA-224'], - ['sha256', 'SHA-256'], - ['sha384', 'SHA-384'], - ['sha512', 'SHA-512'], - ], -}); -Ext.define('PVE.form.HotplugFeatureSelector', { - extend: 'Ext.form.CheckboxGroup', - alias: 'widget.pveHotplugFeatureSelector', - - columns: 1, - vertical: true, - - defaults: { - name: 'hotplugCbGroup', - submitValue: false, - }, - items: [ - { - boxLabel: gettext('Disk'), - inputValue: 'disk', - checked: true, - }, - { - boxLabel: gettext('Network'), - inputValue: 'network', - checked: true, - }, - { - boxLabel: 'USB', - inputValue: 'usb', - checked: true, - }, - { - boxLabel: gettext('Memory'), - inputValue: 'memory', - }, - { - boxLabel: gettext('CPU'), - inputValue: 'cpu', - }, - ], - - setValue: function (value) { - var me = this; - var newVal = []; - if (value === '1') { - newVal = ['disk', 'network', 'usb']; - } else if (value !== '0') { - newVal = value.split(','); - } - me.callParent([{ hotplugCbGroup: newVal }]); - }, - - // override framework function to - // assemble the hotplug value - getSubmitData: function () { - var me = this, - boxes = me.getBoxes(), - data = []; - Ext.Array.forEach(boxes, function (box) { - if (box.getValue()) { - data.push(box.inputValue); - } - }); - - /* because above is hotplug an array */ - if (data.length === 0) { - return { hotplug: '0' }; - } else { - return { hotplug: data.join(',') }; - } - }, -}); -Ext.define('PVE.form.IPProtocolSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveIPProtocolSelector'], - valueField: 'p', - displayField: 'p', - listConfig: { - columns: [ - { - header: gettext('Protocol'), - dataIndex: 'p', - hideable: false, - sortable: false, - width: 100, - }, - { - header: gettext('Number'), - dataIndex: 'n', - hideable: false, - sortable: false, - width: 50, - }, - { - header: gettext('Description'), - dataIndex: 'd', - hideable: false, - sortable: false, - flex: 1, - }, - ], - }, - store: { - fields: ['p', 'd', 'n'], - data: [ - { p: 'tcp', n: 6, d: 'Transmission Control Protocol' }, - { p: 'udp', n: 17, d: 'User Datagram Protocol' }, - { p: 'icmp', n: 1, d: 'Internet Control Message Protocol' }, - { p: 'igmp', n: 2, d: 'Internet Group Management' }, - { p: 'ggp', n: 3, d: 'gateway-gateway protocol' }, - { p: 'ipencap', n: 4, d: 'IP encapsulated in IP' }, - { p: 'st', n: 5, d: 'ST datagram mode' }, - { p: 'egp', n: 8, d: 'exterior gateway protocol' }, - { p: 'igp', n: 9, d: 'any private interior gateway (Cisco)' }, - { p: 'pup', n: 12, d: 'PARC universal packet protocol' }, - { p: 'hmp', n: 20, d: 'host monitoring protocol' }, - { p: 'xns-idp', n: 22, d: 'Xerox NS IDP' }, - { p: 'rdp', n: 27, d: '"reliable datagram" protocol' }, - { p: 'iso-tp4', n: 29, d: 'ISO Transport Protocol class 4 [RFC905]' }, - { p: 'dccp', n: 33, d: 'Datagram Congestion Control Prot. [RFC4340]' }, - { p: 'xtp', n: 36, d: 'Xpress Transfer Protocol' }, - { p: 'ddp', n: 37, d: 'Datagram Delivery Protocol' }, - { p: 'idpr-cmtp', n: 38, d: 'IDPR Control Message Transport' }, - { p: 'ipv6', n: 41, d: 'Internet Protocol, version 6' }, - { p: 'ipv6-route', n: 43, d: 'Routing Header for IPv6' }, - { p: 'ipv6-frag', n: 44, d: 'Fragment Header for IPv6' }, - { p: 'idrp', n: 45, d: 'Inter-Domain Routing Protocol' }, - { p: 'rsvp', n: 46, d: 'Reservation Protocol' }, - { p: 'gre', n: 47, d: 'General Routing Encapsulation' }, - { p: 'esp', n: 50, d: 'Encap Security Payload [RFC2406]' }, - { p: 'ah', n: 51, d: 'Authentication Header [RFC2402]' }, - { p: 'skip', n: 57, d: 'SKIP' }, - { p: 'ipv6-icmp', n: 58, d: 'ICMP for IPv6' }, - { p: 'ipv6-nonxt', n: 59, d: 'No Next Header for IPv6' }, - { p: 'ipv6-opts', n: 60, d: 'Destination Options for IPv6' }, - { p: 'vmtp', n: 81, d: 'Versatile Message Transport' }, - { p: 'eigrp', n: 88, d: 'Enhanced Interior Routing Protocol (Cisco)' }, - { p: 'ospf', n: 89, d: 'Open Shortest Path First IGP' }, - { p: 'ax.25', n: 93, d: 'AX.25 frames' }, - { p: 'ipip', n: 94, d: 'IP-within-IP Encapsulation Protocol' }, - { p: 'etherip', n: 97, d: 'Ethernet-within-IP Encapsulation [RFC3378]' }, - { p: 'encap', n: 98, d: 'Yet Another IP encapsulation [RFC1241]' }, - { p: 'pim', n: 103, d: 'Protocol Independent Multicast' }, - { p: 'ipcomp', n: 108, d: 'IP Payload Compression Protocol' }, - { p: 'vrrp', n: 112, d: 'Virtual Router Redundancy Protocol [RFC5798]' }, - { p: 'l2tp', n: 115, d: 'Layer Two Tunneling Protocol [RFC2661]' }, - { p: 'isis', n: 124, d: 'IS-IS over IPv4' }, - { p: 'sctp', n: 132, d: 'Stream Control Transmission Protocol' }, - { p: 'fc', n: 133, d: 'Fibre Channel' }, - { p: 'mobility-header', n: 135, d: 'Mobility Support for IPv6 [RFC3775]' }, - { p: 'udplite', n: 136, d: 'UDP-Lite [RFC3828]' }, - { p: 'mpls-in-ip', n: 137, d: 'MPLS-in-IP [RFC4023]' }, - { p: 'hip', n: 139, d: 'Host Identity Protocol' }, - { p: 'shim6', n: 140, d: 'Shim6 Protocol [RFC5533]' }, - { p: 'wesp', n: 141, d: 'Wrapped Encapsulating Security Payload' }, - { p: 'rohc', n: 142, d: 'Robust Header Compression' }, - ], - }, -}); -Ext.define('PVE.form.IPRefSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveIPRefSelector'], - - base_url: undefined, - - preferredValue: '', // hack: else Form sets dirty flag? - - ref_type: undefined, // undefined = any [undefined, 'ipset' or 'alias'] - - valueField: 'scopedref', - displayField: 'ref', - notFoundIsValid: true, - - initComponent: function () { - var me = this; - - if (!me.base_url) { - throw 'no base_url specified'; - } - - var url = '/api2/json' + me.base_url; - if (me.ref_type) { - url += '?type=' + me.ref_type; - } - - var store = Ext.create('Ext.data.Store', { - autoLoad: true, - fields: [ - 'type', - 'name', - 'ref', - 'comment', - 'scope', - { - name: 'scopedref', - calculate: function (v) { - if (v.type === 'alias') { - return `${v.scope}/${v.name}`; - } else if (v.type === 'ipset') { - return `+${v.scope}/${v.name}`; - } else { - return v.ref; - } - }, - }, - ], - idProperty: 'ref', - proxy: { - type: 'proxmox', - url: url, - }, - sorters: { - property: 'ref', - direction: 'ASC', - }, - }); - - var columns = []; - - if (!me.ref_type) { - columns.push({ - header: gettext('Type'), - dataIndex: 'type', - hideable: false, - width: 60, - }); - } - - let scopes = { - dc: gettext('Datacenter'), - guest: gettext('Guest'), - sdn: gettext('SDN'), - }; - - columns.push( - { - header: gettext('Name'), - dataIndex: 'ref', - hideable: false, - width: 140, - }, - { - header: gettext('Scope'), - dataIndex: 'scope', - hideable: false, - width: 140, - renderer: function (value) { - return scopes[value] ?? 'unknown scope'; - }, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - minWidth: 60, - flex: 1, - }, - ); - - Ext.apply(me, { - store: store, - listConfig: { - columns: columns, - width: 500, - }, - }); - - me.on('beforequery', function (queryPlan) { - return !(queryPlan.query === null || queryPlan.query.match(/^\d/)); - }); - - me.callParent(); - }, -}); -Ext.define('PVE.form.MDevSelector', { - extend: 'Proxmox.form.ComboGrid', - xtype: 'pveMDevSelector', - - store: { - fields: ['type', 'available', 'description'], - filterOnLoad: true, - sorters: [ - { - property: 'type', - direction: 'ASC', - }, - ], - }, - autoSelect: false, - valueField: 'type', - displayField: 'type', - listConfig: { - width: 550, - columns: [ - { - header: gettext('Type'), - dataIndex: 'type', - renderer: function (value, md, rec) { - if (rec.data.name !== undefined) { - return `${rec.data.name} (${value})`; - } - return value; - }, - flex: 1, - }, - { - header: gettext('Avail'), - dataIndex: 'available', - width: 60, - }, - { - header: gettext('Description'), - dataIndex: 'description', - flex: 1, - cellWrap: true, - renderer: function (value) { - if (!value) { - return ''; - } - - return value.split('\n').join('
    '); - }, - }, - ], - }, - - setPciIdOrMapping: function (pciIdOrMapping, force) { - var me = this; - - if (!force && (!pciIdOrMapping || me.pciIdOrMapping === pciIdOrMapping)) { - return; - } - - me.pciIdOrMapping = pciIdOrMapping; - me.updateProxy(); - }, - - setNodename: function (nodename) { - var me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - me.updateProxy(); - }, - - updateProxy: function () { - var me = this; - me.store.setProxy({ - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/hardware/pci/${me.pciIdOrMapping}/mdev`, - }); - me.store.load(); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.callParent(); - - if (me.pciIdOrMapping) { - me.setPciIdOrMapping(me.pciIdOrMapping, true); - } - }, -}); -Ext.define('PVE.form.MemoryField', { - extend: 'Ext.form.field.Number', - alias: 'widget.pveMemoryField', - - allowBlank: false, - - hotplug: false, - - minValue: 32, - - maxValue: 4178944, - - step: 32, - - value: '512', // qm backend default - - allowDecimals: false, - - allowExponential: false, - - computeUpDown: function (value) { - var me = this; - - if (!me.hotplug) { - return { up: value + me.step, down: value - me.step }; - } - - var dimm_size = 512; - var prev_dimm_size = 0; - var min_size = 1024; - var current_size = min_size; - var value_up = min_size; - var value_down = min_size; - var value_start = min_size; - - var i, j; - for (j = 0; j < 9; j++) { - for (i = 0; i < 32; i++) { - if (value >= current_size && value < current_size + dimm_size) { - value_start = current_size; - value_up = current_size + dimm_size; - value_down = current_size - (i === 0 ? prev_dimm_size : dimm_size); - } - current_size += dimm_size; - } - prev_dimm_size = dimm_size; - dimm_size = dimm_size * 2; - } - - return { up: value_up, down: value_down, start: value_start }; - }, - - onSpinUp: function () { - var me = this; - if (!me.readOnly) { - let res = me.computeUpDown(me.getValue()); - me.setValue(Ext.Number.constrain(res.up, me.minValue, me.maxValue)); - } - }, - - onSpinDown: function () { - var me = this; - if (!me.readOnly) { - let res = me.computeUpDown(me.getValue()); - me.setValue(Ext.Number.constrain(res.down, me.minValue, me.maxValue)); - } - }, - - initComponent: function () { - var me = this; - - if (me.hotplug) { - me.minValue = 1024; - - me.on('blur', function (field) { - var value = me.getValue(); - var res = me.computeUpDown(value); - if (value === res.start || value === res.up || value === res.down) { - return; - } - field.setValue(res.up); - }); - } - - me.callParent(); - }, -}); -Ext.define('PVE.form.MultiPCISelector', { - extend: 'Ext.grid.Panel', - alias: 'widget.pveMultiPCISelector', - - emptyText: gettext('No Devices found'), - - mixins: { - field: 'Ext.form.field.Field', - }, - - // will be called after loading finished - onLoadCallBack: Ext.emptyFn, - - getValue: function () { - let me = this; - return me.value ?? []; - }, - - getSubmitData: function () { - let me = this; - let res = {}; - res[me.name] = me.getValue(); - return res; - }, - - setValue: function (value) { - let me = this; - - value ??= []; - - me.updateSelectedDevices(value); - - return me.mixins.field.setValue.call(me, value); - }, - - getErrors: function () { - let me = this; - - let errorCls = ['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']; - - if (me.getValue().length < 1) { - let error = gettext('Must choose at least one device'); - me.addCls(errorCls); - me.getActionEl()?.dom.setAttribute('data-errorqtip', error); - - return [error]; - } - - me.removeCls(errorCls); - me.getActionEl()?.dom.setAttribute('data-errorqtip', ''); - - return []; - }, - - viewConfig: { - getRowClass: function (record) { - if (record.data.disabled === true) { - return 'x-item-disabled'; - } - return ''; - }, - }, - - updateSelectedDevices: function (value = []) { - let me = this; - - let recs = []; - let store = me.getStore(); - - for (const map of value) { - let parsed = PVE.Parser.parsePropertyString(map); - if (parsed.node !== me.nodename) { - continue; - } - - let rec = store.getById(parsed.path); - if (rec) { - recs.push(rec); - } - } - - me.suspendEvent('change'); - me.setSelection(); - me.setSelection(recs); - me.resumeEvent('change'); - }, - - setNodename: function (nodename) { - let me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.getStore().setProxy({ - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/hardware/pci?pci-class-blacklist=', - }); - - me.setSelection(); - - me.getStore().load({ - callback: (recs, op, success) => me.addSlotRecords(recs, op, success), - }); - }, - - setMdev: function (mdev) { - let me = this; - if (mdev) { - me.getStore().addFilter({ - id: 'mdev-filter', - property: 'mdev', - value: '1', - operator: '=', - }); - } else { - me.getStore().removeFilter('mdev-filter'); - } - me.setSelection(); - }, - - // adds the virtual 'slot' records (e.g. '0000:01:00') to the store - addSlotRecords: function (records, _op, success) { - let me = this; - if (!success) { - return; - } - - let slots = {}; - records.forEach((rec) => { - let slotname = rec.data.id.slice(0, -2); // remove function - if (slots[slotname] !== undefined) { - slots[slotname].count++; - rec.set('slot', slots[slotname]); - return; - } - slots[slotname] = { - count: 1, - }; - - rec.set('slot', slots[slotname]); - - if (rec.data.id.endsWith('.0')) { - slots[slotname].device = rec.data; - } - }); - - let store = me.getStore(); - - for (const [slot, { count, device }] of Object.entries(slots)) { - if (count === 1) { - continue; - } - store.add( - Ext.apply( - {}, - { - id: slot, - mdev: undefined, - device_name: gettext('Pass through all functions as one device'), - }, - device, - ), - ); - } - - me.updateSelectedDevices(me.value); - }, - - selectionChange: function (_grid, selection) { - let me = this; - - let ids = {}; - selection - .filter((rec) => rec.data.id.indexOf('.') === -1) - .forEach((rec) => { - ids[rec.data.id] = true; - }); - - let to_disable = []; - - me.getStore().each((rec) => { - let id = rec.data.id; - rec.set('disabled', false); - if (id.indexOf('.') === -1) { - return; - } - let slot = id.slice(0, -2); // remove function - - if (ids[slot]) { - to_disable.push(rec); - rec.set('disabled', true); - } - }); - - me.suspendEvent('selectionchange'); - me.getSelectionModel().deselect(to_disable); - me.resumeEvent('selectionchange'); - - me.value = me.getSelection().map((rec) => { - let res = { - path: rec.data.id, - node: me.nodename, - id: `${rec.data.vendor}:${rec.data.device}`.replace(/0x/g, ''), - 'subsystem-id': `${rec.data.subsystem_vendor}:${rec.data.subsystem_device}`.replace( - /0x/g, - '', - ), - }; - - if (rec.data.iommugroup !== -1) { - res.iommugroup = rec.data.iommugroup; - } - - return PVE.Parser.printPropertyString(res); - }); - me.checkChange(); - }, - - selModel: { - type: 'checkboxmodel', - mode: 'SIMPLE', - }, - - columns: [ - { - header: 'ID', - dataIndex: 'id', - renderer: function (value, _md, rec) { - if (value.match(/\.[0-9a-f]/i) && rec.data.slot?.count > 1) { - return ` ${value}`; - } - return value; - }, - width: 150, - }, - { - header: gettext('IOMMU Group'), - dataIndex: 'iommugroup', - renderer: (v, _md, rec) => (rec.data.slot === rec.data.id ? '' : v === -1 ? '-' : v), - width: 50, - }, - { - header: gettext('Vendor'), - dataIndex: 'vendor_name', - flex: 3, - }, - { - header: gettext('Device'), - dataIndex: 'device_name', - flex: 6, - }, - { - header: gettext('Mediated Devices'), - dataIndex: 'mdev', - flex: 1, - renderer: function (val) { - return Proxmox.Utils.format_boolean(!!val); - }, - }, - ], - - listeners: { - selectionchange: function () { - this.selectionChange(...arguments); - }, - }, - - store: { - fields: [ - 'id', - 'vendor_name', - 'device_name', - 'vendor', - 'device', - 'iommugroup', - 'mdev', - 'subsystem_vendor', - 'subsystem_device', - 'disabled', - { - name: 'subsystem-vendor', - calculate: function (data) { - return data.subsystem_vendor; - }, - }, - { - name: 'subsystem-device', - calculate: function (data) { - return data.subsystem_device; - }, - }, - ], - sorters: [ - { - property: 'id', - direction: 'ASC', - }, - ], - }, - - initComponent: function () { - let me = this; - - let nodename = me.nodename; - me.nodename = undefined; - - me.callParent(); - - me.mon(me.getStore(), 'load', me.onLoadCallBack); - - Proxmox.Utils.monStoreErrors(me, me.getStore(), true); - - me.setNodename(nodename); - - me.initField(); - }, -}); -Ext.define('PVE.form.NetworkCardSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: 'widget.pveNetworkCardSelector', - comboItems: [ - ['e1000', 'Intel E1000'], - ['e1000e', 'Intel E1000E'], - ['virtio', 'VirtIO (' + gettext('paravirtualized') + ')'], - ['rtl8139', 'Realtek RTL8139'], - ['vmxnet3', 'VMware vmxnet3'], - ], -}); -Ext.define('PVE.form.NodeSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveNodeSelector'], - - // invalidate nodes which are offline - onlineValidator: false, - - selectCurNode: false, - - // do not allow those nodes (array) - disallowedNodes: undefined, - - // only allow those nodes (array) - allowedNodes: undefined, - - valueField: 'node', - displayField: 'node', - store: { - fields: ['node', 'cpu', 'maxcpu', 'mem', 'maxmem', 'uptime'], - proxy: { - type: 'proxmox', - url: '/api2/json/nodes', - }, - sorters: [ - { - property: 'node', - direction: 'ASC', - }, - { - property: 'mem', - direction: 'DESC', - }, - ], - }, - - listConfig: { - columns: [ - { - header: gettext('Node'), - dataIndex: 'node', - sortable: true, - hideable: false, - flex: 1, - }, - { - header: gettext('Memory usage') + ' %', - renderer: PVE.Utils.render_mem_usage_percent, - sortable: true, - width: 100, - dataIndex: 'mem', - }, - { - header: gettext('CPU usage'), - renderer: Proxmox.Utils.render_cpu, - sortable: true, - width: 100, - dataIndex: 'cpu', - }, - ], - }, - - validator: function (value) { - let me = this; - if (!me.onlineValidator || (me.allowBlank && !value)) { - return true; - } - - let offline = [], - notAllowed = []; - Ext.Array.each(value.split(/\s*,\s*/), function (node) { - let rec = me.store.findRecord(me.valueField, node, 0, false, true, true); - if (!(rec && rec.data) || rec.data.status !== 'online') { - offline.push(node); - } else if (me.allowedNodes && !Ext.Array.contains(me.allowedNodes, node)) { - notAllowed.push(node); - } - }); - - if (value && notAllowed.length !== 0) { - return 'Node ' + notAllowed.join(', ') + ' is not allowed for this action!'; - } - if (value && offline.length !== 0) { - return 'Node ' + offline.join(', ') + ' seems to be offline!'; - } - return true; - }, - - initComponent: function () { - var me = this; - - if (me.selectCurNode && PVE.curSelectedNode && PVE.curSelectedNode.data.node) { - me.preferredValue = PVE.curSelectedNode.data.node; - } - - me.callParent(); - me.getStore().load(); - - me.getStore().addFilter( - new Ext.util.Filter({ - // filter out disallowed nodes - filterFn: (item) => - !(me.disallowedNodes && me.disallowedNodes.includes(item.data.node)), - }), - ); - - me.mon(me.getStore(), 'load', () => me.isValid()); - }, -}); -Ext.define('PVE.form.NotificationModeSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveNotificationModeSelector'], - comboItems: [ - ['notification-target', gettext('Target')], - ['mailto', gettext('E-Mail')], - ], -}); -Ext.define('PVE.form.NotificationTargetSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveNotificationTargetSelector'], - - // set default value to empty array, else it inits it with - // null and after the store load it is an empty array, - // triggering dirtychange - value: [], - valueField: 'name', - displayField: 'name', - deleteEmpty: true, - skipEmptyText: true, - - store: { - fields: ['name', 'type', 'comment'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/notifications/targets', - }, - sorters: [ - { - property: 'name', - direction: 'ASC', - }, - ], - autoLoad: true, - }, - - listConfig: { - columns: [ - { - header: gettext('Target'), - dataIndex: 'name', - sortable: true, - hideable: false, - flex: 1, - }, - { - header: gettext('Type'), - dataIndex: 'type', - sortable: true, - hideable: false, - flex: 1, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - sortable: true, - hideable: false, - flex: 2, - }, - ], - }, -}); -Ext.define('PVE.form.EmailNotificationSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveEmailNotificationSelector'], - comboItems: [ - ['always', gettext('Always')], - ['failure', gettext('On failure only')], - ], -}); -Ext.define('PVE.form.PCISelector', { - extend: 'Proxmox.form.ComboGrid', - xtype: 'pvePCISelector', - - store: { - fields: ['id', 'vendor_name', 'device_name', 'vendor', 'device', 'iommugroup', 'mdev'], - filterOnLoad: true, - sorters: [ - { - property: 'id', - direction: 'ASC', - }, - ], - }, - - autoSelect: false, - valueField: 'id', - displayField: 'id', - - // can contain a load callback for the store - // useful to determine the state of the IOMMU - onLoadCallBack: undefined, - - listConfig: { - minHeight: 80, - width: 800, - columns: [ - { - header: 'ID', - dataIndex: 'id', - width: 100, - }, - { - header: gettext('IOMMU Group'), - dataIndex: 'iommugroup', - renderer: (v) => (v === -1 ? '-' : v), - width: 75, - }, - { - header: gettext('Vendor'), - dataIndex: 'vendor_name', - flex: 2, - }, - { - header: gettext('Device'), - dataIndex: 'device_name', - flex: 6, - }, - { - header: gettext('Mediated Devices'), - dataIndex: 'mdev', - flex: 1, - renderer: function (val) { - return Proxmox.Utils.format_boolean(!!val); - }, - }, - ], - }, - - setNodename: function (nodename) { - var me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.store.setProxy({ - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/hardware/pci', - }); - - me.store.load(); - }, - - initComponent: function () { - var me = this; - - var nodename = me.nodename; - me.nodename = undefined; - - me.callParent(); - - if (me.onLoadCallBack !== undefined) { - me.mon(me.getStore(), 'load', me.onLoadCallBack); - } - - me.setNodename(nodename); - }, -}); -Ext.define('pve-mapped-pci-model', { - extend: 'Ext.data.Model', - - fields: ['id', 'path', 'vendor', 'device', 'iommugroup', 'mdev', 'description', 'map'], - idProperty: 'id', -}); - -Ext.define('PVE.form.PCIMapSelector', { - extend: 'Proxmox.form.ComboGrid', - xtype: 'pvePCIMapSelector', - - store: { - model: 'pve-mapped-pci-model', - filterOnLoad: true, - sorters: [ - { - property: 'id', - direction: 'ASC', - }, - ], - }, - - autoSelect: false, - valueField: 'id', - displayField: 'id', - - // can contain a load callback for the store - // useful to determine the state of the IOMMU - onLoadCallBack: undefined, - - listConfig: { - width: 800, - columns: [ - { - header: gettext('ID'), - dataIndex: 'id', - flex: 1, - }, - { - header: gettext('Description'), - dataIndex: 'description', - flex: 1, - renderer: Ext.String.htmlEncode, - }, - { - header: gettext('Status'), - dataIndex: 'checks', - renderer: function (value) { - let _me = this; - - if (!Ext.isArray(value) || !value?.length) { - return ` ${gettext('Mapping matches host data')}`; - } - - let checks = []; - - value.forEach((check) => { - let iconCls; - switch (check?.severity) { - case 'warning': - iconCls = 'fa-exclamation-circle warning'; - break; - case 'error': - iconCls = 'fa-times-circle critical'; - break; - } - - let message = check?.message; - let icon = ``; - if (iconCls !== undefined) { - checks.push(`${icon} ${message}`); - } - }); - - return checks.join('
    '); - }, - flex: 3, - }, - ], - }, - - setNodename: function (nodename) { - var me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.store.setProxy({ - type: 'proxmox', - url: `/api2/json/cluster/mapping/pci?check-node=${nodename}`, - }); - - me.store.load(); - }, - - initComponent: function () { - var me = this; - - var nodename = me.nodename; - me.nodename = undefined; - - me.callParent(); - - if (me.onLoadCallBack !== undefined) { - me.mon(me.getStore(), 'load', me.onLoadCallBack); - } - - me.setNodename(nodename); - }, -}); -Ext.define('PVE.form.PermPathSelector', { - extend: 'Ext.form.field.ComboBox', - xtype: 'pvePermPathSelector', - - valueField: 'value', - displayField: 'value', - typeAhead: true, - queryMode: 'local', - width: 380, - - store: { - type: 'pvePermPath', - }, -}); -Ext.define( - 'PVE.form.PoolSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pvePoolSelector'], - - allowBlank: false, - valueField: 'poolid', - displayField: 'poolid', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-pools', - sorters: 'poolid', - }); - - Ext.apply(me, { - store: store, - autoSelect: false, - listConfig: { - columns: [ - { - header: gettext('Pool'), - sortable: true, - dataIndex: 'poolid', - flex: 1, - }, - { - header: gettext('Comment'), - sortable: false, - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - }, - }); - - me.callParent(); - - store.load(); - }, - }, - function () { - Ext.define('pve-pools', { - extend: 'Ext.data.Model', - fields: ['poolid', 'comment'], - proxy: { - type: 'proxmox', - url: '/api2/json/pools', - }, - idProperty: 'poolid', - }); - }, -); -Ext.define('PVE.form.preallocationSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pvePreallocationSelector'], - comboItems: [ - ['__default__', Proxmox.Utils.defaultText], - ['off', 'Off'], - ['metadata', 'Metadata'], - ['falloc', 'Full (posix_fallocate)'], - ['full', 'Full'], - ], -}); -Ext.define('PVE.form.PrivilegesSelector', { - extend: 'Proxmox.form.KVComboBox', - xtype: 'pvePrivilegesSelector', - - multiSelect: true, - - initComponent: function () { - let me = this; - - me.callParent(); - - Proxmox.Utils.API2Request({ - url: '/access/roles/Administrator', - method: 'GET', - success: function (response, options) { - let data = Object.keys(response.result.data).map((key) => [key, key]); - - me.store.setData(data); - - me.store.sort({ - property: 'key', - direction: 'ASC', - }); - }, - failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, -}); -Ext.define('PVE.form.QemuBiosSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveQemuBiosSelector'], - - initComponent: function () { - var me = this; - - me.comboItems = [ - ['__default__', PVE.Utils.render_qemu_bios('')], - ['seabios', PVE.Utils.render_qemu_bios('seabios')], - ['ovmf', PVE.Utils.render_qemu_bios('ovmf')], - ]; - - me.callParent(); - }, -}); -Ext.define( - 'PVE.form.SDNControllerSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveSDNControllerSelector'], - - allowBlank: false, - valueField: 'controller', - displayField: 'controller', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-sdn-controller', - sorters: { - property: 'controller', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - autoSelect: false, - listConfig: { - columns: [ - { - header: gettext('Controller'), - sortable: true, - dataIndex: 'controller', - flex: 1, - }, - ], - }, - }); - - me.callParent(); - - store.load(); - }, - }, - function () { - Ext.define('pve-sdn-controller', { - extend: 'Ext.data.Model', - fields: ['controller'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/controllers', - }, - idProperty: 'controller', - }); - }, -); -Ext.define( - 'PVE.form.SDNZoneSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveSDNZoneSelector'], - - allowBlank: false, - valueField: 'zone', - displayField: 'zone', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-sdn-zone', - sorters: { - property: 'zone', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - autoSelect: false, - listConfig: { - columns: [ - { - header: gettext('Zone'), - sortable: true, - dataIndex: 'zone', - flex: 1, - }, - ], - }, - }); - - me.callParent(); - - store.load(); - }, - }, - function () { - Ext.define('pve-sdn-zone', { - extend: 'Ext.data.Model', - fields: ['zone', 'type'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/zones', - }, - idProperty: 'zone', - }); - }, -); -Ext.define( - 'PVE.form.SDNVnetSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveSDNVnetSelector'], - - allowBlank: false, - valueField: 'vnet', - displayField: 'vnet', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-sdn-vnet', - sorters: { - property: 'vnet', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - autoSelect: false, - listConfig: { - columns: [ - { - header: gettext('VNet'), - sortable: true, - dataIndex: 'vnet', - flex: 1, - }, - { - header: gettext('Alias'), - flex: 1, - dataIndex: 'alias', - }, - { - header: gettext('Tag'), - flex: 1, - dataIndex: 'tag', - }, - ], - }, - }); - - me.callParent(); - - store.load(); - }, - }, - function () { - Ext.define('pve-sdn-vnet', { - extend: 'Ext.data.Model', - fields: ['alias', 'tag', 'type', 'vnet', 'zone'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/vnets', - }, - idProperty: 'vnet', - }); - }, -); -Ext.define( - 'PVE.form.SDNIpamSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveSDNIpamSelector'], - - allowBlank: false, - valueField: 'ipam', - displayField: 'ipam', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-sdn-ipam', - sorters: { - property: 'ipam', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - autoSelect: false, - listConfig: { - columns: [ - { - header: gettext('Ipam'), - sortable: true, - dataIndex: 'ipam', - flex: 1, - }, - ], - }, - }); - - me.callParent(); - - store.load(); - }, - }, - function () { - Ext.define('pve-sdn-ipam', { - extend: 'Ext.data.Model', - fields: ['ipam'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/ipams', - }, - idProperty: 'ipam', - }); - }, -); -Ext.define( - 'PVE.form.SDNDnsSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveSDNDnsSelector'], - - allowBlank: false, - valueField: 'dns', - displayField: 'dns', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-sdn-dns', - sorters: { - property: 'dns', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - autoSelect: false, - listConfig: { - columns: [ - { - header: gettext('dns'), - sortable: true, - dataIndex: 'dns', - flex: 1, - }, - ], - }, - }); - - me.callParent(); - - store.load(); - }, - }, - function () { - Ext.define('pve-sdn-dns', { - extend: 'Ext.data.Model', - fields: ['dns'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/dns', - }, - idProperty: 'dns', - }); - }, -); -Ext.define('PVE.form.ScsiHwSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveScsiHwSelector'], - comboItems: [ - ['__default__', PVE.Utils.render_scsihw('')], - ['lsi', PVE.Utils.render_scsihw('lsi')], - ['lsi53c810', PVE.Utils.render_scsihw('lsi53c810')], - ['megasas', PVE.Utils.render_scsihw('megasas')], - ['virtio-scsi-pci', PVE.Utils.render_scsihw('virtio-scsi-pci')], - ['virtio-scsi-single', PVE.Utils.render_scsihw('virtio-scsi-single')], - ['pvscsi', PVE.Utils.render_scsihw('pvscsi')], - ], -}); -Ext.define('PVE.form.SecurityGroupsSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveSecurityGroupsSelector'], - - valueField: 'group', - displayField: 'group', - initComponent: function () { - var me = this; - - var store = Ext.create('Ext.data.Store', { - autoLoad: true, - fields: ['group', 'comment'], - idProperty: 'group', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/firewall/groups', - }, - sorters: { - property: 'group', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - listConfig: { - columns: [ - { - header: gettext('Security Group'), - dataIndex: 'group', - hideable: false, - width: 100, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: function (value, metaData) { - let comment = Ext.String.htmlEncode(value) || ''; - if (comment.length * 12 > metaData.column.cellWidth) { - let qtip = Ext.htmlEncode(comment); - comment = `${comment}`; - } - return comment; - }, - flex: 1, - }, - ], - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.form.SnapshotSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.PVE.form.SnapshotSelector'], - - valueField: 'name', - displayField: 'name', - - loadStore: function (nodename, vmid) { - var me = this; - - if (!nodename) { - return; - } - - me.nodename = nodename; - - if (!vmid) { - return; - } - - me.vmid = vmid; - - me.store.setProxy({ - type: 'proxmox', - url: - '/api2/json/nodes/' + - me.nodename + - '/' + - me.guestType + - '/' + - me.vmid + - '/snapshot', - }); - - me.store.load(); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - if (!me.guestType) { - throw 'no guest type specified'; - } - - var store = Ext.create('Ext.data.Store', { - fields: ['name'], - filterOnLoad: true, - }); - - Ext.apply(me, { - store: store, - listConfig: { - columns: [ - { - header: gettext('Snapshot'), - dataIndex: 'name', - hideable: false, - flex: 1, - }, - ], - }, - }); - - me.callParent(); - - me.loadStore(me.nodename, me.vmid); - }, -}); -Ext.define('PVE.form.SpiceEnhancementSelector', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveSpiceEnhancementSelector', - - viewModel: {}, - - items: [ - { - xtype: 'proxmoxcheckbox', - itemId: 'foldersharing', - name: 'foldersharing', - reference: 'foldersharing', - fieldLabel: 'Folder Sharing', - uncheckedValue: 0, - }, - { - xtype: 'proxmoxKVComboBox', - itemId: 'videostreaming', - name: 'videostreaming', - value: 'off', - fieldLabel: 'Video Streaming', - comboItems: [ - ['off', 'off'], - ['all', 'all'], - ['filter', 'filter'], - ], - }, - { - xtype: 'displayfield', - itemId: 'spicehint', - userCls: 'pmx-hint', - value: gettext( - 'To use these features set the display to SPICE in the hardware settings of the VM.', - ), - hidden: true, - }, - { - xtype: 'displayfield', - itemId: 'spicefolderhint', - userCls: 'pmx-hint', - value: gettext('Make sure the SPICE WebDav daemon is installed in the VM.'), - bind: { - hidden: '{!foldersharing.checked}', - }, - }, - ], - - onGetValues: function (values) { - var ret = {}; - - if (values.videostreaming !== 'off') { - ret.videostreaming = values.videostreaming; - } - if (values.foldersharing) { - ret.foldersharing = 1; - } - if (Ext.Object.isEmpty(ret)) { - return { delete: 'spice_enhancements' }; - } - var enhancements = PVE.Parser.printPropertyString(ret); - return { spice_enhancements: enhancements }; - }, - - setValues: function (values) { - var vga = PVE.Parser.parsePropertyString(values.vga, 'type'); - if (!/^qxl\d?$/.test(vga.type)) { - this.down('#spicehint').setVisible(true); - } - if (values.spice_enhancements) { - let enhancements = PVE.Parser.parsePropertyString(values.spice_enhancements); - enhancements.foldersharing = PVE.Parser.parseBoolean(enhancements.foldersharing, 0); - this.callParent([enhancements]); - } - }, -}); -Ext.define('PVE.form.StorageScanNodeSelector', { - extend: 'PVE.form.NodeSelector', - xtype: 'pveStorageScanNodeSelector', - - name: 'storageScanNode', - itemId: 'pveStorageScanNodeSelector', - fieldLabel: gettext('Scan node'), - allowBlank: true, - disallowedNodes: undefined, - autoSelect: false, - submitValue: false, - value: null, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Scan for available storages on the selected node'), - }, - triggers: { - clear: { - handler: function () { - let me = this; - me.setValue(null); - }, - }, - }, - - emptyText: Proxmox.NodeName, - - setValue: function (value) { - let me = this; - me.callParent([value]); - me.triggers.clear.setVisible(!!value); - }, -}); -Ext.define( - 'PVE.form.StorageSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: 'widget.pveStorageSelector', - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: { - clusterView: false, - }, - - allowBlank: false, - valueField: 'storage', - displayField: 'storage', - listConfig: { - cbind: { - clusterView: '{clusterView}', - }, - width: 450, - columns: [ - { - header: gettext('Name'), - dataIndex: 'storage', - hideable: false, - flex: 1, - }, - { - header: gettext('Type'), - width: 75, - dataIndex: 'type', - }, - { - header: gettext('Avail'), - width: 90, - dataIndex: 'avail', - renderer: Proxmox.Utils.format_size, - cbind: { - hidden: '{clusterView}', - }, - }, - { - header: gettext('Capacity'), - width: 90, - dataIndex: 'total', - renderer: Proxmox.Utils.format_size, - cbind: { - hidden: '{clusterView}', - }, - }, - { - header: gettext('Nodes'), - width: 120, - dataIndex: 'nodes', - renderer: (value) => (value ? value : '-- ' + gettext('All') + ' --'), - cbind: { - hidden: '{!clusterView}', - }, - }, - { - header: gettext('Shared'), - width: 70, - dataIndex: 'shared', - renderer: Proxmox.Utils.format_boolean, - cbind: { - hidden: '{!clusterView}', - }, - }, - ], - }, - - reloadStorageList: function () { - let me = this; - - if (me.clusterView) { - me.getStore().setProxy({ - type: 'proxmox', - url: `/api2/json/storage`, - }); - - // filter here, back-end does not support it currently - let filters = [(storage) => !storage.data.disable]; - - if (me.storageContent) { - filters.push((storage) => - storage.data.content.split(',').includes(me.storageContent), - ); - } - - if (me.nodename) { - filters.push( - (storage) => - !storage.data.nodes || storage.data.nodes.includes(me.nodename), - ); - } - - me.getStore().clearFilter(); - me.getStore().setFilters(filters); - } else { - if (!me.nodename) { - return; - } - - let params = { - format: 1, - }; - if (me.storageContent) { - params.content = me.storageContent; - } - if (me.targetNode) { - params.target = me.targetNode; - params.enabled = 1; // skip disabled storages - } - me.store.setProxy({ - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/storage`, - extraParams: params, - }); - } - - me.store.load(() => me.validate()); - }, - - setTargetNode: function (targetNode) { - var me = this; - - if (!targetNode || me.targetNode === targetNode) { - return; - } - - if (me.clusterView) { - throw 'setting targetNode with clusterView is not implemented'; - } - - me.targetNode = targetNode; - - me.reloadStorageList(); - }, - - setNodename: function (nodename) { - var me = this; - - nodename = nodename || ''; - - if (me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.reloadStorageList(); - }, - - initComponent: function () { - var me = this; - - let nodename = me.nodename; - me.nodename = undefined; - - var store = Ext.create('Ext.data.Store', { - model: 'pve-storage-status', - sorters: { - property: 'storage', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - - me.setNodename(nodename); - }, - }, - function () { - Ext.define('pve-storage-status', { - extend: 'Ext.data.Model', - fields: ['storage', 'active', 'type', 'avail', 'total', 'nodes', 'shared'], - idProperty: 'storage', - }); - }, -); -Ext.define('PVE.form.TFASelector', { - extend: 'Ext.container.Container', - xtype: 'pveTFASelector', - mixins: ['Proxmox.Mixin.CBind'], - - deleteEmpty: true, - - viewModel: { - data: { - type: '__default__', - step: null, - digits: null, - id: null, - key: null, - url: null, - }, - - formulas: { - isOath: (get) => get('type') === 'oath', - isYubico: (get) => get('type') === 'yubico', - tfavalue: { - get: function (get) { - let val = { - type: get('type'), - }; - if (get('isOath')) { - let step = get('step'); - let digits = get('digits'); - if (step) { - val.step = step; - } - if (digits) { - val.digits = digits; - } - } else if (get('isYubico')) { - let id = get('id'); - let key = get('key'); - let url = get('url'); - val.id = id; - val.key = key; - if (url) { - val.url = url; - } - } else if (val.type === '__default__') { - return ''; - } - - return PVE.Parser.printPropertyString(val); - }, - set: function (value) { - let val = PVE.Parser.parseTfaConfig(value); - this.set(val); - this.notify(); - // we need to reset the original values, so that - // we can reliably track the state of the form - let form = this.getView().up('form'); - if (form.trackResetOnLoad) { - let fields = this.getView().query('field[name!="tfa"]'); - fields.forEach((field) => field.resetOriginalValue()); - } - }, - }, - }, - }, - - items: [ - { - xtype: 'proxmoxtextfield', - name: 'tfa', - hidden: true, - submitValue: true, - cbind: { - deleteEmpty: '{deleteEmpty}', - }, - bind: { - value: '{tfavalue}', - }, - }, - { - xtype: 'proxmoxKVComboBox', - value: '__default__', - deleteEmpty: false, - submitValue: false, - fieldLabel: gettext('Require TFA'), - comboItems: [ - ['__default__', Proxmox.Utils.noneText], - ['oath', 'OATH/TOTP'], - ['yubico', 'Yubico'], - ], - bind: { - value: '{type}', - }, - }, - { - xtype: 'proxmoxintegerfield', - hidden: true, - minValue: 10, - submitValue: false, - emptyText: Proxmox.Utils.defaultText + ' (30)', - fieldLabel: gettext('Time Step'), - bind: { - value: '{step}', - hidden: '{!isOath}', - disabled: '{!isOath}', - }, - }, - { - xtype: 'proxmoxintegerfield', - hidden: true, - submitValue: false, - fieldLabel: gettext('Secret Length'), - minValue: 6, - maxValue: 8, - emptyText: Proxmox.Utils.defaultText + ' (6)', - bind: { - value: '{digits}', - hidden: '{!isOath}', - disabled: '{!isOath}', - }, - }, - { - xtype: 'textfield', - hidden: true, - submitValue: false, - allowBlank: false, - fieldLabel: 'Yubico API Id', - bind: { - value: '{id}', - hidden: '{!isYubico}', - disabled: '{!isYubico}', - }, - }, - { - xtype: 'textfield', - hidden: true, - submitValue: false, - allowBlank: false, - fieldLabel: 'Yubico API Key', - bind: { - value: '{key}', - hidden: '{!isYubico}', - disabled: '{!isYubico}', - }, - }, - { - xtype: 'textfield', - hidden: true, - submitValue: false, - fieldLabel: 'Yubico URL', - bind: { - value: '{url}', - hidden: '{!isYubico}', - disabled: '{!isYubico}', - }, - }, - ], -}); -Ext.define( - 'PVE.form.TokenSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveTokenSelector'], - - allowBlank: false, - autoSelect: false, - displayField: 'id', - - editable: true, - anyMatch: true, - forceSelection: true, - - store: { - model: 'pve-tokens', - autoLoad: true, - proxy: { - type: 'proxmox', - url: 'api2/json/access/users', - extraParams: { full: 1 }, - }, - sorters: 'id', - listeners: { - load: function (store, records, success) { - let tokens = []; - for (const { data: user } of records) { - if (!user.tokens || user.tokens.length === 0) { - continue; - } - for (const token of user.tokens) { - tokens.push({ - id: `${user.userid}!${token.tokenid}`, - comment: token.comment, - }); - } - } - store.loadData(tokens); - }, - }, - }, - - listConfig: { - columns: [ - { - header: gettext('API Token'), - sortable: true, - dataIndex: 'id', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - { - header: gettext('Comment'), - sortable: false, - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - }, - }, - function () { - Ext.define('pve-tokens', { - extend: 'Ext.data.Model', - fields: [ - 'id', - 'userid', - 'tokenid', - 'comment', - { type: 'boolean', name: 'privsep' }, - { type: 'date', dateFormat: 'timestamp', name: 'expire' }, - ], - idProperty: 'id', - }); - }, -); -Ext.define( - 'PVE.form.USBSelector', - { - extend: 'Proxmox.form.ComboGrid', - alias: ['widget.pveUSBSelector'], - - allowBlank: false, - autoSelect: false, - anyMatch: true, - displayField: 'product_and_id', - valueField: 'usbid', - editable: true, - - validator: function (value) { - var me = this; - if (!value) { - return true; // handled later by allowEmpty in the getErrors call chain - } - value = me.getValue(); // as the valueField is not the displayfield - if (me.type === 'device') { - return /^[a-f0-9]{4}:[a-f0-9]{4}$/i.test(value); - } else if (me.type === 'port') { - return /^[0-9]+-[0-9]+(\.[0-9]+)*$/.test(value); - } - return gettext('Invalid Value'); - }, - - setNodename: function (nodename) { - var me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.store.setProxy({ - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/hardware/usb`, - }); - - me.store.load(); - }, - - initComponent: function () { - var me = this; - - if (me.pveSelNode) { - me.nodename = me.pveSelNode.data.node; - } - - var nodename = me.nodename; - me.nodename = undefined; - - if (me.type !== 'device' && me.type !== 'port') { - throw 'no valid type specified'; - } - - let store = new Ext.data.Store({ - model: `pve-usb-${me.type}`, - filters: [ - ({ data }) => !!data.usbpath && !!data.prodid && String(data.class) !== '9', - ], - }); - let emptyText = ''; - if (me.type === 'device') { - emptyText = gettext('Passthrough a specific device'); - } else { - emptyText = gettext('Passthrough a full port'); - } - - Ext.apply(me, { - store: store, - emptyText: emptyText, - listConfig: { - minHeight: 80, - width: 520, - columns: [ - { - header: me.type === 'device' ? gettext('Device') : gettext('Port'), - sortable: true, - dataIndex: 'usbid', - width: 80, - }, - { - header: gettext('Manufacturer'), - sortable: true, - dataIndex: 'manufacturer', - width: 150, - }, - { - header: gettext('Product'), - sortable: true, - dataIndex: 'product', - flex: 1, - }, - { - header: gettext('Speed'), - width: 75, - sortable: true, - dataIndex: 'speed', - renderer: function (value) { - let speed2Class = { - 10000: 'USB 3.1', - 5000: 'USB 3.0', - 480: 'USB 2.0', - 12: 'USB 1.x', - 1.5: 'USB 1.x', - }; - return speed2Class[value] || value + ' Mbps'; - }, - }, - ], - }, - }); - - me.callParent(); - - me.setNodename(nodename); - }, - }, - function () { - Ext.define('pve-usb-device', { - extend: 'Ext.data.Model', - fields: [ - { - name: 'usbid', - convert: function (val, data) { - if (val) { - return val; - } - return data.get('vendid') + ':' + data.get('prodid'); - }, - }, - 'speed', - 'product', - 'manufacturer', - 'vendid', - 'prodid', - 'usbpath', - { name: 'port', type: 'number' }, - { name: 'level', type: 'number' }, - { name: 'class', type: 'number' }, - { name: 'devnum', type: 'number' }, - { name: 'busnum', type: 'number' }, - { - name: 'product_and_id', - type: 'string', - convert: (v, rec) => { - let res = rec.data.product || gettext('Unknown'); - res += ' (' + rec.data.usbid + ')'; - return res; - }, - }, - ], - }); - - Ext.define('pve-usb-port', { - extend: 'Ext.data.Model', - fields: [ - { - name: 'usbid', - convert: function (val, data) { - if (val) { - return val; - } - return data.get('busnum') + '-' + data.get('usbpath'); - }, - }, - 'speed', - 'product', - 'manufacturer', - 'vendid', - 'prodid', - 'usbpath', - { name: 'port', type: 'number' }, - { name: 'level', type: 'number' }, - { name: 'class', type: 'number' }, - { name: 'devnum', type: 'number' }, - { name: 'busnum', type: 'number' }, - { - name: 'product_and_id', - type: 'string', - convert: (v, rec) => { - let res = rec.data.product || gettext('Unplugged'); - res += ' (' + rec.data.usbid + ')'; - return res; - }, - }, - ], - }); - }, -); -Ext.define('PVE.form.USBMapSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: 'widget.pveUSBMapSelector', - - store: { - fields: ['name', 'vendor', 'device', 'path'], - filterOnLoad: true, - sorters: [ - { - property: 'name', - direction: 'ASC', - }, - ], - }, - - allowBlank: false, - autoSelect: false, - displayField: 'id', - valueField: 'id', - - listConfig: { - width: 800, - columns: [ - { - header: gettext('Name'), - dataIndex: 'id', - flex: 1, - }, - { - header: gettext('Status'), - dataIndex: 'errors', - flex: 2, - renderer: function (value) { - let _me = this; - - if (!Ext.isArray(value) || !value?.length) { - return ` ${gettext('Mapping matches host data')}`; - } - - let errors = []; - - value.forEach((error) => { - let iconCls; - switch (error?.severity) { - case 'warning': - iconCls = 'fa-exclamation-circle warning'; - break; - case 'error': - iconCls = 'fa-times-circle critical'; - break; - } - - let message = error?.message; - let icon = ``; - if (iconCls !== undefined) { - errors.push(`${icon} ${message}`); - } - }); - - return errors.join('
    '); - }, - }, - { - header: gettext('Comment'), - dataIndex: 'description', - flex: 1, - renderer: Ext.String.htmlEncode, - }, - ], - }, - - setNodename: function (nodename) { - var me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - - me.nodename = nodename; - - me.store.setProxy({ - type: 'proxmox', - url: `/api2/json/cluster/mapping/usb?check-node=${nodename}`, - }); - - me.store.load(); - }, - - initComponent: function () { - var me = this; - - var nodename = me.nodename; - me.nodename = undefined; - - me.callParent(); - - me.setNodename(nodename); - }, -}); -Ext.define('pmx-users', { - extend: 'Ext.data.Model', - fields: [ - 'userid', - 'firstname', - 'lastname', - 'email', - 'comment', - { type: 'boolean', name: 'enable' }, - { type: 'date', dateFormat: 'timestamp', name: 'expire' }, - ], - proxy: { - type: 'proxmox', - url: '/api2/json/access/users?full=1', - }, - idProperty: 'userid', -}); -Ext.define('PVE.form.VlanField', { - extend: 'Ext.form.field.Number', - alias: ['widget.pveVlanField'], - - deleteEmpty: false, - - emptyText: gettext('no VLAN'), - - fieldLabel: gettext('VLAN Tag'), - - allowBlank: true, - - getSubmitData: function () { - var me = this, - data = null, - val; - if (!me.disabled && me.submitValue) { - val = me.getSubmitValue(); - if (val) { - data = {}; - data[me.getName()] = val; - } else if (me.deleteEmpty) { - data = {}; - data.delete = me.getName(); - } - } - return data; - }, - - initComponent: function () { - var me = this; - - Ext.apply(me, { - minValue: 1, - maxValue: 4094, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.form.VMCPUFlagSelector', { - extend: 'Ext.grid.Panel', - alias: 'widget.vmcpuflagselector', - - mixins: { - field: 'Ext.form.field.Field', - }, - - disableSelection: true, - columnLines: false, - selectable: false, - hideHeaders: true, - - scrollable: 'y', - height: 200, - - unkownFlags: [], - - store: { - type: 'store', - fields: ['name', { name: 'state', defaultValue: '=' }, 'description'], - autoLoad: true, - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/localhost/capabilities/qemu/cpu-flags', - }, - listeners: { - update: function () { - this.commitChanges(); - }, - refresh: function (store, eOpts) { - let me = this; - let view = me.view; - - if (store.adjustedForValue !== view.value) { - view.adjustStoreForValue(); - } - }, - }, - adjustedForValue: undefined, - }, - - getValue: function () { - let me = this; - let store = me.getStore(); - - if (!store.isLoaded()) { - return me.value; - } - - let flags = ''; - - store.getData().each(function (rec) { - let s = rec.get('state'); - if (s && s !== '=') { - let f = rec.get('name'); - if (flags === '') { - flags = s + f; - } else { - flags += ';' + s + f; - } - } - }); - - flags += me.unkownFlags.join(';'); - - return flags; - }, - - // Adjusts the store for the current value and determines the unkown flags based on what the - // store does not know. - adjustStoreForValue: function () { - let me = this; - let store = me.getStore(); - let value = me.value; - - me.unkownFlags = []; - - store.getData().each((rec) => rec.set('state', '=')); - - let flags = value ? value.split(';') : []; - flags.forEach(function (flag) { - let sign = flag.substr(0, 1); - flag = flag.substr(1); - - let rec = store.findRecord('name', flag, 0, false, true, true); - if (rec !== null) { - rec.set('state', sign); - } else { - me.unkownFlags.push(flag); - } - }); - - store.adjustedForValue = value; - }, - - setValue: function (value) { - let me = this; - - me.value = value || ''; - - if (me.getStore().isLoaded()) { - me.adjustStoreForValue(); - } // if not yet loaded, the store will trigger the function - - let res = me.mixins.field.setValue.call(me, value); - - return res; - }, - columns: [ - { - dataIndex: 'state', - renderer: function (v) { - switch (v) { - case '=': - return 'Default'; - case '-': - return 'Off'; - case '+': - return 'On'; - default: - return 'Unknown'; - } - }, - width: 65, - }, - { - xtype: 'widgetcolumn', - dataIndex: 'state', - width: 95, - onWidgetAttach: function (column, widget, record) { - let val = record.get('state') || '='; - widget.down('[inputValue=' + val + ']').setValue(true); - // TODO: disable if selected CPU model and flag are incompatible - }, - widget: { - xtype: 'radiogroup', - hideLabel: true, - layout: 'hbox', - validateOnChange: false, - value: '=', - listeners: { - change: function (f, value) { - let v = Object.values(value)[0]; - f.getWidgetRecord().set('state', v); - - let view = this.up('grid'); - view.dirty = view.getValue() !== view.originalValue; - view.checkDirty(); - //view.checkChange(); - }, - }, - items: [ - { - boxLabel: '-', - boxLabelAlign: 'before', - inputValue: '-', - isFormField: false, - }, - { - checked: true, - inputValue: '=', - isFormField: false, - }, - { - boxLabel: '+', - inputValue: '+', - isFormField: false, - }, - ], - }, - }, - { - dataIndex: 'name', - width: 100, - }, - { - dataIndex: 'description', - cellWrap: true, - flex: 1, - }, - ], - - initComponent: function () { - let me = this; - - me.value = me.originalValue = ''; - me.store.view = me; - - me.callParent(arguments); - }, -}); -/* filter is a javascript builtin, but extjs calls it also filter */ -Ext.define('PVE.form.VMSelector', { - extend: 'Ext.grid.Panel', - alias: 'widget.vmselector', - - mixins: { - field: 'Ext.form.field.Field', - }, - - allowBlank: true, - selectAll: false, - isFormField: true, - - plugins: 'gridfilters', - - store: { - model: 'PVEResources', - sorters: 'vmid', - }, - - userCls: 'proxmox-tags-circle', - - columnsDeclaration: [ - { - header: 'ID', - dataIndex: 'vmid', - width: 80, - filter: { - type: 'number', - }, - }, - { - header: gettext('Node'), - dataIndex: 'node', - }, - { - header: gettext('Status'), - dataIndex: 'status', - filter: { - type: 'list', - }, - }, - { - header: gettext('Name'), - dataIndex: 'name', - flex: 1, - filter: { - type: 'string', - }, - }, - { - header: gettext('Pool'), - dataIndex: 'pool', - filter: { - type: 'list', - }, - }, - { - header: gettext('Type'), - dataIndex: 'type', - width: 120, - renderer: function (value) { - if (value === 'qemu') { - return gettext('Virtual Machine'); - } else if (value === 'lxc') { - return gettext('LXC Container'); - } - - return ''; - }, - filter: { - type: 'list', - store: { - data: [ - { id: 'qemu', text: gettext('Virtual Machine') }, - { id: 'lxc', text: gettext('LXC Container') }, - ], - un: function () { - // Due to EXTJS-18711. we have to do a static list via a store but to avoid - // creating an object, we have to have an empty pseudo un function - }, - }, - }, - }, - { - header: gettext('Tags'), - dataIndex: 'tags', - renderer: (tags) => PVE.Utils.renderTags(tags, PVE.UIOptions.tagOverrides), - flex: 1, - }, - { - header: 'HA ' + gettext('Status'), - dataIndex: 'hastate', - flex: 1, - filter: { - type: 'list', - }, - }, - ], - - // should be a list of 'dataIndex' values, if 'undefined' all declared columns will be included - columnSelection: undefined, - - selModel: { - selType: 'checkboxmodel', - mode: 'SIMPLE', - }, - - checkChangeEvents: ['selectionchange', 'change'], - - listeners: { - selectionchange: function () { - // to trigger validity and error checks - this.checkChange(); - }, - }, - - getValue: function () { - var me = this; - if (me.savedValue !== undefined) { - return me.savedValue; - } - var sm = me.getSelectionModel(); - var selection = sm.getSelection(); - var values = []; - var store = me.getStore(); - selection.forEach(function (item) { - // only add if not filtered - if (store.findExact('vmid', item.data.vmid) !== -1) { - values.push(item.data.vmid); - } - }); - return values; - }, - - setValueSelection: function (value) { - let me = this; - - let store = me.getStore(); - let notFound = []; - let selection = value - .map((item) => { - let found = store.findRecord('vmid', item, 0, false, true, true); - if (!found) { - if (Ext.isNumeric(item)) { - notFound.push(item); - } else { - console.warn(`invalid item in vm selection: ${item}`); - } - } - return found; - }) - .filter((r) => r); - - for (const vmid of notFound) { - let rec = store.add({ - vmid, - node: 'unknown', - }); - selection.push(rec[0]); - } - - let sm = me.getSelectionModel(); - if (selection.length) { - sm.select(selection); - } else { - sm.deselectAll(); - } - // to correctly trigger invalid class - me.getErrors(); - }, - - setValue: function (value) { - let me = this; - value ??= []; - if (!Ext.isArray(value)) { - value = value.split(',').filter((v) => v !== ''); - } - - let store = me.getStore(); - if (!store.isLoaded()) { - me.savedValue = value; - store.on( - 'load', - function () { - me.setValueSelection(value); - delete me.savedValue; - }, - { single: true }, - ); - } else { - me.setValueSelection(value); - } - return me.mixins.field.setValue.call(me, value); - }, - - getErrors: function (value) { - let me = this; - if (!me.isDisabled() && me.allowBlank === false && me.getValue().length === 0) { - me.addBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']); - return [gettext('No VM selected')]; - } - - me.removeBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']); - return []; - }, - - setDisabled: function (disabled) { - let me = this; - let res = me.callParent([disabled]); - me.getErrors(); - return res; - }, - - initComponent: function () { - let me = this; - - let columns = me.columnsDeclaration - .filter((column) => - me.columnSelection ? me.columnSelection.indexOf(column.dataIndex) !== -1 : true, - ) - .map((x) => x); - - me.columns = columns; - - me.callParent(); - - me.getStore().load({ params: { type: 'vm' } }); - - if (me.nodename) { - me.getStore().addFilter({ - property: 'node', - exactMatch: true, - value: me.nodename, - }); - } - - // only show the relevant guests by default - if (me.action) { - let statusfilter = ''; - switch (me.action) { - case 'startall': - statusfilter = 'stopped'; - break; - case 'stopall': - statusfilter = 'running'; - break; - } - if (statusfilter !== '') { - me.getStore().addFilter([ - { - property: 'template', - value: 0, - }, - { - id: 'x-gridfilter-status', - operator: 'in', - property: 'status', - value: [statusfilter], - }, - ]); - } - } - - if (me.selectAll) { - me.mon(me.getStore(), 'load', function () { - me.getSelectionModel().selectAll(false); - }); - } - }, -}); - -Ext.define('PVE.form.VMComboSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: 'widget.vmComboSelector', - - valueField: 'vmid', - displayField: 'vmid', - - autoSelect: false, - editable: true, - anyMatch: true, - forceSelection: true, - - store: { - model: 'PVEResources', - autoLoad: true, - sorters: 'vmid', - filters: [ - { - property: 'type', - value: /lxc|qemu/, - }, - ], - }, - - listConfig: { - width: 600, - plugins: 'gridfilters', - columns: [ - { - header: 'ID', - dataIndex: 'vmid', - width: 80, - filter: { - type: 'number', - }, - }, - { - header: gettext('Name'), - dataIndex: 'name', - flex: 1, - filter: { - type: 'string', - }, - }, - { - header: gettext('Node'), - dataIndex: 'node', - }, - { - header: gettext('Status'), - dataIndex: 'status', - filter: { - type: 'list', - }, - }, - { - header: gettext('Pool'), - dataIndex: 'pool', - hidden: true, - filter: { - type: 'list', - }, - }, - { - header: gettext('Type'), - dataIndex: 'type', - width: 120, - renderer: function (value) { - if (value === 'qemu') { - return gettext('Virtual Machine'); - } else if (value === 'lxc') { - return gettext('LXC Container'); - } - - return ''; - }, - filter: { - type: 'list', - store: { - data: [ - { id: 'qemu', text: gettext('Virtual Machine') }, - { id: 'lxc', text: gettext('LXC Container') }, - ], - un: function () { - /* due to EXTJS-18711 */ - }, - }, - }, - }, - { - header: 'HA ' + gettext('Status'), - dataIndex: 'hastate', - hidden: true, - flex: 1, - filter: { - type: 'list', - }, - }, - ], - }, -}); -Ext.define('PVE.form.VNCKeyboardSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.VNCKeyboardSelector'], - comboItems: Object.entries(PVE.Utils.kvm_keymaps), -}); -/* - * Top left combobox, used to select a view of the underneath RessourceTree - */ -Ext.define('PVE.form.ViewSelector', { - extend: 'Ext.form.field.ComboBox', - alias: ['widget.pveViewSelector'], - - editable: false, - allowBlank: false, - forceSelection: true, - autoSelect: false, - valueField: 'key', - displayField: 'value', - hideLabel: true, - queryMode: 'local', - - initComponent: function () { - let me = this; - - let default_views = { - server: { - text: gettext('Server View'), - groups: ['node'], - }, - folder: { - text: gettext('Folder View'), - groups: ['type'], - }, - pool: { - text: gettext('Pool View'), - groups: ['pool'], - // Pool View only lists VMs and Containers - getFilterFn: - () => - ({ data }) => - data.type === 'qemu' || data.type === 'lxc' || data.type === 'pool', - }, - tags: { - text: gettext('Tag View'), - groups: ['tag'], - getFilterFn: - () => - ({ data }) => - ['qemu', 'lxc', 'node', 'storage'].indexOf(data.type) !== -1, - groupRenderer: function (info) { - let tag = PVE.Utils.renderTags(info.tag, PVE.UIOptions.tagOverrides); - return `${tag}`; - }, - itemMap: function (item) { - let tags = (item.data.tags ?? '').split(/[;, ]/); - if (tags.length === 1 && tags[0] === '') { - return item; - } - let items = []; - for (const tag of tags) { - let id = `${item.data.id}-${tag}`; - let info = Ext.apply({ leaf: true }, item.data); - info.tag = tag; - info.realId = info.id; - info.id = id; - items.push(Ext.create('Ext.data.TreeModel', info)); - } - return items; - }, - attrMoveChecks: { - tag: (newitem, olditem) => newitem.data.tags !== olditem.data.tags, - }, - }, - }; - let groupdef = Object.entries(default_views).map(([name, config]) => [name, config.text]); - - let store = Ext.create('Ext.data.Store', { - model: 'KeyValue', - proxy: { - type: 'memory', - reader: 'array', - }, - data: groupdef, - autoload: true, - }); - - Ext.apply(me, { - store: store, - value: groupdef[0][0], - getViewFilter: function () { - let view = me.getValue(); - return Ext.apply({ id: view }, default_views[view] || default_views.server); - }, - getState: function () { - return { value: me.getValue() }; - }, - applyState: function (state, doSelect) { - let view = me.getValue(); - if (state && state.value && view !== state.value) { - let record = store.findRecord('key', state.value, 0, false, true, true); - if (record) { - me.setValue(state.value, true); - if (doSelect) { - me.fireEvent('select', me, [record]); - } - } - } - }, - stateEvents: ['select'], - stateful: true, - stateId: 'pveview', - id: 'view', - }); - - me.callParent(); - - let statechange = function (sp, key, value) { - if (key === me.id) { - me.applyState(value, true); - } - }; - let sp = Ext.state.Manager.getProvider(); - me.mon(sp, 'statechange', statechange, me); - }, -}); -Ext.define('PVE.form.iScsiProviderSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveiScsiProviderSelector'], - comboItems: [ - ['comstar', 'Comstar'], - ['istgt', 'istgt'], - ['iet', 'IET'], - ['LIO', 'LIO'], - ], -}); -Ext.define('PVE.form.ColorPicker', { - extend: 'Ext.form.FieldContainer', - alias: 'widget.pveColorPicker', - - defaultBindProperty: 'value', - - config: { - value: null, - }, - - height: 24, - - layout: { - type: 'hbox', - align: 'stretch', - }, - - getValue: function () { - return this.realvalue.slice(1); - }, - - setValue: function (value) { - let me = this; - me.setColor(value); - if (value && value.length === 6) { - me.picker.value = value[0] !== '#' ? `#${value}` : value; - } - }, - - setColor: function (value) { - let me = this; - let oldValue = me.realvalue; - me.realvalue = value; - let color = value.length === 6 ? `#${value}` : undefined; - me.down('#picker').setStyle('background-color', color); - me.down('#text').setValue(value ?? ''); - me.fireEvent('change', me, me.realvalue, oldValue); - }, - - initComponent: function () { - let me = this; - me.picker = document.createElement('input'); - me.picker.type = 'color'; - me.picker.style = `opacity: 0; border: 0px; width: 100%; height: ${me.height}px`; - me.picker.value = `${me.value}`; - - me.items = [ - { - xtype: 'textfield', - itemId: 'text', - minLength: !me.allowBlank ? 6 : undefined, - maxLength: 6, - enforceMaxLength: true, - allowBlank: me.allowBlank, - emptyText: me.allowBlank ? gettext('Automatic') : undefined, - maskRe: /[a-f0-9]/i, - regex: /^[a-f0-9]{6}$/i, - flex: 1, - listeners: { - change: function (field, value) { - me.setValue(value); - }, - }, - }, - { - xtype: 'box', - style: { - 'margin-left': '1px', - border: '1px solid #cfcfcf', - }, - itemId: 'picker', - width: 24, - contentEl: me.picker, - }, - ]; - - me.callParent(); - me.picker.oninput = function () { - me.setColor(me.picker.value.slice(1)); - }; - }, -}); - -Ext.define('PVE.form.TagColorGrid', { - extend: 'Ext.grid.Panel', - alias: 'widget.pveTagColorGrid', - - mixins: ['Ext.form.field.Field'], - - allowBlank: true, - selectAll: false, - isFormField: true, - deleteEmpty: false, - selModel: 'checkboxmodel', - - config: { - deleteEmpty: false, - }, - - emptyText: gettext('No Overrides'), - viewConfig: { - deferEmptyText: false, - }, - - setValue: function (value) { - let me = this; - let colors; - if (Ext.isObject(value)) { - colors = value.colors; - } else { - colors = value; - } - if (!colors) { - me.getStore().removeAll(); - me.checkChange(); - return me; - } - let entries = (colors.split(';') || []).map((entry) => { - let [tag, bg, fg] = entry.split(':'); - fg = fg || ''; - return { - tag, - color: bg, - text: fg, - }; - }); - me.getStore().setData(entries); - me.checkChange(); - return me; - }, - - getValue: function () { - let me = this; - let values = []; - me.getStore().each((rec) => { - if (rec.data.tag) { - let val = `${rec.data.tag}:${rec.data.color}`; - if (rec.data.text) { - val += `:${rec.data.text}`; - } - values.push(val); - } - }); - return values.join(';'); - }, - - getErrors: function (value) { - let me = this; - let emptyTag = false; - let notValidColor = false; - let colorRegex = new RegExp(/^[0-9a-f]{6}$/i); - me.getStore().each((rec) => { - if (!rec.data.tag) { - emptyTag = true; - } - if (!rec.data.color?.match(colorRegex)) { - notValidColor = true; - } - if (rec.data.text && !rec.data.text?.match(colorRegex)) { - notValidColor = true; - } - }); - let errors = []; - if (emptyTag) { - errors.push(gettext('Tag must not be empty.')); - } - if (notValidColor) { - errors.push(gettext('Not a valid color.')); - } - return errors; - }, - - // override framework function to implement deleteEmpty behaviour - getSubmitData: function () { - let me = this, - data = null, - val; - if (!me.disabled && me.submitValue) { - val = me.getValue(); - if (val !== null && val !== '') { - data = {}; - data[me.getName()] = val; - } else if (me.getDeleteEmpty()) { - data = {}; - data.delete = me.getName(); - } - } - return data; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - addLine: function () { - let me = this; - me.getView().getStore().add({ - tag: '', - color: '', - text: '', - }); - }, - - removeSelection: function () { - let me = this; - let view = me.getView(); - let selection = view.getSelection(); - if (selection === undefined) { - return; - } - - selection.forEach((sel) => { - view.getStore().remove(sel); - }); - view.checkChange(); - }, - - tagChange: function (field, newValue, oldValue) { - let me = this; - let rec = field.getWidgetRecord(); - if (!rec) { - return; - } - if (newValue && newValue !== oldValue) { - let newrgb = Proxmox.Utils.stringToRGB(newValue); - let newvalue = Proxmox.Utils.rgbToHex(newrgb); - if (!rec.get('color')) { - rec.set('color', newvalue); - } else if (oldValue) { - let oldrgb = Proxmox.Utils.stringToRGB(oldValue); - let oldvalue = Proxmox.Utils.rgbToHex(oldrgb); - if (rec.get('color') === oldvalue) { - rec.set('color', newvalue); - } - } - } - me.fieldChange(field, newValue, oldValue); - }, - - backgroundChange: function (field, newValue, oldValue) { - let me = this; - let rec = field.getWidgetRecord(); - if (!rec) { - return; - } - if (newValue && newValue !== oldValue) { - let newrgb = Proxmox.Utils.hexToRGB(newValue); - let newcls = Proxmox.Utils.getTextContrastClass(newrgb); - let hexvalue = newcls === 'dark' ? '000000' : 'FFFFFF'; - if (!rec.get('text')) { - rec.set('text', hexvalue); - } else if (oldValue) { - let oldrgb = Proxmox.Utils.hexToRGB(oldValue); - let oldcls = Proxmox.Utils.getTextContrastClass(oldrgb); - let oldvalue = oldcls === 'dark' ? '000000' : 'FFFFFF'; - if (rec.get('text') === oldvalue) { - rec.set('text', hexvalue); - } - } - } - me.fieldChange(field, newValue, oldValue); - }, - - fieldChange: function (field, newValue, oldValue) { - let me = this; - let view = me.getView(); - let rec = field.getWidgetRecord(); - if (!rec) { - return; - } - let column = field.getWidgetColumn(); - rec.set(column.dataIndex, newValue); - view.checkChange(); - }, - }, - - tbar: [ - { - text: gettext('Add'), - handler: 'addLine', - }, - { - xtype: 'proxmoxButton', - text: gettext('Remove'), - handler: 'removeSelection', - disabled: true, - }, - ], - - columns: [ - { - header: 'Tag', - dataIndex: 'tag', - xtype: 'widgetcolumn', - onWidgetAttach: function (col, widget, rec) { - widget.getStore().setData(PVE.UIOptions.tagList.map((v) => ({ tag: v }))); - }, - widget: { - xtype: 'combobox', - isFormField: false, - maskRe: PVE.Utils.tagCharRegex, - allowBlank: false, - queryMode: 'local', - displayField: 'tag', - valueField: 'tag', - store: {}, - listeners: { - change: 'tagChange', - }, - }, - flex: 1, - }, - { - header: gettext('Background'), - xtype: 'widgetcolumn', - flex: 1, - dataIndex: 'color', - widget: { - xtype: 'pveColorPicker', - isFormField: false, - listeners: { - change: 'backgroundChange', - }, - }, - }, - { - header: gettext('Text'), - xtype: 'widgetcolumn', - flex: 1, - dataIndex: 'text', - widget: { - xtype: 'pveColorPicker', - allowBlank: true, - isFormField: false, - listeners: { - change: 'fieldChange', - }, - }, - }, - ], - - store: { - listeners: { - update: function () { - this.commitChanges(); - }, - }, - }, - - initComponent: function () { - let me = this; - me.callParent(); - me.initField(); - }, -}); -Ext.define('PVE.form.ListField', { - extend: 'Ext.container.Container', - alias: 'widget.pveListField', - - mixins: ['Ext.form.field.Field'], - - // override for column header - fieldTitle: gettext('Item'), - - // will be applied to the textfields - maskRe: undefined, - - allowBlank: true, - selectAll: false, - isFormField: true, - deleteEmpty: false, - config: { - deleteEmpty: false, - }, - - setValue: function (list) { - let me = this; - list = Ext.isArray(list) ? list : (list ?? '').split(';').filter((t) => t !== ''); - - let store = me.lookup('grid').getStore(); - if (list.length > 0) { - store.setData(list.map((item) => ({ item }))); - } else { - store.removeAll(); - } - me.checkChange(); - return me; - }, - - getValue: function () { - let me = this; - let values = []; - me.lookup('grid') - .getStore() - .each((rec) => { - if (rec.data.item) { - values.push(rec.data.item); - } - }); - return values.join(';'); - }, - - getErrors: function (value) { - let me = this; - let empty = false; - me.lookup('grid') - .getStore() - .each((rec) => { - if (!rec.data.item) { - empty = true; - } - }); - if (empty) { - return [gettext('Tag must not be empty.')]; - } - return []; - }, - - // override framework function to implement deleteEmpty behaviour - getSubmitData: function () { - let me = this, - data = null, - val; - if (!me.disabled && me.submitValue) { - val = me.getValue(); - if (val !== null && val !== '') { - data = {}; - data[me.getName()] = val; - } else if (me.getDeleteEmpty()) { - data = {}; - data.delete = me.getName(); - } - } - return data; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - addLine: function () { - let me = this; - me.lookup('grid').getStore().add({ - item: '', - }); - }, - - removeSelection: function (field) { - let me = this; - let view = me.getView(); - let grid = me.lookup('grid'); - - let record = field.getWidgetRecord(); - if (record === undefined) { - // this is sometimes called before a record/column is initialized - return; - } - - grid.getStore().remove(record); - view.checkChange(); - view.validate(); - }, - - itemChange: function (field, newValue) { - let rec = field.getWidgetRecord(); - if (!rec) { - return; - } - let column = field.getWidgetColumn(); - rec.set(column.dataIndex, newValue); - let list = field.up('pveListField'); - list.checkChange(); - list.validate(); - }, - - control: { - 'grid button': { - click: 'removeSelection', - }, - }, - }, - - items: [ - { - xtype: 'grid', - reference: 'grid', - - viewConfig: { - deferEmptyText: false, - }, - - store: { - listeners: { - update: function () { - this.commitChanges(); - }, - }, - }, - }, - { - xtype: 'button', - text: gettext('Add'), - iconCls: 'fa fa-plus-circle', - handler: 'addLine', - margin: '5 0 0 0', - }, - ], - - initComponent: function () { - let me = this; - - for (const [key, value] of Object.entries(me.gridConfig ?? {})) { - me.items[0][key] = value; - } - - me.items[0].columns = [ - { - header: me.fieldTtitle, - dataIndex: 'item', - xtype: 'widgetcolumn', - widget: { - xtype: 'textfield', - isFormField: false, - maskRe: me.maskRe, - allowBlank: false, - queryMode: 'local', - listeners: { - change: 'itemChange', - }, - }, - flex: 1, - }, - { - xtype: 'widgetcolumn', - width: 40, - widget: { - xtype: 'button', - iconCls: 'fa fa-trash-o', - }, - }, - ]; - - me.callParent(); - me.initField(); - }, -}); -Ext.define('Proxmox.form.Tag', { - extend: 'Ext.Component', - alias: 'widget.pveTag', - - mode: 'editable', - - tag: '', - cls: 'pve-edit-tag', - - tpl: [ - '', - '{tag}', - '', - ], - - focusable: true, - getFocusEl: function () { - return Ext.get(this.tagEl()); - }, - - onFocus: function () { - this.selectText(); - }, - - // contains tags not to show in the picker and not allowing to set - filter: [], - - updateFilter: function (tags) { - this.filter = tags; - }, - - onClick: function (event) { - let me = this; - if (event.target.tagName === 'I' && !event.target.classList.contains('handle')) { - if (me.mode === 'editable') { - me.destroy(); - return; - } - } else if (event.target.tagName !== 'SPAN' || me.mode !== 'editable') { - return; - } - me.selectText(); - }, - - selectText: function (collapseToEnd) { - let me = this; - let tagEl = me.tagEl(); - tagEl.contentEditable = true; - let range = document.createRange(); - range.selectNodeContents(tagEl); - if (collapseToEnd) { - range.collapse(false); - } - let sel = window.getSelection(); - sel.removeAllRanges(); - sel.addRange(range); - - me.showPicker(); - }, - - showPicker: function () { - let me = this; - if (!me.picker) { - me.picker = Ext.widget({ - xtype: 'boundlist', - minWidth: 70, - scrollable: true, - floating: true, - hidden: true, - userCls: 'proxmox-tags-full', - displayField: 'tag', - itemTpl: [ - '{[Proxmox.Utils.getTagElement(values.tag, PVE.UIOptions.tagOverrides)]}', - ], - store: [], - listeners: { - select: function (picker, rec) { - me.tagEl().innerHTML = rec.data.tag; - me.setTag(rec.data.tag, true); - me.selectText(true); - me.setColor(rec.data.tag); - me.picker.hide(); - }, - }, - }); - } - me.picker.getStore()?.clearFilter(); - let taglist = PVE.UIOptions.tagList - .filter((v) => !me.filter.includes(v)) - .map((v) => ({ tag: v })); - if (taglist.length < 1) { - return; - } - me.picker.getStore().setData(taglist); - me.picker.showBy(me, 'tl-bl'); - me.picker.setMaxHeight(200); - }, - - setMode: function (mode) { - let me = this; - let tagEl = me.tagEl(); - if (tagEl) { - tagEl.contentEditable = mode === 'editable'; - } - me.removeCls(me.mode); - me.addCls(mode); - me.mode = mode; - if (me.mode !== 'editable') { - me.picker?.hide(); - } - }, - - onKeyPress: function (event) { - let me = this; - let key = event.browserEvent.key; - switch (key) { - case 'Enter': - case 'Escape': - me.fireEvent('keypress', key); - break; - case 'ArrowLeft': - case 'ArrowRight': - case 'Backspace': - case 'Delete': - return; - default: - if (key.match(PVE.Utils.tagCharRegex)) { - return; - } - me.setTag(me.tagEl().innerHTML); - } - event.browserEvent.preventDefault(); - event.browserEvent.stopPropagation(); - }, - - // for pasting text - beforeInput: function (event) { - let me = this; - me.updateLayout(); - let tag = event.event.data ?? event.event.dataTransfer?.getData('text/plain'); - if (!tag) { - return; - } - if (tag.match(PVE.Utils.tagCharRegex) === null) { - event.event.preventDefault(); - event.event.stopPropagation(); - } - }, - - onInput: function (event) { - let me = this; - me.picker.getStore().filter({ - property: 'tag', - value: me.tagEl().innerHTML, - anyMatch: true, - }); - me.setTag(me.tagEl().innerHTML); - }, - - lostFocus: function (list, event) { - let me = this; - me.picker?.hide(); - window.getSelection().removeAllRanges(); - }, - - setColor: function (tag) { - let me = this; - let rgb = PVE.UIOptions.tagOverrides[tag] ?? Proxmox.Utils.stringToRGB(tag); - - let cls = Proxmox.Utils.getTextContrastClass(rgb); - let color = Proxmox.Utils.rgbToCss(rgb); - me.setUserCls(`proxmox-tag-${cls}`); - me.setStyle('background-color', color); - if (rgb.length > 3) { - let fgcolor = Proxmox.Utils.rgbToCss([rgb[3], rgb[4], rgb[5]]); - - me.setStyle('color', fgcolor); - } else { - me.setStyle('color'); - } - }, - - setTag: function (tag) { - let me = this; - let oldtag = me.tag; - me.tag = tag; - - clearTimeout(me.colorTimeout); - me.colorTimeout = setTimeout(() => me.setColor(tag), 200); - - me.updateLayout(); - if (oldtag !== tag) { - me.fireEvent('change', me, tag, oldtag); - } - }, - - tagEl: function () { - return this.el?.dom?.getElementsByTagName('span')?.[0]; - }, - - listeners: { - click: 'onClick', - focusleave: 'lostFocus', - keydown: 'onKeyPress', - beforeInput: 'beforeInput', - input: 'onInput', - element: 'el', - scope: 'this', - }, - - initComponent: function () { - let me = this; - - me.data = { - tag: me.tag, - }; - - me.setTag(me.tag); - me.setColor(me.tag); - me.setMode(me.mode ?? 'normal'); - me.callParent(); - }, - - destroy: function () { - let me = this; - if (me.picker) { - Ext.destroy(me.picker); - } - clearTimeout(me.colorTimeout); - me.callParent(); - }, -}); -Ext.define('PVE.panel.TagEditContainer', { - extend: 'Ext.container.Container', - alias: 'widget.pveTagEditContainer', - - layout: { - type: 'hbox', - align: 'middle', - }, - - // set to false to hide the 'no tags' field and the edit button - canEdit: true, - editOnly: false, - - controller: { - xclass: 'Ext.app.ViewController', - - loadTags: function (tagstring = '', force = false) { - let me = this; - let view = me.getView(); - - if (me.oldTags === tagstring && !force) { - return; - } - - view.suspendLayout = true; - me.forEachTag((tag) => { - view.remove(tag); - }); - me.getViewModel().set('tagCount', 0); - let newtags = tagstring.split(/[;, ]/).filter((t) => !!t) || []; - newtags.forEach((tag) => { - me.addTag(tag); - }); - view.suspendLayout = false; - view.updateLayout(); - if (!force) { - me.oldTags = tagstring; - } - me.tagsChanged(); - }, - - onRender: function (v) { - let me = this; - let view = me.getView(); - view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags()); - - view.dragzone = Ext.create('Ext.dd.DragZone', v.getEl(), { - getDragData: function (e) { - let source = e.getTarget('.handle'); - if (!source) { - return undefined; - } - let sourceId = source.parentNode.id; - let cmp = Ext.getCmp(sourceId); - let ddel = document.createElement('div'); - ddel.classList.add('proxmox-tags-full'); - ddel.innerHTML = Proxmox.Utils.getTagElement( - cmp.tag, - PVE.UIOptions.tagOverrides, - ); - let repairXY = Ext.fly(source).getXY(); - cmp.setDisabled(true); - ddel.id = Ext.id(); - return { - ddel, - repairXY, - sourceId, - }; - }, - onMouseUp: function (target, e, id) { - let cmp = Ext.getCmp(this.dragData.sourceId); - if (cmp && !cmp.isDestroyed) { - cmp.setDisabled(false); - } - }, - getRepairXY: function () { - return this.dragData.repairXY; - }, - beforeInvalidDrop: function (target, e, id) { - let cmp = Ext.getCmp(this.dragData.sourceId); - if (cmp && !cmp.isDestroyed) { - cmp.setDisabled(false); - } - }, - }); - view.dropzone = Ext.create('Ext.dd.DropZone', v.getEl(), { - getTargetFromEvent: function (e) { - return e.getTarget('.proxmox-tag-dark,.proxmox-tag-light'); - }, - getIndicator: function () { - if (!view.indicator) { - view.indicator = Ext.create('Ext.Component', { - floating: true, - html: '', - hidden: true, - shadow: false, - }); - } - return view.indicator; - }, - onContainerOver: function () { - this.getIndicator().setVisible(false); - }, - notifyOut: function () { - this.getIndicator().setVisible(false); - }, - onNodeOver: function (target, dd, e, data) { - let indicator = this.getIndicator(); - indicator.setVisible(true); - indicator.alignTo(Ext.getCmp(target.id), 't50-bl', [-1, -2]); - return this.dropAllowed; - }, - onNodeDrop: function (target, dd, e, data) { - this.getIndicator().setVisible(false); - let sourceCmp = Ext.getCmp(data.sourceId); - if (!sourceCmp) { - return; - } - sourceCmp.setDisabled(false); - let targetCmp = Ext.getCmp(target.id); - view.remove(sourceCmp, { destroy: false }); - view.insert(view.items.indexOf(targetCmp), sourceCmp); - me.tagsChanged(); - }, - }); - }, - - forEachTag: function (func) { - let me = this; - let view = me.getView(); - view.items.each((field) => { - if (field.getXType() === 'pveTag') { - func(field); - } - return true; - }); - }, - - toggleEdit: function (cancel) { - let me = this; - let vm = me.getViewModel(); - let view = me.getView(); - let editMode = !vm.get('editMode'); - vm.set('editMode', editMode); - - // get a current tag list for editing - if (editMode) { - PVE.UIOptions.update(); - } - - me.forEachTag((tag) => { - tag.setMode(editMode ? 'editable' : 'normal'); - }); - - if (!vm.get('editMode')) { - let tags = []; - if (cancel) { - me.loadTags(me.oldTags, true); - } else { - let toRemove = []; - me.forEachTag((cmp) => { - if (cmp.isVisible() && cmp.tag) { - tags.push(cmp.tag); - } else { - toRemove.push(cmp); - } - }); - toRemove.forEach((cmp) => view.remove(cmp)); - tags = tags.join(','); - if (me.oldTags !== tags) { - me.oldTags = tags; - me.loadTags(tags, true); - me.getView().fireEvent('change', tags); - } - } - } - me.getView().updateLayout(); - }, - - tagsChanged: function () { - let me = this; - let tags = []; - me.forEachTag((cmp) => { - if (cmp.tag) { - tags.push(cmp.tag); - } - }); - me.getViewModel().set('isDirty', me.oldTags !== tags.join(',')); - me.forEachTag((cmp) => { - cmp.updateFilter(tags); - }); - }, - - addTag: function (tag, isNew) { - let me = this; - let view = me.getView(); - let vm = me.getViewModel(); - let index = view.items.length - 5; - if (PVE.UIOptions.shouldSortTags() && !isNew) { - index = view.items.findIndexBy((tagField) => { - if (tagField.reference === 'noTagsField') { - return false; - } - if (tagField.xtype !== 'pveTag') { - return true; - } - let a = tagField.tag.toLowerCase(); - let b = tag.toLowerCase(); - return a > b ? true : a < b ? false : tagField.tag.localeCompare(tag) > 0; - }, 1); - } - let tagField = view.insert(index, { - xtype: 'pveTag', - tag, - mode: vm.get('editMode') ? 'editable' : 'normal', - listeners: { - change: 'tagsChanged', - destroy: function () { - vm.set('tagCount', vm.get('tagCount') - 1); - me.tagsChanged(); - }, - keypress: function (key) { - if (vm.get('hideFinishButtons')) { - return; - } - if (key === 'Enter') { - me.editClick(); - } else if (key === 'Escape') { - me.cancelClick(); - } - }, - }, - }); - - if (isNew) { - me.tagsChanged(); - tagField.selectText(); - } - - vm.set('tagCount', vm.get('tagCount') + 1); - }, - - addTagClick: function (event) { - let me = this; - me.lookup('noTagsField').setVisible(false); - me.addTag('', true); - }, - - cancelClick: function () { - this.toggleEdit(true); - }, - - editClick: function () { - this.toggleEdit(false); - }, - - init: function (view) { - let me = this; - if (view.tags) { - me.loadTags(view.tags); - } - me.getViewModel().set('canEdit', view.canEdit); - me.getViewModel().set('editOnly', view.editOnly); - - me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => { - let vm = me.getViewModel(); - view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags()); - me.loadTags(me.oldTags, !vm.get('editMode')); // refresh tag colors and order - }); - - if (view.editOnly) { - me.toggleEdit(); - } - }, - }, - - getTags: function () { - let me = this; - let controller = me.getController(); - let tags = []; - controller.forEachTag((cmp) => { - if (cmp.tag.length) { - tags.push(cmp.tag); - } - }); - - return tags; - }, - - viewModel: { - data: { - tagCount: 0, - editMode: false, - canEdit: true, - isDirty: false, - editOnly: true, - }, - - formulas: { - hideNoTags: function (get) { - return get('tagCount') !== 0 || !get('canEdit'); - }, - hideEditBtn: function (get) { - return get('editMode') || !get('canEdit'); - }, - hideFinishButtons: function (get) { - return !get('editMode') || get('editOnly'); - }, - }, - }, - - loadTags: function () { - return this.getController().loadTags(...arguments); - }, - - items: [ - { - xtype: 'box', - reference: 'noTagsField', - bind: { - hidden: '{hideNoTags}', - }, - html: gettext('No Tags'), - style: { - opacity: 0.5, - }, - }, - { - xtype: 'button', - iconCls: 'fa fa-plus', - tooltip: gettext('Add Tag'), - bind: { - hidden: '{!editMode}', - }, - hidden: true, - margin: '0 8 0 5', - ui: 'default-toolbar', - handler: 'addTagClick', - }, - { - xtype: 'tbseparator', - ui: 'horizontal', - bind: { - hidden: '{hideFinishButtons}', - }, - hidden: true, - }, - { - xtype: 'button', - iconCls: 'fa fa-times', - tooltip: gettext('Cancel Edit'), - bind: { - hidden: '{hideFinishButtons}', - }, - hidden: true, - margin: '0 5 0 0', - ui: 'default-toolbar', - handler: 'cancelClick', - }, - { - xtype: 'button', - iconCls: 'fa fa-check', - tooltip: gettext('Finish Edit'), - bind: { - hidden: '{hideFinishButtons}', - disabled: '{!isDirty}', - }, - hidden: true, - handler: 'editClick', - }, - { - xtype: 'box', - cls: 'pve-tag-inline-button', - html: ``, - bind: { - hidden: '{hideEditBtn}', - }, - listeners: { - click: 'editClick', - element: 'el', - }, - }, - ], - - listeners: { - render: 'onRender', - }, - - destroy: function () { - let me = this; - Ext.destroy(me.dragzone); - Ext.destroy(me.dropzone); - Ext.destroy(me.indicator); - me.callParent(); - }, -}); -// mostly copied from ExtJS FileButton, but added 'multiple' at the relevant -// places so we have a file picker where one can select multiple files -// changes are marked with an 'pmx:' comment -Ext.define('PVE.form.MultiFileButton', { - extend: 'Ext.form.field.FileButton', - alias: 'widget.pveMultiFileButton', - - afterTpl: [ - 'accept="{accept}"
    ', - 'tabindex="{tabIndex}"', - '>', - ], - - createFileInput: function (isTemporary) { - var me = this, - fileInputEl, - listeners; - - fileInputEl = me.fileInputEl = me.el.createChild( - { - name: me.inputName || me.id, - multiple: true, // pmx: added multiple option - id: !isTemporary ? me.id + '-fileInputEl' : undefined, - cls: me.inputCls + (me.getInherited().rtl ? ' ' + Ext.baseCSSPrefix + 'rtl' : ''), - tag: 'input', - type: 'file', - size: 1, - unselectable: 'on', - }, - me.afterInputGuard, - ); // Nothing special happens outside of IE/Edge - - // This is our focusEl - fileInputEl.dom.setAttribute('data-componentid', me.id); - - if (me.tabIndex !== null) { - me.setTabIndex(me.tabIndex); - } - - if (me.accept) { - fileInputEl.dom.setAttribute('accept', me.accept); - } - - // We place focus and blur listeners on fileInputEl to activate Button's - // focus and blur style treatment - listeners = { - scope: me, - change: me.fireChange, - mousedown: me.handlePrompt, - keydown: me.handlePrompt, - focus: me.onFileFocus, - blur: me.onFileBlur, - }; - - if (me.useTabGuards) { - listeners.keydown = me.onFileInputKeydown; - } - - fileInputEl.on(listeners); - }, -}); -Ext.define('PVE.form.TagFieldSet', { - extend: 'Ext.form.FieldSet', - alias: 'widget.pveTagFieldSet', - mixins: ['Ext.form.field.Field'], - - title: gettext('Tags'), - padding: '0 5 5 5', - - getValue: function () { - let me = this; - let tags = me - .down('pveTagEditContainer') - .getTags() - .filter((t) => t !== ''); - return tags.join(';'); - }, - - setValue: function (value) { - let me = this; - value ??= []; - if (!Ext.isArray(value)) { - value = value.split(/[;, ]/).filter((t) => t !== ''); - } - me.down('pveTagEditContainer').loadTags(value.join(';')); - }, - - getErrors: function (value) { - value ??= []; - if (!Ext.isArray(value)) { - value = value.split(/[;, ]/).filter((t) => t !== ''); - } - if (value.some((t) => !t.match(PVE.Utils.tagCharRegex))) { - return [gettext('Tags contain invalid characters.')]; - } - return []; - }, - - getSubmitData: function () { - let me = this; - let value = me.getValue(); - if (me.disabled || !me.submitValue || value === '') { - return null; - } - let data = {}; - data[me.getName()] = value; - return data; - }, - - layout: 'fit', - - items: [ - { - xtype: 'pveTagEditContainer', - userCls: 'proxmox-tags-full proxmox-tag-fieldset', - editOnly: true, - allowBlank: true, - layout: 'column', - scrollable: true, - }, - ], - - initComponent: function () { - let me = this; - me.callParent(); - me.initField(); - }, -}); -Ext.define('PVE.form.IsoSelector', { - extend: 'Ext.container.Container', - alias: 'widget.pveIsoSelector', - mixins: ['Ext.form.field.Field', 'Proxmox.Mixin.CBind'], - - layout: { - type: 'vbox', - align: 'stretch', - }, - - nodename: undefined, - insideWizard: false, - labelWidth: undefined, - labelAlign: 'right', - - cbindData: function () { - let me = this; - return { - nodename: me.nodename, - insideWizard: me.insideWizard, - }; - }, - - getValue: function () { - return this.lookup('file').getValue(); - }, - - setValue: function (value) { - let me = this; - if (!value) { - me.lookup('file').reset(); - return; - } - var match = value.match(/^([^:]+):/); - if (match) { - me.lookup('storage').setValue(match[1]); - me.lookup('file').setValue(value); - } - }, - - getErrors: function () { - let me = this; - me.lookup('storage').validate(); - let file = me.lookup('file'); - file.validate(); - let value = file.getValue(); - if (!value || !value.length) { - return ['']; // for validation - } - return []; - }, - - setNodename: function (nodename) { - let me = this; - me.lookup('storage').setNodename(nodename); - me.lookup('file').setStorage(undefined, nodename); - }, - - setDisabled: function (disabled) { - let me = this; - me.lookup('storage').setDisabled(disabled); - me.lookup('file').setDisabled(disabled); - return me.callParent([disabled]); - }, - - referenceHolder: true, - - items: [ - { - xtype: 'pveStorageSelector', - reference: 'storage', - isFormField: false, - fieldLabel: gettext('Storage'), - storageContent: 'iso', - allowBlank: false, - cbind: { - nodename: '{nodename}', - autoSelect: '{insideWizard}', - insideWizard: '{insideWizard}', - disabled: '{disabled}', - labelWidth: '{labelWidth}', - labelAlign: '{labelAlign}', - }, - listeners: { - change: function (f, value) { - let me = this; - let selector = me.up('pveIsoSelector'); - selector.lookup('file').setStorage(value); - selector.checkChange(); - }, - }, - }, - { - xtype: 'pveFileSelector', - reference: 'file', - isFormField: false, - storageContent: 'iso', - fieldLabel: gettext('ISO image'), - labelAlign: 'right', - cbind: { - nodename: '{nodename}', - disabled: '{disabled}', - labelWidth: '{labelWidth}', - labelAlign: '{labelAlign}', - }, - allowBlank: false, - listeners: { - change: function () { - this.up('pveIsoSelector').checkChange(); - }, - }, - }, - ], -}); -Ext.define('PVE.grid.BackupView', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveBackupView'], - - onlineHelp: 'chapter_vzdump', - - stateful: true, - stateId: 'grid-guest-backup', - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var vmtype = me.pveSelNode.data.type; - if (!vmtype) { - throw 'no VM type specified'; - } - - var vmtypeFilter; - if (vmtype === 'lxc' || vmtype === 'openvz') { - vmtypeFilter = function (item) { - return PVE.Utils.volume_is_lxc_backup(item.data); - }; - } else if (vmtype === 'qemu') { - vmtypeFilter = function (item) { - return PVE.Utils.volume_is_qemu_backup(item.data); - }; - } else { - throw "unsupported VM type '" + vmtype + "'"; - } - - let vmname = me.pveSelNode.data.name; - - var searchFilter = { - property: 'volid', - value: '', - anyMatch: true, - caseSensitive: false, - }; - - var vmidFilter = { - property: 'vmid', - value: vmid, - exactMatch: true, - }; - - me.store = Ext.create('Ext.data.Store', { - model: 'pve-storage-content', - sorters: [ - { - property: 'vmid', - direction: 'ASC', - }, - { - property: 'vdate', - direction: 'DESC', - }, - ], - filters: [vmtypeFilter, searchFilter, vmidFilter], - }); - - let updateFilter = function () { - me.store.filter([vmtypeFilter, searchFilter, vmidFilter]); - }; - - const reload = Ext.Function.createBuffered((options) => { - if (me.store) { - me.store.load(options); - } - }, 100); - - let isPBS = false; - var setStorage = function (storage) { - var url = '/api2/json/nodes/' + nodename + '/storage/' + storage + '/content'; - url += '?content=backup'; - - me.store.setProxy({ - type: 'proxmox', - url: url, - }); - - Proxmox.Utils.monStoreErrors(me.view, me.store, true); - - reload(); - }; - - let file_restore_btn; - - var storagesel = Ext.create('PVE.form.StorageSelector', { - nodename: nodename, - fieldLabel: gettext('Storage'), - labelAlign: 'right', - storageContent: 'backup', - allowBlank: false, - listeners: { - change: function (f, value) { - let storage = f.getStore().findRecord('storage', value, 0, false, true, true); - if (storage) { - isPBS = storage.data.type === 'pbs'; - me.getColumns().forEach((column) => { - let id = column.dataIndex; - if (id === 'verification' || id === 'encrypted') { - column.setHidden(!isPBS); - } - }); - } else { - isPBS = false; - } - setStorage(value); - if (file_restore_btn) { - file_restore_btn.setHidden(!isPBS); - } - }, - }, - }); - - var storagefilter = Ext.create('Ext.form.field.Text', { - fieldLabel: gettext('Search'), - labelWidth: 50, - labelAlign: 'right', - enableKeyEvents: true, - value: searchFilter.value, - listeners: { - buffer: 500, - keyup: function (field) { - me.store.clearFilter(true); - searchFilter.value = field.getValue(); - updateFilter(); - }, - }, - }); - - var vmidfilterCB = Ext.create('Ext.form.field.Checkbox', { - boxLabel: gettext('Filter VMID'), - value: '1', - listeners: { - change: function (cb, value) { - vmidFilter.value = value ? vmid : ''; - vmidFilter.exactMatch = !!value; - updateFilter(); - }, - }, - }); - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var backup_btn = Ext.create('Ext.button.Button', { - text: gettext('Backup now'), - handler: function () { - var win = Ext.create('PVE.window.Backup', { - nodename: nodename, - vmid: vmid, - vmtype: vmtype, - vmname: vmname, - storage: storagesel.getValue(), - listeners: { - close: function () { - reload(); - }, - }, - }); - win.show(); - }, - }); - - var restore_btn = Ext.create('Proxmox.button.Button', { - text: gettext('Restore'), - disabled: true, - selModel: sm, - enableFn: function (rec) { - return !!rec; - }, - handler: function (b, e, rec) { - let win = Ext.create('PVE.window.Restore', { - nodename: nodename, - vmid: vmid, - vmname: vmname, - volid: rec.data.volid, - volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec), - vmtype: vmtype, - isPBS: isPBS, - }); - win.show(); - win.on('destroy', reload); - }, - }); - - let delete_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - dangerous: true, - delay: 5, - enableFn: (rec) => !rec?.data?.protected, - confirmMsg: ({ data }) => { - let msg = Ext.String.format( - gettext('Are you sure you want to remove entry {0}'), - `'${data.volid}'`, - ); - return msg + ' ' + gettext('This will permanently erase all data.'); - }, - getUrl: ({ data }) => - `/nodes/${nodename}/storage/${storagesel.getValue()}/content/${data.volid}`, - callback: () => reload(), - }); - - let config_btn = Ext.create('Proxmox.button.Button', { - text: gettext('Show Configuration'), - disabled: true, - selModel: sm, - enableFn: (rec) => !!rec, - handler: function (b, e, rec) { - let storage = storagesel.getValue(); - if (!storage) { - return; - } - Ext.create('PVE.window.BackupConfig', { - volume: rec.data.volid, - pveSelNode: me.pveSelNode, - autoShow: true, - }); - }, - }); - - // declared above so that the storage selector can change this buttons hidden state - file_restore_btn = Ext.create('Proxmox.button.Button', { - text: gettext('File Restore'), - disabled: true, - selModel: sm, - enableFn: (rec) => !!rec && isPBS, - hidden: !isPBS, - handler: function (b, e, rec) { - let storage = storagesel.getValue(); - let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format); - Ext.create('Proxmox.window.FileBrowser', { - title: gettext('File Restore') + ' - ' + rec.data.text, - listURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/list`, - downloadURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/download`, - extraParams: { - volume: rec.data.volid, - }, - archive: isVMArchive ? 'all' : undefined, - autoShow: true, - }); - }, - }); - - Ext.apply(me, { - selModel: sm, - tbar: { - overflowHandler: 'scroller', - items: [ - backup_btn, - '-', - restore_btn, - file_restore_btn, - config_btn, - { - xtype: 'proxmoxButton', - text: gettext('Edit Notes'), - disabled: true, - handler: function () { - let volid = sm.getSelection()[0].data.volid; - var storage = storagesel.getValue(); - Ext.create('Proxmox.window.Edit', { - autoLoad: true, - width: 600, - height: 400, - resizable: true, - title: gettext('Notes'), - url: `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`, - layout: 'fit', - items: [ - { - xtype: 'textarea', - layout: 'fit', - name: 'notes', - height: '100%', - }, - ], - listeners: { - destroy: () => reload(), - }, - }).show(); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Change Protection'), - disabled: true, - handler: function (button, event, record) { - let volid = record.data.volid, - storage = storagesel.getValue(); - let url = `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`; - Proxmox.Utils.API2Request({ - url: url, - method: 'PUT', - waitMsgTarget: me, - params: { - protected: record.data.protected ? 0 : 1, - }, - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - success: () => { - reload({ - callback: () => - sm.fireEvent('selectionchange', sm, [record]), - }); - }, - }); - }, - }, - '-', - delete_btn, - '->', - storagesel, - '-', - vmidfilterCB, - storagefilter, - ], - }, - columns: [ - { - header: gettext('Name'), - flex: 2, - sortable: true, - renderer: PVE.Utils.render_storage_content, - dataIndex: 'volid', - }, - { - header: gettext('Notes'), - dataIndex: 'notes', - flex: 1, - renderer: Ext.htmlEncode, - }, - { - header: ``, - tooltip: gettext('Protected'), - width: 30, - renderer: (v) => - v ? `` : '', - sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0), - dataIndex: 'protected', - }, - { - header: gettext('Date'), - width: 150, - dataIndex: 'vdate', - }, - { - header: gettext('Format'), - width: 100, - dataIndex: 'format', - }, - { - header: gettext('Size'), - width: 100, - renderer: Proxmox.Utils.format_size, - dataIndex: 'size', - }, - { - header: 'VMID', - dataIndex: 'vmid', - hidden: true, - }, - { - header: gettext('Encrypted'), - dataIndex: 'encrypted', - renderer: PVE.Utils.render_backup_encryption, - }, - { - // TRANSLATORS: The state of the verification task - header: gettext('Verify State'), - dataIndex: 'verification', - renderer: PVE.Utils.render_backup_verification, - }, - ], - }); - - me.callParent(); - }, -}); -Ext.define('PVE.FirewallAliasEdit', { - extend: 'Proxmox.window.Edit', - - base_url: undefined, - - alias_name: undefined, - - width: 400, - - initComponent: function () { - let me = this; - - me.isCreate = me.alias_name === undefined; - - if (me.isCreate) { - me.url = '/api2/extjs' + me.base_url; - me.method = 'POST'; - } else { - me.url = '/api2/extjs' + me.base_url + '/' + me.alias_name; - me.method = 'PUT'; - } - - let ipanel = Ext.create('Proxmox.panel.InputPanel', { - isCreate: me.isCreate, - items: [ - { - xtype: 'textfield', - name: me.isCreate ? 'name' : 'rename', - fieldLabel: gettext('Name'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'cidr', - fieldLabel: gettext('IP/CIDR'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'comment', - fieldLabel: gettext('Comment'), - }, - ], - }); - - Ext.apply(me, { - subject: gettext('Alias'), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - let values = response.result.data; - values.rename = values.name; - ipanel.setValues(values); - }, - }); - } - }, -}); - -Ext.define('pve-fw-aliases', { - extend: 'Ext.data.Model', - - fields: ['name', 'cidr', 'comment', 'digest'], - idProperty: 'name', -}); - -Ext.define('PVE.FirewallAliases', { - extend: 'Ext.grid.Panel', - alias: ['widget.pveFirewallAliases'], - - onlineHelp: 'pve_firewall_ip_aliases', - - stateful: true, - stateId: 'grid-firewall-aliases', - - base_url: undefined, - - title: gettext('Alias'), - - initComponent: function () { - let me = this; - - if (!me.base_url) { - throw 'missing base_url configuration'; - } - - let store = new Ext.data.Store({ - model: 'pve-fw-aliases', - proxy: { - type: 'proxmox', - url: '/api2/json' + me.base_url, - }, - sorters: { - property: 'name', - direction: 'ASC', - }, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let caps = Ext.state.Manager.get('GuiCap'); - let canEdit = - !!caps.vms['VM.Config.Network'] || - !!caps.dc['Sys.Modify'] || - !!caps.nodes['Sys.Modify']; - - let reload = function () { - let oldrec = sm.getSelection()[0]; - store.load(function (records, operation, success) { - if (oldrec) { - let rec = store.findRecord('name', oldrec.data.name, 0, false, true, true); - if (rec) { - sm.select(rec); - } - } - }); - }; - - let run_editor = function () { - let rec = me.getSelectionModel().getSelection()[0]; - if (!rec || !canEdit) { - return; - } - let win = Ext.create('PVE.FirewallAliasEdit', { - base_url: me.base_url, - alias_name: rec.data.name, - }); - win.show(); - win.on('destroy', reload); - }; - - me.editBtn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - enableFn: (rec) => canEdit, - handler: run_editor, - }); - - me.addBtn = Ext.create('Ext.Button', { - text: gettext('Add'), - disabled: - !caps.vms['VM.Config.Network'] && - !caps.dc['Sys.Modify'] && - !caps.nodes['Sys.Modify'], - handler: function () { - var win = Ext.create('PVE.FirewallAliasEdit', { - base_url: me.base_url, - }); - win.on('destroy', reload); - win.show(); - }, - }); - - me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { - disabled: true, - selModel: sm, - enableFn: (rec) => - !!caps.vms['VM.Config.Network'] || - !!caps.dc['Sys.Modify'] || - !!caps.nodes['Sys.Modify'], - baseurl: me.base_url + '/', - callback: reload, - }); - - Ext.apply(me, { - store: store, - tbar: [me.addBtn, me.removeBtn, me.editBtn], - selModel: sm, - columns: [ - { - header: gettext('Name'), - dataIndex: 'name', - flex: 1, - }, - { - header: gettext('IP/CIDR'), - dataIndex: 'cidr', - flex: 1, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 3, - }, - ], - listeners: { - itemdblclick: run_editor, - }, - }); - - me.callParent(); - me.on('activate', reload); - }, -}); -Ext.define('PVE.FirewallOptions', { - extend: 'Proxmox.grid.ObjectGrid', - alias: ['widget.pveFirewallOptions'], - - fwtype: undefined, // 'dc', 'node', 'vm' or 'vnet' - - base_url: undefined, - - initComponent: function () { - var me = this; - - if (!['dc', 'node', 'vm', 'vnet'].includes(me.fwtype)) { - throw 'unknown firewall option type'; - } - - if (me.fwtype === 'node') { - me.cwidth1 = 250; - } - - let caps = Ext.state.Manager.get('GuiCap'); - let canEdit = - caps.vms['VM.Config.Network'] || caps.dc['Sys.Modify'] || caps.nodes['Sys.Modify']; - - me.rows = {}; - - var add_boolean_row = function (name, text, defaultValue) { - me.add_boolean_row(name, text, { defaultValue: defaultValue }); - }; - var add_integer_row = function (name, text, minValue, labelWidth) { - me.add_integer_row(name, text, { - minValue: minValue, - deleteEmpty: true, - labelWidth: labelWidth, - renderer: function (value) { - if (value === undefined) { - return Proxmox.Utils.defaultText; - } - - return value; - }, - }); - }; - - var add_log_row = function (name, labelWidth) { - me.rows[name] = { - header: name, - required: true, - defaultValue: 'nolog', - editor: { - xtype: 'proxmoxWindowEdit', - subject: name, - fieldDefaults: { labelWidth: labelWidth || 100 }, - items: { - xtype: 'pveFirewallLogLevels', - name: name, - fieldLabel: name, - }, - }, - }; - }; - - if (me.fwtype === 'node') { - me.rows.enable = { - required: true, - defaultValue: 1, - header: gettext('Firewall'), - renderer: Proxmox.Utils.format_boolean, - editor: { - xtype: 'pveFirewallEnableEdit', - defaultValue: 1, - }, - }; - add_boolean_row('nosmurfs', gettext('SMURFS filter'), 1); - add_boolean_row('tcpflags', gettext('TCP flags filter'), 0); - add_boolean_row('ndp', 'NDP', 1); - add_integer_row('nf_conntrack_max', 'nf_conntrack_max', 32768, 120); - add_integer_row( - 'nf_conntrack_tcp_timeout_established', - 'nf_conntrack_tcp_timeout_established', - 7875, - 250, - ); - add_log_row('log_level_in'); - add_log_row('log_level_out'); - add_log_row('log_level_forward'); - add_log_row('tcp_flags_log_level', 120); - add_log_row('smurf_log_level'); - add_boolean_row('nftables', gettext('nftables (tech preview)'), 0); - } else if (me.fwtype === 'vm') { - me.rows.enable = { - required: true, - defaultValue: 0, - header: gettext('Firewall'), - renderer: Proxmox.Utils.format_boolean, - editor: { - xtype: 'pveFirewallEnableEdit', - defaultValue: 0, - }, - }; - add_boolean_row('dhcp', 'DHCP', 1); - add_boolean_row('ndp', 'NDP', 1); - add_boolean_row('radv', gettext('Router Advertisement'), 0); - add_boolean_row('macfilter', gettext('MAC filter'), 1); - add_boolean_row('ipfilter', gettext('IP filter'), 0); - add_log_row('log_level_in'); - add_log_row('log_level_out'); - } else if (me.fwtype === 'dc') { - add_boolean_row('enable', gettext('Firewall'), 0); - add_boolean_row('ebtables', 'ebtables', 1); - me.rows.log_ratelimit = { - header: gettext('Log rate limit'), - required: true, - defaultValue: gettext('Default') + ' (enable=1,rate1/second,burst=5)', - editor: { - xtype: 'pveFirewallLograteEdit', - defaultValue: 'enable=1', - }, - }; - } else if (me.fwtype === 'vnet') { - add_boolean_row('enable', gettext('Firewall'), 0); - add_log_row('log_level_forward'); - } - - if (me.fwtype === 'dc' || me.fwtype === 'vm') { - me.rows.policy_in = { - header: gettext('Input Policy'), - required: true, - defaultValue: 'DROP', - editor: { - xtype: 'proxmoxWindowEdit', - subject: gettext('Input Policy'), - items: { - xtype: 'pveFirewallPolicySelector', - name: 'policy_in', - value: 'DROP', - fieldLabel: gettext('Input Policy'), - }, - }, - }; - - me.rows.policy_out = { - header: gettext('Output Policy'), - required: true, - defaultValue: 'ACCEPT', - editor: { - xtype: 'proxmoxWindowEdit', - subject: gettext('Output Policy'), - items: { - xtype: 'pveFirewallPolicySelector', - name: 'policy_out', - value: 'ACCEPT', - fieldLabel: gettext('Output Policy'), - }, - }, - }; - } - - if (me.fwtype === 'vnet' || me.fwtype === 'dc') { - me.rows.policy_forward = { - header: gettext('Forward Policy'), - required: true, - defaultValue: 'ACCEPT', - editor: { - xtype: 'proxmoxWindowEdit', - subject: gettext('Forward Policy'), - items: { - xtype: 'pveFirewallPolicySelector', - name: 'policy_forward', - value: 'ACCEPT', - fieldLabel: gettext('Forward Policy'), - comboItems: [ - ['ACCEPT', 'ACCEPT'], - ['DROP', 'DROP'], - ], - }, - }, - }; - } - - var edit_btn = new Ext.Button({ - text: gettext('Edit'), - disabled: true, - handler: function () { - me.run_editor(); - }, - }); - - var set_button_status = function () { - var sm = me.getSelectionModel(); - var rec = sm.getSelection()[0]; - - if (!rec) { - edit_btn.disable(); - return; - } - var rowdef = me.rows[rec.data.key]; - if (canEdit) { - edit_btn.setDisabled(!rowdef.editor); - } - }; - - Ext.apply(me, { - tbar: [edit_btn], - listeners: { - itemdblclick: () => { - if (canEdit) { - me.run_editor(); - } - }, - selectionchange: set_button_status, - }, - }); - - if (me.base_url) { - me.applyUrl(me.base_url); - } else { - me.rstore = Ext.create('Proxmox.data.ObjectStore', { - interval: me.interval, - extraParams: me.extraParams, - rows: me.rows, - }); - } - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - me.on('deactivate', me.rstore.stopUpdate); - }, - applyUrl: function (url) { - let me = this; - - Ext.apply(me, { - url: '/api2/json' + url, - editorConfig: { - url: '/api2/extjs/' + url, - }, - }); - }, - setBaseUrl: function (url) { - let me = this; - - me.base_url = url; - - me.applyUrl(url); - - me.rstore.getProxy().setConfig('url', `/api2/extjs/${url}`); - me.rstore.reload(); - }, -}); - -Ext.define('PVE.FirewallLogLevels', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveFirewallLogLevels'], - - name: 'log', - fieldLabel: gettext('Log level'), - value: 'nolog', - comboItems: [ - ['nolog', 'nolog'], - ['emerg', 'emerg'], - ['alert', 'alert'], - ['crit', 'crit'], - ['err', 'err'], - ['warning', 'warning'], - ['notice', 'notice'], - ['info', 'info'], - ['debug', 'debug'], - ], -}); -Ext.define('PVE.form.FWMacroSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: 'widget.pveFWMacroSelector', - - allowBlank: true, - autoSelect: false, - valueField: 'macro', - displayField: 'macro', - - listConfig: { - columns: [ - { - header: gettext('Macro'), - dataIndex: 'macro', - hideable: false, - width: 100, - }, - { - header: gettext('Description'), - renderer: Ext.String.htmlEncode, - flex: 1, - dataIndex: 'descr', - }, - ], - }, - initComponent: function () { - var me = this; - - var store = Ext.create('Ext.data.Store', { - autoLoad: true, - fields: ['macro', 'descr'], - idProperty: 'macro', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/firewall/macros', - }, - sorters: { - property: 'macro', - direction: 'ASC', - }, - }); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.form.ICMPTypeSelector', { - extend: 'Proxmox.form.ComboGrid', - alias: 'widget.pveICMPTypeSelector', - - allowBlank: true, - autoSelect: false, - valueField: 'name', - displayField: 'name', - - listConfig: { - columns: [ - { - header: gettext('Type'), - dataIndex: 'type', - hideable: false, - sortable: false, - width: 50, - }, - { - header: gettext('Name'), - dataIndex: 'name', - hideable: false, - sortable: false, - flex: 1, - }, - ], - }, - setName: function (value) { - this.name = value; - }, -}); - -let ICMP_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', { - field: ['type', 'name'], - data: [ - { type: 'any', name: 'any' }, - { type: '0', name: 'echo-reply' }, - { type: '3', name: 'destination-unreachable' }, - { type: '3/0', name: 'network-unreachable' }, - { type: '3/1', name: 'host-unreachable' }, - { type: '3/2', name: 'protocol-unreachable' }, - { type: '3/3', name: 'port-unreachable' }, - { type: '3/4', name: 'fragmentation-needed' }, - { type: '3/5', name: 'source-route-failed' }, - { type: '3/6', name: 'network-unknown' }, - { type: '3/7', name: 'host-unknown' }, - { type: '3/9', name: 'network-prohibited' }, - { type: '3/10', name: 'host-prohibited' }, - { type: '3/11', name: 'TOS-network-unreachable' }, - { type: '3/12', name: 'TOS-host-unreachable' }, - { type: '3/13', name: 'communication-prohibited' }, - { type: '3/14', name: 'host-precedence-violation' }, - { type: '3/15', name: 'precedence-cutoff' }, - { type: '4', name: 'source-quench' }, - { type: '5', name: 'redirect' }, - { type: '5/0', name: 'network-redirect' }, - { type: '5/1', name: 'host-redirect' }, - { type: '5/2', name: 'TOS-network-redirect' }, - { type: '5/3', name: 'TOS-host-redirect' }, - { type: '8', name: 'echo-request' }, - { type: '9', name: 'router-advertisement' }, - { type: '10', name: 'router-solicitation' }, - { type: '11', name: 'time-exceeded' }, - { type: '11/0', name: 'ttl-zero-during-transit' }, - { type: '11/1', name: 'ttl-zero-during-reassembly' }, - { type: '12', name: 'parameter-problem' }, - { type: '12/0', name: 'ip-header-bad' }, - { type: '12/1', name: 'required-option-missing' }, - { type: '13', name: 'timestamp-request' }, - { type: '14', name: 'timestamp-reply' }, - { type: '17', name: 'address-mask-request' }, - { type: '18', name: 'address-mask-reply' }, - ], -}); -let ICMPV6_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', { - field: ['type', 'name'], - data: [ - { type: '1', name: 'destination-unreachable' }, - { type: '1/0', name: 'no-route' }, - { type: '1/1', name: 'communication-prohibited' }, - { type: '1/2', name: 'beyond-scope' }, - { type: '1/3', name: 'address-unreachable' }, - { type: '1/4', name: 'port-unreachable' }, - { type: '1/5', name: 'failed-policy' }, - { type: '1/6', name: 'reject-route' }, - { type: '2', name: 'packet-too-big' }, - { type: '3', name: 'time-exceeded' }, - { type: '3/0', name: 'ttl-zero-during-transit' }, - { type: '3/1', name: 'ttl-zero-during-reassembly' }, - { type: '4', name: 'parameter-problem' }, - { type: '4/0', name: 'bad-header' }, - { type: '4/1', name: 'unknown-header-type' }, - { type: '4/2', name: 'unknown-option' }, - { type: '128', name: 'echo-request' }, - { type: '129', name: 'echo-reply' }, - { type: '133', name: 'router-solicitation' }, - { type: '134', name: 'router-advertisement' }, - { type: '135', name: 'neighbour-solicitation' }, - { type: '136', name: 'neighbour-advertisement' }, - { type: '137', name: 'redirect' }, - ], -}); - -let DEFAULT_ALLOWED_DIRECTIONS = ['in', 'out']; - -let ALLOWED_DIRECTIONS = { - dc: ['in', 'out', 'forward'], - node: ['in', 'out', 'forward'], - group: ['in', 'out', 'forward'], - vm: ['in', 'out'], - vnet: ['forward'], -}; - -let DEFAULT_ALLOWED_ACTIONS = ['ACCEPT', 'REJECT', 'DROP']; - -let ALLOWED_ACTIONS = { - in: ['ACCEPT', 'REJECT', 'DROP'], - out: ['ACCEPT', 'REJECT', 'DROP'], - forward: ['ACCEPT', 'DROP'], -}; - -Ext.define('PVE.FirewallRulePanel', { - extend: 'Proxmox.panel.InputPanel', - - allow_iface: false, - - list_refs_url: undefined, - - firewall_type: undefined, - action_selector: undefined, - forward_warning: undefined, - - onGetValues: function (values) { - var _me = this; - - // hack: editable ComboGrid returns nothing when empty, so we need to set '' - // Also, disabled text fields return nothing, so we need to set '' - - Ext.Array.each( - ['source', 'dest', 'macro', 'proto', 'sport', 'dport', 'icmp-type', 'log'], - function (key) { - if (values[key] === undefined) { - values[key] = ''; - } - }, - ); - - delete values.modified_marker; - - return values; - }, - - setValidActions: function (type) { - let me = this; - - let allowed_actions = ALLOWED_ACTIONS[type] ?? DEFAULT_ALLOWED_ACTIONS; - me.action_selector.setComboItems(allowed_actions.map((action) => [action, action])); - }, - - setForwardWarning: function (type) { - let me = this; - me.forward_warning.setHidden(type !== 'forward'); - }, - - onSetValues: function (values) { - let me = this; - - if (values.type) { - me.setValidActions(values.type); - me.setForwardWarning(values.type); - } - - return values; - }, - - initComponent: function () { - var me = this; - - if (!me.list_refs_url) { - throw 'no list_refs_url specified'; - } - - let allowed_directions = ALLOWED_DIRECTIONS[me.firewall_type] ?? DEFAULT_ALLOWED_DIRECTIONS; - - me.action_selector = Ext.create('Proxmox.form.KVComboBox', { - xtype: 'proxmoxKVComboBox', - name: 'action', - value: 'ACCEPT', - comboItems: DEFAULT_ALLOWED_ACTIONS.map((action) => [action, action]), - fieldLabel: gettext('Action'), - allowBlank: false, - }); - - me.forward_warning = Ext.create('Proxmox.form.field.DisplayEdit', { - userCls: 'pmx-hint', - value: gettext( - 'Forward rules only take effect when the nftables firewall is activated in the host options', - ), - hidden: true, - }); - - me.column1 = [ - { - // hack: we use this field to mark the form 'dirty' when the - // record has errors- so that the user can safe the unmodified - // form again. - xtype: 'hiddenfield', - name: 'modified_marker', - value: '', - }, - { - xtype: 'proxmoxKVComboBox', - name: 'type', - value: allowed_directions[0], - comboItems: allowed_directions.map((dir) => [dir, dir]), - fieldLabel: gettext('Direction'), - allowBlank: false, - listeners: { - change: function (f, value) { - me.setValidActions(value); - me.setForwardWarning(value); - }, - }, - }, - me.action_selector, - ]; - - if (me.allow_iface) { - me.column1.push({ - xtype: 'proxmoxtextfield', - name: 'iface', - deleteEmpty: !me.isCreate, - value: '', - fieldLabel: gettext('Interface'), - }); - } else { - me.column1.push({ - xtype: 'displayfield', - fieldLabel: '', - value: '', - }); - } - - me.column1.push( - { - xtype: 'displayfield', - fieldLabel: '', - height: 7, - value: '', - }, - { - xtype: 'pveIPRefSelector', - name: 'source', - autoSelect: false, - editable: true, - base_url: me.list_refs_url, - fieldLabel: gettext('Source'), - maxLength: 512, - maxLengthText: gettext('Too long, consider using IP sets.'), - }, - { - xtype: 'pveIPRefSelector', - name: 'dest', - autoSelect: false, - editable: true, - base_url: me.list_refs_url, - fieldLabel: gettext('Destination'), - maxLength: 512, - maxLengthText: gettext('Too long, consider using IP sets.'), - }, - ); - - me.column2 = [ - { - xtype: 'proxmoxcheckbox', - name: 'enable', - checked: false, - uncheckedValue: 0, - fieldLabel: gettext('Enable'), - }, - { - xtype: 'pveFWMacroSelector', - name: 'macro', - fieldLabel: gettext('Macro'), - editable: true, - allowBlank: true, - listeners: { - change: function (f, value) { - if (value === null) { - me.down('field[name=proto]').setDisabled(false); - me.down('field[name=sport]').setDisabled(false); - me.down('field[name=dport]').setDisabled(false); - } else { - me.down('field[name=proto]').setDisabled(true); - me.down('field[name=proto]').setValue(''); - me.down('field[name=sport]').setDisabled(true); - me.down('field[name=sport]').setValue(''); - me.down('field[name=dport]').setDisabled(true); - me.down('field[name=dport]').setValue(''); - } - }, - }, - }, - { - xtype: 'pveIPProtocolSelector', - name: 'proto', - autoSelect: false, - editable: true, - value: '', - fieldLabel: gettext('Protocol'), - listeners: { - change: function (f, value) { - if (value === 'icmp' || value === 'icmpv6' || value === 'ipv6-icmp') { - me.down('field[name=dport]').setHidden(true); - me.down('field[name=dport]').setDisabled(true); - if (value === 'icmp') { - me.down('#icmpv4-type').setHidden(false); - me.down('#icmpv4-type').setDisabled(false); - me.down('#icmpv6-type').setHidden(true); - me.down('#icmpv6-type').setDisabled(true); - } else { - me.down('#icmpv6-type').setHidden(false); - me.down('#icmpv6-type').setDisabled(false); - me.down('#icmpv4-type').setHidden(true); - me.down('#icmpv4-type').setDisabled(true); - } - } else { - me.down('#icmpv4-type').setHidden(true); - me.down('#icmpv4-type').setDisabled(true); - me.down('#icmpv6-type').setHidden(true); - me.down('#icmpv6-type').setDisabled(true); - me.down('field[name=dport]').setHidden(false); - me.down('field[name=dport]').setDisabled(false); - } - }, - }, - }, - { - xtype: 'displayfield', - fieldLabel: '', - height: 7, - value: '', - }, - { - xtype: 'textfield', - name: 'sport', - value: '', - fieldLabel: gettext('Source port'), - }, - { - xtype: 'textfield', - name: 'dport', - value: '', - fieldLabel: gettext('Dest. port'), - }, - { - xtype: 'pveICMPTypeSelector', - name: 'icmp-type', - id: 'icmpv4-type', - autoSelect: false, - editable: true, - hidden: true, - disabled: true, - value: '', - fieldLabel: gettext('ICMP type'), - store: ICMP_TYPE_NAMES_STORE, - }, - { - xtype: 'pveICMPTypeSelector', - name: 'icmp-type', - id: 'icmpv6-type', - autoSelect: false, - editable: true, - hidden: true, - disabled: true, - value: '', - fieldLabel: gettext('ICMP type'), - store: ICMPV6_TYPE_NAMES_STORE, - }, - ]; - - me.advancedColumn1 = [ - { - xtype: 'pveFirewallLogLevels', - }, - ]; - - me.columnB = [ - { - xtype: 'textfield', - name: 'comment', - value: '', - fieldLabel: gettext('Comment'), - }, - me.forward_warning, - ]; - - me.callParent(); - - if (me.isCreate) { - // on create we never change the values, so we need to trigger this - // manually - me.setValidActions(me.getValues().type); - me.setForwardWarning(me.getValues().type); - } - }, -}); - -Ext.define('PVE.FirewallRuleEdit', { - extend: 'Proxmox.window.Edit', - - base_url: undefined, - list_refs_url: undefined, - - allow_iface: false, - - firewall_type: undefined, - - initComponent: function () { - var me = this; - - if (!me.base_url) { - throw 'no base_url specified'; - } - if (!me.list_refs_url) { - throw 'no list_refs_url specified'; - } - - me.isCreate = me.rule_pos === undefined; - - if (me.isCreate) { - me.url = '/api2/extjs' + me.base_url; - me.method = 'POST'; - } else { - me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString(); - me.method = 'PUT'; - } - - var ipanel = Ext.create('PVE.FirewallRulePanel', { - isCreate: me.isCreate, - list_refs_url: me.list_refs_url, - allow_iface: me.allow_iface, - rule_pos: me.rule_pos, - firewall_type: me.firewall_type, - }); - - Ext.apply(me, { - subject: gettext('Rule'), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - ipanel.setValues(values); - // set icmp-type again after protocol has been set - if (values['icmp-type'] !== undefined) { - ipanel.setValues({ 'icmp-type': values['icmp-type'] }); - } - if (values.errors) { - let field = me.query('[isFormField][name=modified_marker]')[0]; - field.setValue(1); - Ext.Function.defer(function () { - var form = ipanel.up('form').getForm(); - form.markInvalid(values.errors); - }, 100); - } - }, - }); - } else if (me.rec) { - ipanel.setValues(me.rec.data); - } - }, -}); - -Ext.define('PVE.FirewallGroupRuleEdit', { - extend: 'Proxmox.window.Edit', - - base_url: undefined, - - allow_iface: false, - - initComponent: function () { - var me = this; - - me.isCreate = me.rule_pos === undefined; - - if (me.isCreate) { - me.url = '/api2/extjs' + me.base_url; - me.method = 'POST'; - } else { - me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString(); - me.method = 'PUT'; - } - - var column1 = [ - { - xtype: 'hiddenfield', - name: 'type', - value: 'group', - }, - { - xtype: 'pveSecurityGroupsSelector', - name: 'action', - value: '', - fieldLabel: gettext('Security Group'), - allowBlank: false, - }, - ]; - - if (me.allow_iface) { - column1.push({ - xtype: 'proxmoxtextfield', - name: 'iface', - deleteEmpty: !me.isCreate, - value: '', - fieldLabel: gettext('Interface'), - }); - } - - var ipanel = Ext.create('Proxmox.panel.InputPanel', { - isCreate: me.isCreate, - column1: column1, - column2: [ - { - xtype: 'proxmoxcheckbox', - name: 'enable', - checked: false, - uncheckedValue: 0, - fieldLabel: gettext('Enable'), - }, - ], - columnB: [ - { - xtype: 'textfield', - name: 'comment', - value: '', - fieldLabel: gettext('Comment'), - }, - ], - }); - - Ext.apply(me, { - subject: gettext('Rule'), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - ipanel.setValues(values); - }, - }); - } - }, -}); - -Ext.define( - 'PVE.FirewallRules', - { - extend: 'Ext.grid.Panel', - alias: 'widget.pveFirewallRules', - - onlineHelp: 'chapter_pve_firewall', - emptyText: gettext('No firewall rule configured here.'), - - stateful: true, - stateId: 'grid-firewall-rules', - - base_url: undefined, - list_refs_url: undefined, - - addBtn: undefined, - removeBtn: undefined, - editBtn: undefined, - groupBtn: undefined, - - tbar_prefix: undefined, - - allow_groups: true, - allow_iface: false, - - firewall_type: undefined, - - setBaseUrl: function (url) { - var me = this; - - me.base_url = url; - - if (url === undefined) { - me.addBtn.setDisabled(true); - if (me.groupBtn) { - me.groupBtn.setDisabled(true); - } - me.store.removeAll(); - } else { - if (me.canEdit) { - me.addBtn.setDisabled(false); - if (me.groupBtn) { - me.groupBtn.setDisabled(false); - } - } - me.removeBtn.baseurl = url + '/'; - - me.store.setProxy({ - type: 'proxmox', - url: '/api2/json' + url, - }); - - me.store.load(); - } - }, - - moveRule: function (from, to) { - var me = this; - - if (!me.base_url) { - return; - } - - Proxmox.Utils.API2Request({ - url: me.base_url + '/' + from, - method: 'PUT', - params: { moveto: to }, - waitMsgTarget: me, - failure: function (response, options) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - callback: function () { - me.store.load(); - }, - }); - }, - - updateRule: function (rule) { - var me = this; - - if (!me.base_url) { - return; - } - - rule.enable = rule.enable ? 1 : 0; - - var pos = rule.pos; - delete rule.pos; - delete rule.errors; - - Proxmox.Utils.API2Request({ - url: me.base_url + '/' + pos.toString(), - method: 'PUT', - params: rule, - waitMsgTarget: me, - failure: function (response, options) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - callback: function () { - me.store.load(); - }, - }); - }, - - initComponent: function () { - var me = this; - - if (!me.list_refs_url) { - throw 'no list_refs_url specified'; - } - - var store = Ext.create('Ext.data.Store', { - model: 'pve-fw-rule', - }); - - var reload = function () { - store.load(); - }; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - me.caps = Ext.state.Manager.get('GuiCap'); - me.canEdit = - !!me.caps.vms['VM.Config.Network'] || - !!me.caps.dc['Sys.Modify'] || - !!me.caps.nodes['Sys.Modify']; - - var run_editor = function () { - var rec = sm.getSelection()[0]; - if (!rec || !me.canEdit) { - return; - } - var type = rec.data.type; - - var editor; - if (type === 'in' || type === 'out' || type === 'forward') { - editor = 'PVE.FirewallRuleEdit'; - } else if (type === 'group') { - editor = 'PVE.FirewallGroupRuleEdit'; - } else { - return; - } - - var win = Ext.create(editor, { - firewall_type: me.firewall_type, - digest: rec.data.digest, - allow_iface: me.allow_iface, - base_url: me.base_url, - list_refs_url: me.list_refs_url, - rule_pos: rec.data.pos, - }); - - win.show(); - win.on('destroy', reload); - }; - - me.editBtn = Ext.create('Proxmox.button.Button', { - text: gettext('Edit'), - disabled: true, - enableFn: (rec) => me.canEdit, - selModel: sm, - handler: run_editor, - }); - - me.addBtn = Ext.create('Ext.Button', { - text: gettext('Add'), - disabled: true, - handler: function () { - var win = Ext.create('PVE.FirewallRuleEdit', { - firewall_type: me.firewall_type, - allow_iface: me.allow_iface, - base_url: me.base_url, - list_refs_url: me.list_refs_url, - }); - win.on('destroy', reload); - win.show(); - }, - }); - - var run_copy_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - let type = rec.data.type; - if (!(type === 'in' || type === 'out' || type === 'forward')) { - return; - } - - let win = Ext.create('PVE.FirewallRuleEdit', { - firewall_type: me.firewall_type, - allow_iface: me.allow_iface, - base_url: me.base_url, - list_refs_url: me.list_refs_url, - rec: rec, - }); - win.show(); - win.on('destroy', reload); - }; - - me.copyBtn = Ext.create('Proxmox.button.Button', { - text: gettext('Copy'), - selModel: sm, - enableFn: ({ data }) => - (data.type === 'in' || data.type === 'out' || data.type === 'forward') && - me.canEdit, - disabled: true, - handler: run_copy_editor, - }); - - if (me.allow_groups) { - me.groupBtn = Ext.create('Ext.Button', { - text: gettext('Insert') + ': ' + gettext('Security Group'), - disabled: true, - handler: function () { - var win = Ext.create('PVE.FirewallGroupRuleEdit', { - allow_iface: me.allow_iface, - base_url: me.base_url, - }); - win.on('destroy', reload); - win.show(); - }, - }); - } - - me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { - enableFn: (rec) => me.canEdit, - selModel: sm, - baseurl: me.base_url + '/', - confirmMsg: false, - getRecordName: function (rec) { - var rule = rec.data; - return rule.pos.toString() + '?digest=' + encodeURIComponent(rule.digest); - }, - callback: function () { - me.store.load(); - }, - }); - - let tbar = me.tbar_prefix ? [me.tbar_prefix] : []; - tbar.push(me.addBtn, me.copyBtn); - if (me.groupBtn) { - tbar.push(me.groupBtn); - } - tbar.push(me.removeBtn, me.editBtn); - - let render_errors = function (name, value, metaData, record) { - let errors = record.data.errors; - if (errors && errors[name]) { - metaData.tdCls = 'proxmox-invalid-row'; - let html = Ext.htmlEncode(`

    ${Ext.htmlEncode(errors[name])}`); - metaData.tdAttr = - 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' + html + '"'; - } - return Ext.htmlEncode(value); - }; - - let columns = [ - { - // similar to xtype: 'rownumberer', - dataIndex: 'pos', - resizable: false, - minWidth: 65, - maxWidth: 83, - flex: 1, - sortable: false, - hideable: false, - menuDisabled: true, - renderer: function (value, metaData, record, rowIdx, colIdx) { - metaData.tdCls = Ext.baseCSSPrefix + 'grid-cell-special'; - let dragHandle = - ""; - if (value >= 0) { - return dragHandle + value; - } - return dragHandle; - }, - }, - { - xtype: 'checkcolumn', - header: gettext('On'), - dataIndex: 'enable', - listeners: { - checkchange: function (column, recordIndex, checked) { - var record = me.getStore().getData().items[recordIndex]; - record.commit(); - var data = {}; - Ext.Array.forEach(record.getFields(), function (field) { - data[field.name] = record.get(field.name); - }); - if (!me.allow_iface || !data.iface) { - delete data.iface; - } - me.updateRule(data); - }, - }, - width: 40, - }, - { - header: gettext('Type'), - dataIndex: 'type', - renderer: function (value, metaData, record) { - return render_errors('type', value, metaData, record); - }, - minWidth: 60, - maxWidth: 80, - flex: 2, - }, - { - header: gettext('Action'), - dataIndex: 'action', - renderer: function (value, metaData, record) { - return render_errors('action', value, metaData, record); - }, - minWidth: 80, - maxWidth: 200, - flex: 2, - }, - { - header: gettext('Macro'), - dataIndex: 'macro', - renderer: function (value, metaData, record) { - return render_errors('macro', value, metaData, record); - }, - minWidth: 80, - flex: 2, - }, - ]; - - if (me.allow_iface) { - columns.push({ - header: gettext('Interface'), - dataIndex: 'iface', - renderer: function (value, metaData, record) { - return render_errors('iface', value, metaData, record); - }, - minWidth: 80, - flex: 2, - }); - } - - columns.push( - { - header: gettext('Protocol'), - dataIndex: 'proto', - renderer: function (value, metaData, record) { - return render_errors('proto', value, metaData, record); - }, - width: 75, - }, - { - header: gettext('Source'), - dataIndex: 'source', - renderer: function (value, metaData, record) { - return render_errors('source', value, metaData, record); - }, - minWidth: 100, - flex: 2, - }, - { - header: gettext('S.Port'), - dataIndex: 'sport', - renderer: function (value, metaData, record) { - return render_errors('sport', value, metaData, record); - }, - width: 75, - }, - { - header: gettext('Destination'), - dataIndex: 'dest', - renderer: function (value, metaData, record) { - return render_errors('dest', value, metaData, record); - }, - minWidth: 100, - flex: 2, - }, - { - header: gettext('D.Port'), - dataIndex: 'dport', - renderer: function (value, metaData, record) { - return render_errors('dport', value, metaData, record); - }, - width: 75, - }, - { - header: gettext('Log level'), - dataIndex: 'log', - renderer: function (value, metaData, record) { - return render_errors('log', value, metaData, record); - }, - width: 100, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - flex: 10, - minWidth: 75, - renderer: function (value, metaData, record) { - let comment = render_errors('comment', value, metaData, record) || ''; - if (comment.length * 12 > metaData.column.cellWidth) { - comment = `${comment}`; - } - return comment; - }, - }, - ); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: tbar, - viewConfig: { - plugins: [ - { - ptype: 'gridviewdragdrop', - dragGroup: 'FWRuleDDGroup', - dropGroup: 'FWRuleDDGroup', - }, - ], - listeners: { - beforedrop: function (node, data, dropRec, dropPosition) { - if (!dropRec) { - return false; // empty view - } - let moveto = dropRec.get('pos'); - if (dropPosition === 'after') { - moveto++; - } - let pos = data.records[0].get('pos'); - me.moveRule(pos, moveto); - return 0; - }, - itemdblclick: run_editor, - }, - }, - sortableColumns: false, - columns: columns, - }); - - me.callParent(); - - if (me.base_url) { - me.setBaseUrl(me.base_url); // load - } - }, - }, - function () { - Ext.define('pve-fw-rule', { - extend: 'Ext.data.Model', - fields: [ - { name: 'enable', type: 'boolean' }, - 'type', - 'action', - 'macro', - 'source', - 'dest', - 'proto', - 'iface', - 'dport', - 'sport', - 'comment', - 'pos', - 'digest', - 'errors', - ], - idProperty: 'pos', - }); - }, -); -Ext.define('PVE.pool.AddVM', { - extend: 'Proxmox.window.Edit', - - width: 800, - height: 600, - resizable: true, - - isAdd: true, - isCreate: true, - - extraRequestParams: { - 'allow-move': 1, - }, - - initComponent: function () { - var me = this; - - if (!me.pool) { - throw 'no pool specified'; - } - - me.url = '/pools/'; - me.method = 'PUT'; - me.extraRequestParams.poolid = me.pool; - - var vmsField = Ext.create('Ext.form.field.Text', { - name: 'vms', - hidden: true, - allowBlank: false, - }); - - let basicFilter = (data) => - (data.type === 'lxc' || data.type === 'qemu') && data.pool !== me.pool; - - var vmStore = Ext.create('Ext.data.Store', { - model: 'PVEResources', - sorters: [ - { - property: 'vmid', - direction: 'ASC', - }, - ], - filters: [(item) => basicFilter(item.data)], - }); - - var vmGrid = Ext.create('widget.grid', { - store: vmStore, - border: true, - height: 480, - scrollable: true, - selModel: { - selType: 'checkboxmodel', - mode: 'SIMPLE', - listeners: { - selectionchange: function (model, selected, opts) { - var selectedVms = []; - selected.forEach(function (vm) { - selectedVms.push(vm.data.vmid); - }); - vmsField.setValue(selectedVms); - }, - }, - }, - tbar: [ - '->', - gettext('Filter') + ':', - ' ', - { - xtype: 'textfield', - width: 200, - enableKeyEvents: true, - emptyText: gettext('Name, Node, VMID'), - submitValue: false, - listeners: { - keyup: { - buffer: 350, - fn: function (field) { - let needle = field.getValue().toLocaleLowerCase(); - if (needle?.length === 0) { - this.triggers.clear.setVisible(false); - } - let matchesNeedle = (v) => v?.toLocaleLowerCase().includes(needle); - vmStore.clearFilter(true); - vmStore.filter([ - { - filterFn: ({ data }) => - basicFilter(data) && - (matchesNeedle(data.vmid.toString()) || - matchesNeedle(data.name) || - matchesNeedle(data.node)), - }, - ]); - }, - }, - change: function (field, newValue, oldValue) { - if (newValue !== this.originalValue) { - this.triggers.clear.setVisible(true); - } - }, - }, - triggers: { - clear: { - cls: 'pmx-clear-trigger', - weight: -1, - hidden: true, - handler: function () { - this.triggers.clear.setVisible(false); - this.setValue(this.originalValue); - vmStore.clearFilter(true); - vmStore.filter([ - { - filterFn: ({ data }) => basicFilter(data), - }, - ]); - }, - }, - }, - }, - ], - columns: [ - { - header: 'ID', - dataIndex: 'vmid', - width: 60, - }, - { - header: gettext('Node'), - dataIndex: 'node', - }, - { - header: gettext('Current Pool'), - dataIndex: 'pool', - }, - { - header: gettext('Status'), - dataIndex: 'uptime', - renderer: (v) => (v ? Proxmox.Utils.runningText : Proxmox.Utils.stoppedText), - }, - { - header: gettext('Name'), - dataIndex: 'name', - flex: 1, - }, - { - header: gettext('Type'), - dataIndex: 'type', - }, - ], - }); - - Ext.apply(me, { - subject: gettext('Virtual Machine'), - items: [ - vmsField, - vmGrid, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - 'Selected guests who are already part of a pool will be removed from it first.', - ), - }, - ], - }); - - me.callParent(); - vmStore.load(); - }, -}); - -Ext.define('PVE.pool.AddStorage', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - if (!me.pool) { - throw 'no pool specified'; - } - - me.isCreate = true; - me.isAdd = true; - me.url = '/pools/'; - me.method = 'PUT'; - me.extraRequestParams.poolid = me.pool; - - Ext.apply(me, { - subject: gettext('Storage'), - width: 350, - items: [ - { - xtype: 'pveStorageSelector', - name: 'storage', - nodename: 'localhost', - autoSelect: false, - value: '', - fieldLabel: gettext('Storage'), - }, - ], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.grid.PoolMembers', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pvePoolMembers'], - - stateful: true, - stateId: 'grid-pool-members', - - initComponent: function () { - var me = this; - - if (!me.pool) { - throw 'no pool specified'; - } - - me.rstore = Ext.create('Proxmox.data.UpdateStore', { - interval: 10000, - model: 'PVEResources', - proxy: { - type: 'proxmox', - root: 'data[0].members', - url: `/api2/json/pools/?poolid=${me.pool}`, - }, - autoStart: true, - }); - - let store = Ext.create('Proxmox.data.DiffStore', { - rstore: me.rstore, - sorters: [ - { - property: 'type', - direction: 'ASC', - }, - ], - }); - - var coldef = PVE.data.ResourceStore.defaultColumns().filter( - (c) => c.dataIndex !== 'tags' && c.dataIndex !== 'lock', - ); - - const reload = function () { - me.rstore.load(); - }; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var remove_btn = new Proxmox.button.Button({ - text: gettext('Remove'), - disabled: true, - selModel: sm, - confirmMsg: function (rec) { - return Ext.String.format( - gettext('Are you sure you want to remove entry {0}'), - "'" + rec.data.id + "'", - ); - }, - handler: function (btn, event, rec) { - var params = { delete: 1, poolid: me.pool }; - if (rec.data.type === 'storage') { - params.storage = rec.data.storage; - } else if ( - rec.data.type === 'qemu' || - rec.data.type === 'lxc' || - rec.data.type === 'openvz' - ) { - params.vms = rec.data.vmid; - } else { - throw 'unknown resource type'; - } - - Proxmox.Utils.API2Request({ - url: '/pools/', - method: 'PUT', - params: params, - waitMsgTarget: me, - callback: function () { - reload(); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - }); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: [ - { - text: gettext('Virtual Machine'), - iconCls: 'fa fa-desktop', - handler: function () { - var win = Ext.create('PVE.pool.AddVM', { pool: me.pool }); - win.on('destroy', reload); - win.show(); - }, - }, - { - text: gettext('Storage'), - iconCls: 'fa fa-hdd-o', - handler: function () { - var win = Ext.create('PVE.pool.AddStorage', { pool: me.pool }); - win.on('destroy', reload); - win.show(); - }, - }, - ], - }), - }, - remove_btn, - ], - viewConfig: { - stripeRows: true, - }, - columns: coldef, - listeners: { - itemcontextmenu: PVE.Utils.createCmdMenu, - itemdblclick: function (v, record) { - var ws = me.up('pveStdWorkspace'); - ws.selectById(record.data.id); - }, - activate: reload, - destroy: () => me.rstore.stopUpdate(), - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.window.ReplicaEdit', { - extend: 'Proxmox.window.Edit', - xtype: 'pveReplicaEdit', - - subject: gettext('Replication Job'), - - url: '/cluster/replication', - method: 'POST', - - initComponent: function () { - var me = this; - - var vmid = me.pveSelNode.data.vmid; - var nodename = me.pveSelNode.data.node; - - var items = []; - - items.push({ - xtype: me.isCreate && !vmid ? 'pveGuestIDSelector' : 'displayfield', - name: 'guest', - fieldLabel: 'CT/VM ID', - value: vmid || '', - }); - - items.push( - { - xtype: me.isCreate ? 'pveNodeSelector' : 'displayfield', - name: 'target', - disallowedNodes: [nodename], - allowBlank: false, - onlineValidator: true, - fieldLabel: gettext('Target'), - }, - { - xtype: 'pveCalendarEvent', - fieldLabel: gettext('Schedule'), - emptyText: '*/15 - ' + Ext.String.format(gettext('Every {0} minutes'), 15), - name: 'schedule', - }, - { - xtype: 'numberfield', - fieldLabel: gettext('Rate limit') + ' (MB/s)', - step: 1, - minValue: 1, - emptyText: gettext('unlimited'), - name: 'rate', - }, - { - xtype: 'textfield', - fieldLabel: gettext('Comment'), - name: 'comment', - }, - { - xtype: 'proxmoxcheckbox', - name: 'enabled', - defaultValue: 'on', - checked: true, - fieldLabel: gettext('Enabled'), - }, - ); - - me.items = [ - { - xtype: 'inputpanel', - itemId: 'ipanel', - onlineHelp: 'pvesr_schedule_time_format', - - onGetValues: function (values) { - let win = this.up('window'); - - values.disable = values.enabled ? 0 : 1; - delete values.enabled; - - PVE.Utils.delete_if_default(values, 'rate', '', win.isCreate); - PVE.Utils.delete_if_default(values, 'disable', 0, win.isCreate); - PVE.Utils.delete_if_default(values, 'schedule', '*/15', win.isCreate); - PVE.Utils.delete_if_default(values, 'comment', '', win.isCreate); - - if (win.isCreate) { - values.type = 'local'; - let vm = vmid || values.guest; - let id = -1; - if (win.highestids[vm] !== undefined) { - id = win.highestids[vm]; - } - id++; - values.id = vm + '-' + id.toString(); - delete values.guest; - } - return values; - }, - items: items, - }, - ]; - - me.callParent(); - - if (me.isCreate) { - me.load({ - success: function (response) { - var jobs = response.result.data; - var highestids = {}; - Ext.Array.forEach(jobs, function (job) { - var match = /^([0-9]+)-([0-9]+)$/.exec(job.id); - if (match) { - let jobVMID = parseInt(match[1], 10); - let id = parseInt(match[2], 10); - if (highestids[jobVMID] === undefined || highestids[jobVMID] < id) { - highestids[jobVMID] = id; - } - } - }); - me.highestids = highestids; - }, - }); - } else { - me.load({ - success: function (response, options) { - response.result.data.enabled = !response.result.data.disable; - me.setValues(response.result.data); - me.digest = response.result.data.digest; - }, - }); - } - }, -}); - -/* callback is a function and string */ -Ext.define( - 'PVE.grid.ReplicaView', - { - extend: 'Ext.grid.Panel', - xtype: 'pveReplicaView', - - onlineHelp: 'chapter_pvesr', - - stateful: true, - stateId: 'grid-pve-replication-status', - - controller: { - xclass: 'Ext.app.ViewController', - - addJob: function (button, event, rec) { - let me = this; - let view = me.getView(); - Ext.create('PVE.window.ReplicaEdit', { - isCreate: true, - method: 'POST', - pveSelNode: view.pveSelNode, - listeners: { - destroy: () => me.reload(), - }, - autoShow: true, - }); - }, - - editJob: function (button, event, { data }) { - let me = this; - let view = me.getView(); - Ext.create('PVE.window.ReplicaEdit', { - url: `/cluster/replication/${data.id}`, - method: 'PUT', - pveSelNode: view.pveSelNode, - listeners: { - destroy: () => me.reload(), - }, - autoShow: true, - }); - }, - - scheduleJobNow: function (button, event, rec) { - let me = this; - let view = me.getView(); - Proxmox.Utils.API2Request({ - url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/schedule_now`, - method: 'POST', - waitMsgTarget: view, - callback: () => me.reload(), - failure: (response, opts) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, - - showLog: function (button, event, rec) { - let me = this; - let view = this.getView(); - - let logView = Ext.create('Proxmox.panel.LogView', { - border: false, - url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/log`, - }); - let task = Ext.TaskManager.newTask({ - run: () => logView.requestUpdate(), - interval: 1000, - }); - let win = Ext.create('Ext.window.Window', { - items: [logView], - layout: 'fit', - width: 800, - height: 400, - modal: true, - title: gettext('Replication Log'), - listeners: { - destroy: function () { - task.stop(); - me.reload(); - }, - }, - }); - task.start(); - win.show(); - }, - - reload: function () { - this.getView().rstore.load(); - }, - - dblClick: function (grid, record, item) { - this.editJob(undefined, undefined, record); - }, - - // currently replication is for cluster only, so disable the whole component for non-cluster - checkPrerequisites: function () { - let view = this.getView(); - if (PVE.Utils.isStandaloneNode()) { - view.mask(gettext('Replication needs at least two nodes'), ['pve-static-mask']); - } - }, - - control: { - '#': { - itemdblclick: 'dblClick', - afterlayout: 'checkPrerequisites', - }, - }, - }, - - tbar: [ - { - text: gettext('Add'), - itemId: 'addButton', - handler: 'addJob', - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - itemId: 'editButton', - handler: 'editJob', - disabled: true, - }, - { - xtype: 'proxmoxStdRemoveButton', - itemId: 'removeButton', - baseurl: '/api2/extjs/cluster/replication/', - dangerous: true, - callback: 'reload', - }, - { - xtype: 'proxmoxButton', - text: gettext('Log'), - itemId: 'logButton', - handler: 'showLog', - disabled: true, - }, - { - xtype: 'proxmoxButton', - text: gettext('Schedule now'), - itemId: 'scheduleNowButton', - handler: 'scheduleJobNow', - disabled: true, - }, - ], - - initComponent: function () { - var me = this; - var mode = ''; - var url = '/cluster/replication'; - - me.nodename = me.pveSelNode.data.node; - me.vmid = me.pveSelNode.data.vmid; - - me.columns = [ - { - header: gettext('Enabled'), - width: 80, - dataIndex: 'enabled', - align: 'center', - renderer: Proxmox.Utils.renderEnabledIcon, - sortable: true, - }, - { - text: 'ID', - dataIndex: 'id', - width: 60, - hidden: true, - }, - { - text: gettext('Guest'), - dataIndex: 'guest', - width: 75, - }, - { - text: gettext('Job'), - dataIndex: 'jobnum', - width: 60, - }, - { - text: gettext('Target'), - dataIndex: 'target', - }, - ]; - - if (!me.nodename) { - mode = 'dc'; - me.stateId = 'grid-pve-replication-dc'; - } else if (!me.vmid) { - mode = 'node'; - url = `/nodes/${me.nodename}/replication`; - } else { - mode = 'vm'; - url = `/nodes/${me.nodename}/replication?guest=${me.vmid}`; - } - - if (mode !== 'dc') { - me.columns.push( - { - text: gettext('Status'), - dataIndex: 'state', - minWidth: 160, - flex: 1, - renderer: function (value, metadata, record) { - if (record.data.pid) { - metadata.tdCls = 'x-grid-row-loading'; - return ''; - } - - let icons = [], - states = []; - - if (record.data.remove_job) { - icons.push( - '', - ); - states.push(gettext('Removal Scheduled')); - } - if (record.data.error) { - icons.push( - '', - ); - states.push(record.data.error); - } - if (icons.length === 0) { - icons.push(''); - states.push(gettext('OK')); - } - - return icons.join(',') + ' ' + states.join(','); - }, - }, - { - text: gettext('Last Sync'), - dataIndex: 'last_sync', - width: 150, - renderer: function (value, metadata, record) { - if (!value) { - return '-'; - } - if (record.data.pid) { - return gettext('syncing'); - } - return Proxmox.Utils.render_timestamp(value); - }, - }, - { - text: gettext('Duration'), - dataIndex: 'duration', - width: 60, - renderer: Proxmox.Utils.render_duration, - }, - { - text: gettext('Next Sync'), - dataIndex: 'next_sync', - width: 150, - renderer: function (value) { - if (!value) { - return '-'; - } - - let now = new Date(), - next = new Date(value * 1000); - if (next < now) { - return gettext('pending'); - } - return Proxmox.Utils.render_timestamp(value); - }, - }, - ); - } - - me.columns.push( - { - text: gettext('Schedule'), - width: 75, - dataIndex: 'schedule', - }, - { - text: gettext('Rate limit'), - dataIndex: 'rate', - renderer: function (value) { - if (!value) { - return gettext('unlimited'); - } - - return value.toString() + ' MB/s'; - }, - hidden: true, - }, - { - text: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.htmlEncode, - }, - ); - - me.rstore = Ext.create('Proxmox.data.UpdateStore', { - storeid: 'pve-replica-' + me.nodename + me.vmid, - model: mode === 'dc' ? 'pve-replication' : 'pve-replication-state', - interval: 3000, - proxy: { - type: 'proxmox', - url: '/api2/json' + url, - }, - }); - - me.store = Ext.create('Proxmox.data.DiffStore', { - rstore: me.rstore, - sorters: [ - { - property: 'guest', - }, - { - property: 'jobnum', - }, - ], - }); - - me.callParent(); - - // we cannot access the log and scheduleNow button - // in the datacenter, because - // we do not know where/if the jobs runs - if (mode === 'dc') { - me.down('#logButton').setHidden(true); - me.down('#scheduleNowButton').setHidden(true); - } - - // if we set the warning mask, we do not want to load - // or set the mask on store errors - if (PVE.Utils.isStandaloneNode()) { - return; - } - - Proxmox.Utils.monStoreErrors(me, me.rstore); - - me.on('destroy', me.rstore.stopUpdate); - me.rstore.startUpdate(); - }, - }, - function () { - Ext.define('pve-replication', { - extend: 'Ext.data.Model', - fields: [ - 'id', - 'target', - 'comment', - 'rate', - 'type', - { name: 'guest', type: 'integer' }, - { name: 'jobnum', type: 'integer' }, - { name: 'schedule', defaultValue: '*/15' }, - { name: 'disable', defaultValue: '' }, - { - name: 'enabled', - calculate: function (data) { - return !data.disable; - }, - }, - ], - }); - - Ext.define('pve-replication-state', { - extend: 'pve-replication', - fields: [ - 'last_sync', - 'next_sync', - 'error', - 'duration', - 'state', - 'fail_count', - 'remove_job', - 'pid', - ], - }); - }, -); -Ext.define('PVE.grid.ResourceGrid', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveResourceGrid'], - - border: false, - defaultSorter: { - property: 'type', - direction: 'ASC', - }, - userCls: 'proxmox-tags-full', - initComponent: function () { - let me = this; - - let rstore = PVE.data.ResourceStore; - - let store = Ext.create('Ext.data.Store', { - model: 'PVEResources', - sorters: me.defaultSorter, - proxy: { - type: 'memory', - }, - }); - - let textfilter = ''; - let textfilterMatch = function (item) { - for (const field of ['name', 'storage', 'node', 'type', 'text']) { - let v = item.data[field]; - if (v && v.toLowerCase().indexOf(textfilter) >= 0) { - return true; - } - } - return false; - }; - - let updateGrid = function () { - var filterfn = me.viewFilter ? me.viewFilter.filterfn : null; - - store.suspendEvents(); - - let nodeidx = {}; - let gather_child_nodes; - gather_child_nodes = function (node) { - if (!node || !node.childNodes) { - return; - } - for (let child of node.childNodes) { - let orgNode = rstore.data.get(child.data.realId ?? child.data.id); - if (orgNode) { - if ( - (!filterfn || filterfn(child)) && - (!textfilter || textfilterMatch(child)) - ) { - nodeidx[child.data.id] = orgNode; - } - } - gather_child_nodes(child); - } - }; - gather_child_nodes(me.pveSelNode); - - // remove vanished items - let rmlist = []; - store.each((olditem) => { - if (!nodeidx[olditem.data.id]) { - rmlist.push(olditem); - } - }); - if (rmlist.length) { - store.remove(rmlist); - } - - // add new items - let addlist = []; - for (const [_key, item] of Object.entries(nodeidx)) { - // getById() use find(), which is slow (ExtJS4 DP5) - let olditem = store.data.get(item.data.id); - if (!olditem) { - addlist.push(item); - continue; - } - let changes = false; - for (let field of PVE.data.ResourceStore.fieldNames) { - if (field !== 'id' && item.data[field] !== olditem.data[field]) { - changes = true; - olditem.beginEdit(); - olditem.set(field, item.data[field]); - } - } - if (changes) { - olditem.endEdit(true); - olditem.commit(true); - } - } - if (addlist.length) { - store.add(addlist); - } - store.sort(); - store.resumeEvents(); - store.fireEvent('refresh', store); - }; - - Ext.apply(me, { - store: store, - stateful: true, - stateId: 'grid-resource', - tbar: [ - '->', - gettext('Search') + ':', - ' ', - { - xtype: 'textfield', - width: 200, - value: textfilter, - enableKeyEvents: true, - listeners: { - buffer: 500, - keyup: function (field, e) { - textfilter = field.getValue().toLowerCase(); - updateGrid(); - }, - }, - }, - ], - viewConfig: { - stripeRows: true, - }, - listeners: { - itemcontextmenu: PVE.Utils.createCmdMenu, - itemdblclick: function (v, record) { - var ws = me.up('pveStdWorkspace'); - ws.selectById(record.data.id); - }, - afterrender: function () { - updateGrid(); - }, - }, - columns: rstore.defaultColumns(), - }); - me.callParent(); - me.mon(rstore, 'load', () => updateGrid()); - }, -}); -/* - * Base class for all the multitab config panels - * - * How to use this: - * - * You create a subclass of this, and then define your wanted tabs - * as items like this: - * - * items: [{ - * title: "myTitle", - * xytpe: "somextype", - * iconCls: 'fa fa-icon', - * groups: ['somegroup'], - * expandedOnInit: true, - * itemId: 'someId' - * }] - * - * this has to be in the declarative syntax, else we - * cannot save them for later - * (so no Ext.create or Ext.apply of an item in the subclass) - * - * the groups array expects the itemids of the items - * which are the parents, which have to come before they - * are used - * - * if you want following the tree: - * - * Option1 - * Option2 - * -> SubOption1 - * -> SubSubOption1 - * - * the suboption1 group array has to look like this: - * groups: ['itemid-of-option2'] - * - * and of subsuboption1: - * groups: ['itemid-of-option2', 'itemid-of-suboption1'] - * - * setting the expandedOnInit determines if the item/group is expanded - * initially (false by default) - */ -Ext.define('PVE.panel.Config', { - extend: 'Ext.panel.Panel', - alias: 'widget.pvePanelConfig', - - showSearch: true, // add a resource grid with a search button as first tab - viewFilter: undefined, // a filter to pass to that resource grid - - tbarSpacing: true, // if true, adds a spacer after the title in tbar - - dockedItems: [ - { - // this is needed for the overflow handler - xtype: 'toolbar', - overflowHandler: 'scroller', - dock: 'left', - style: { - padding: 0, - margin: 0, - }, - cls: 'pve-toolbar-bg', - items: { - xtype: 'treelist', - itemId: 'menu', - ui: 'pve-nav', - expanderOnly: true, - expanderFirst: false, - animation: false, - singleExpand: false, - listeners: { - selectionchange: function (treeList, selection) { - if (!selection) { - return; - } - let view = this.up('panel'); - view.suspendLayout = true; - view.activateCard(selection.data.id); - view.suspendLayout = false; - view.updateLayout(); - }, - itemclick: function (treelist, info) { - var olditem = treelist.getSelection(); - var newitem = info.node; - - // when clicking on the expand arrow, we don't select items, but still want the original behaviour - if (info.select === false) { - return; - } - - // click on a different, open item then leave it open, else toggle the clicked item - if (olditem.data.id !== newitem.data.id && newitem.data.expanded === true) { - info.toggle = false; - } else { - info.toggle = true; - } - }, - }, - }, - }, - { - xtype: 'toolbar', - itemId: 'toolbar', - dock: 'top', - height: 36, - overflowHandler: 'scroller', - }, - ], - - firstItem: '', - layout: 'card', - border: 0, - - // used for automated test - selectById: function (cardid) { - var me = this; - - var root = me.store.getRoot(); - var selection = root.findChild('id', cardid, true); - - if (selection) { - selection.expand(); - let menu = me.down('#menu'); - menu.setSelection(selection); - return cardid; - } - return ''; - }, - - activateCard: function (cardid) { - var me = this; - if (me.savedItems[cardid]) { - let curcard = me.getLayout().getActiveItem(); - let newcard = me.add(me.savedItems[cardid]); - me.helpButton.setOnlineHelp(newcard.onlineHelp || me.onlineHelp); - if (curcard) { - me.setActiveItem(cardid); - me.remove(curcard, true); - - // trigger state change - - let ncard = cardid; - // Note: '' is alias for first tab. - // First tab can be 'search' or something else - if (cardid === me.firstItem) { - ncard = ''; - } - if (me.hstateid) { - me.sp.set(me.hstateid, { value: ncard }); - } - } - } - }, - - initComponent: function () { - var me = this; - - var stateid = me.hstateid; - - me.sp = Ext.state.Manager.getProvider(); - - var activeTab; // leaving this undefined means items[0] will be the default tab - - if (stateid) { - let state = me.sp.get(stateid); - if (state && state.value) { - // if this tab does not exist, it chooses the first - activeTab = state.value; - } - } - - // get title - var title = me.title || me.pveSelNode.data.text; - me.title = undefined; - - // create toolbar - var tbar = me.tbar || []; - me.tbar = undefined; - - if (!me.onlineHelp) { - // use the onlineHelp property indirection to enforce checking reference validity - let typeToOnlineHelp = { - 'type/lxc': { onlineHelp: 'chapter_pct' }, - 'type/node': { onlineHelp: 'chapter_system_administration' }, - 'type/pool': { onlineHelp: 'pveum_pools' }, - 'type/qemu': { onlineHelp: 'chapter_virtual_machines' }, - 'type/sdn': { onlineHelp: 'chapter_pvesdn' }, - 'type/network': { onlineHelp: 'chapter_pvesdn' }, - 'type/storage': { onlineHelp: 'chapter_storage' }, - }; - me.onlineHelp = typeToOnlineHelp[me.pveSelNode.data.id]?.onlineHelp; - } - - if (me.tbarSpacing) { - tbar.unshift('->'); - } - tbar.unshift({ - xtype: 'tbtext', - text: title, - baseCls: 'x-panel-header-text', - }); - - me.helpButton = Ext.create('Proxmox.button.Help', { - hidden: false, - listenToGlobalEvent: false, - onlineHelp: me.onlineHelp || undefined, - }); - - tbar.push(me.helpButton); - - me.dockedItems[1].items = tbar; - - // include search tab - me.items = me.items || []; - if (me.showSearch) { - me.items.unshift({ - xtype: 'pveResourceGrid', - itemId: 'search', - title: gettext('Search'), - iconCls: 'fa fa-search', - pveSelNode: me.pveSelNode, - }); - } - - me.savedItems = {}; - if (me.items[0]) { - me.firstItem = me.items[0].itemId; - } - - me.store = Ext.create('Ext.data.TreeStore', { - root: { - expanded: true, - }, - }); - var root = me.store.getRoot(); - me.insertNodes(me.items); - - delete me.items; - me.defaults = me.defaults || {}; - Ext.apply(me.defaults, { - pveSelNode: me.pveSelNode, - viewFilter: me.viewFilter, - workspace: me.workspace, - border: 0, - }); - - me.callParent(); - - var menu = me.down('#menu'); - var selection = root.findChild('id', activeTab, true) || root.firstChild; - var node = selection; - while (node !== root) { - node.expand(); - node = node.parentNode; - } - menu.setStore(me.store); - menu.setSelection(selection); - - // on a state change, - // select the new item - var statechange = function (sp, key, state) { - // it the state change is for this panel - if (stateid && key === stateid && state) { - // get active item - let acard = me.getLayout().getActiveItem().itemId; - // get the itemid of the new value - let ncard = state.value || me.firstItem; - if (ncard && acard !== ncard) { - // select the chosen item - menu.setSelection(root.findChild('id', ncard, true) || root.firstChild); - } - } - }; - - if (stateid) { - me.mon(me.sp, 'statechange', statechange); - } - }, - - insertNodes: function (items) { - var me = this; - var root = me.store.getRoot(); - - items.forEach(function (item) { - var treeitem = Ext.create('Ext.data.TreeModel', { - id: item.itemId, - text: item.title, - iconCls: item.iconCls, - leaf: true, - expanded: item.expandedOnInit, - }); - item.header = false; - if (me.savedItems[item.itemId] !== undefined) { - throw 'itemId already exists, please use another'; - } - me.savedItems[item.itemId] = item; - - var group; - var curnode = root; - - // get/create the group items - while (Ext.isArray(item.groups) && item.groups.length > 0) { - group = item.groups.shift(); - - let child = curnode.findChild('id', group); - if (child === null) { - // did not find the group item - // so add it where we are - break; - } - curnode = child; - } - - // insert the item - - // lets see if it already exists - var node = curnode.findChild('id', item.itemId); - - if (node === null) { - curnode.appendChild(treeitem); - } else { - // should not happen! - throw 'id already exists'; - } - }); - }, -}); -/* - * Input panel for advanced backup options intended to be used as part of an edit/create window. - */ -Ext.define('PVE.panel.BackupAdvancedOptions', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveBackupAdvancedOptionsPanel', - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: function () { - let me = this; - me.isCreate = !!me.isCreate; - return {}; - }, - - viewModel: { - data: {}, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - toggleFleecing: function (cb, value) { - let me = this; - me.lookup('fleecingStorage').setDisabled(!value); - }, - - control: { - 'proxmoxcheckbox[reference=fleecingEnabled]': { - change: 'toggleFleecing', - }, - }, - }, - - onGetValues: function (formValues) { - let me = this; - if (me.needMask) { - // isMasked() may not yet be true if not rendered once - return {}; - } - - if (!formValues.id && me.isCreate) { - formValues.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); - } - - let options = {}; - - if (!me.isCreate) { - options.delete = []; // to avoid having to check this all the time - } - const deletePropertyOnEdit = me.isCreate - ? () => { - /* no-op on create */ - } - : (key) => options.delete.push(key); - - let fleecing = {}, - fleecingOptions = ['fleecing-enabled', 'fleecing-storage']; - let performance = {}, - performanceOptions = ['max-workers', 'pbs-entries-max']; - - for (const [key, value] of Object.entries(formValues)) { - if (performanceOptions.includes(key)) { - performance[key] = value; - // deleteEmpty is not currently implemented for pveBandwidthField - } else if (key === 'bwlimit' && value === '') { - deletePropertyOnEdit('bwlimit'); - } else if (key === 'delete') { - if (Array.isArray(value)) { - value - .filter((opt) => !performanceOptions.includes(opt)) - .forEach((opt) => deletePropertyOnEdit(opt)); - } else if (!performanceOptions.includes(formValues.delete)) { - deletePropertyOnEdit(value); - } - } else if (fleecingOptions.includes(key)) { - let fleecingKey = key.slice('fleecing-'.length); - fleecing[fleecingKey] = value; - } else { - options[key] = value; - } - } - - if (Object.keys(performance).length > 0) { - options.performance = PVE.Parser.printPropertyString(performance); - } else { - deletePropertyOnEdit('performance'); - } - - if (Object.keys(fleecing).length > 0) { - options.fleecing = PVE.Parser.printPropertyString(fleecing); - } else { - deletePropertyOnEdit('fleecing'); - } - - if (me.isCreate) { - delete options.delete; - } - - return options; - }, - - onSetValues: function (values) { - if (values.fleecing) { - for (const [key, value] of Object.entries(values.fleecing)) { - values[`fleecing-${key}`] = value; - } - delete values.fleecing; - } - if (values['pbs-change-detection-mode'] === '__default__') { - delete values['pbs-change-detection-mode']; - } - return values; - }, - - updateCompression: function (value, disabled) { - this.lookup('zstdThreadCount').setDisabled(disabled || value !== 'zstd'); - }, - - items: [ - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'pmxDisplayEditField', - vtype: 'ConfigId', - fieldLabel: gettext('Job ID'), - emptyText: gettext('Autogenerate'), - name: 'id', - allowBlank: true, - cbind: { - editable: '{isCreate}', - }, - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: gettext('Can be used in notification matchers to match this job.'), - }, - }, - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'pveBandwidthField', - name: 'bwlimit', - fieldLabel: gettext('Bandwidth Limit'), - emptyText: gettext('Fallback'), - backendUnit: 'KiB', - allowZero: true, - emptyValue: '', - autoEl: { - tag: 'div', - 'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), 0), - }, - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: `${gettext('Limit I/O bandwidth.')} ${Ext.String.format(gettext('Schema default: {0}'), 0)}`, - }, - }, - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'proxmoxintegerfield', - name: 'zstd', - reference: 'zstdThreadCount', - fieldLabel: gettext('Zstd Threads'), - fieldStyle: 'text-align: right', - emptyText: gettext('Fallback'), - minValue: 0, - cbind: { - deleteEmpty: '{!isCreate}', - }, - autoEl: { - tag: 'div', - 'data-qtip': gettext('With 0, half of the available cores are used'), - }, - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: `${gettext('Threads used for zstd compression (non-PBS).')} ${Ext.String.format(gettext('Schema default: {0}'), 1)}`, - }, - }, - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'proxmoxintegerfield', - name: 'max-workers', - minValue: 1, - maxValue: 256, - fieldLabel: gettext('IO-Workers'), - fieldStyle: 'text-align: right', - emptyText: gettext('Fallback'), - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: `${gettext('I/O workers in the QEMU process (VMs only).')} ${Ext.String.format(gettext('Schema default: {0}'), 16)}`, - }, - }, - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'proxmoxcheckbox', - name: 'fleecing-enabled', - reference: 'fleecingEnabled', - fieldLabel: gettext('Fleecing'), - uncheckedValue: 0, - value: 0, - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: gettext( - 'Backup write cache that can reduce IO pressure inside guests (VMs only).', - ), - }, - }, - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'pveStorageSelector', - name: 'fleecing-storage', - fieldLabel: gettext('Fleecing Storage'), - reference: 'fleecingStorage', - clusterView: true, - storageContent: 'images', - allowBlank: false, - disabled: true, - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: gettext( - 'Prefer a fast and local storage, ideally with support for discard and thin-provisioning or sparse files.', - ), - }, - }, - { - // It's part of the 'performance' property string, so have a field to preserve the - // value, but don't expose it. It's a rather niche setting and difficult to - // convey/understand what it does. - xtype: 'proxmoxintegerfield', - name: 'pbs-entries-max', - hidden: true, - fieldLabel: 'TODO', - fieldStyle: 'text-align: right', - emptyText: 'TODO', - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Repeat missed'), - name: 'repeat-missed', - uncheckedValue: 0, - defaultValue: 0, - cbind: { - deleteDefaultValue: '{!isCreate}', - }, - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: gettext( - "Run jobs as soon as possible if they couldn't start on schedule, for example, due to the node being offline.", - ), - }, - }, - { - xtype: 'pveTwoColumnContainer', - startColumn: { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('PBS change detection mode'), - name: 'pbs-change-detection-mode', - deleteEmpty: true, - value: '__default__', - comboItems: [ - ['__default__', 'Default'], - ['data', 'Data'], - ['metadata', 'Metadata'], - ], - }, - endFlex: 2, - endColumn: { - xtype: 'displayfield', - value: gettext( - 'Mode to detect file changes and switch archive encoding format for container backups.', - ), - }, - }, - { - xtype: 'component', - padding: '5 1', - html: `${gettext('Note')}: ${gettext( - "The node-specific 'vzdump.conf' or, if this is not set, the default from the config schema is used to determine fallback values.", - )}`, - }, - ], -}); -/* - * Input panel for notification options of backup jobs. - */ -Ext.define('PVE.panel.BackupNotificationOptions', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveBackupNotificationOptionsPanel', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'chapter_notifications', - - cbindData: function () { - let me = this; - me.isCreate = !!me.isCreate; - return {}; - }, - - viewModel: { - data: { - notificationMode: undefined, - }, - formulas: { - showMailtoFields: (get) => { - let mode = get('notificationMode'); - return mode['notification-mode'] === 'legacy-sendmail'; - }, - }, - }, - - onSetValues: function (values) { - let me = this; - - let mode = values['notification-mode'] ?? 'auto'; - let mailto = values.mailto; - - let mappedMode = 'legacy-sendmail'; - - // The 'auto' mode is a bit annoying and confusing, so we try - // to map it to the equivalent behavior. - if ((mode === 'auto' && !mailto) || mode === 'notification-system') { - mappedMode = 'notification-system'; - } - - me.getViewModel().set('notificationMode', { 'notification-mode': mappedMode }); - - values['notification-mode'] = mappedMode; - return values; - }, - - items: [ - { - xtype: 'radiogroup', - height: '15px', - layout: { - type: 'vbox', - }, - bind: { - value: '{notificationMode}', - }, - items: [ - { - xtype: 'radiofield', - name: 'notification-mode', - inputValue: 'notification-system', - boxLabel: gettext('Use global notification settings'), - cbind: { - checked: '{isCreate}', - }, - }, - { - xtype: 'radiofield', - name: 'notification-mode', - inputValue: 'legacy-sendmail', - boxLabel: gettext('Use sendmail to send an email (legacy)'), - }, - ], - }, - { - xtype: 'textfield', - fieldLabel: gettext('Recipients'), - emptyText: 'test@example.com, ...', - name: 'mailto', - padding: '0 0 0 50', - disabled: true, - bind: { - disabled: '{!showMailtoFields}', - }, - }, - { - xtype: 'pveEmailNotificationSelector', - fieldLabel: gettext('When'), - name: 'mailnotification', - padding: '0 0 0 50', - disabled: true, - value: 'always', - cbind: { - deleteEmpty: '{!isCreate}', - }, - bind: { - disabled: '{!showMailtoFields}', - }, - }, - ], -}); -/* - * Input panel for prune settings with a keep-all option intended to be used as - * part of an edit/create window. - */ -Ext.define('PVE.panel.BackupJobPrune', { - extend: 'Proxmox.panel.PruneInputPanel', - xtype: 'pveBackupJobPrunePanel', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'vzdump_retention', - - onGetValues: function (formValues) { - if (this.needMask) { - // isMasked() may not yet be true if not rendered once - return {}; - } else if (this.isCreate && !this.rendered) { - return this.keepAllDefaultForCreate ? { 'prune-backups': 'keep-all=1' } : {}; - } - - let options = { delete: [] }; - - if ('max-protected-backups' in formValues) { - options['max-protected-backups'] = formValues['max-protected-backups']; - } else if (this.hasMaxProtected) { - options.delete.push('max-protected-backups'); - } - - delete formValues['max-protected-backups']; - delete formValues.delete; - - let retention = PVE.Parser.printPropertyString(formValues); - if (retention === '') { - options.delete.push('prune-backups'); - } else { - options['prune-backups'] = retention; - } - - if (this.isCreate) { - delete options.delete; - } - - return options; - }, - - updateComponents: function () { - let me = this; - - let keepAll = me.down('proxmoxcheckbox[name=keep-all]').getValue(); - let anyValue = false; - me.query('pmxPruneKeepField').forEach((field) => { - anyValue = anyValue || field.getValue() !== null; - field.setDisabled(keepAll); - }); - me.down('component[name=no-keeps-hint]').setHidden(anyValue || keepAll); - }, - - listeners: { - afterrender: function (panel) { - if (panel.needMask) { - panel.down('component[name=no-keeps-hint]').setHtml(''); - panel.mask(gettext('Backup content type not available for this storage.')); - } else if (panel.isCreate && panel.keepAllDefaultForCreate) { - panel.down('proxmoxcheckbox[name=keep-all]').setValue(true); - } - panel.down('component[name=pbs-hint]').setHidden(!panel.showPBSHint); - - let maxProtected = panel.down('proxmoxintegerfield[name=max-protected-backups]'); - maxProtected.setDisabled(!panel.hasMaxProtected); - maxProtected.setHidden(!panel.hasMaxProtected); - - panel.query('pmxPruneKeepField').forEach((field) => { - field.on('change', panel.updateComponents, panel); - }); - panel.updateComponents(); - }, - }, - - columnT: { - xtype: 'proxmoxcheckbox', - name: 'keep-all', - boxLabel: gettext('Keep all backups'), - listeners: { - change: function (field, newValue) { - let panel = field.up('pveBackupJobPrunePanel'); - panel.updateComponents(); - }, - }, - }, - - columnB: [ - { - xtype: 'component', - userCls: 'pmx-hint', - name: 'no-keeps-hint', - hidden: true, - padding: '5 1', - cbind: { - html: '{fallbackHintHtml}', - }, - }, - { - xtype: 'component', - userCls: 'pmx-hint', - name: 'pbs-hint', - hidden: true, - padding: '5 1', - html: gettext( - "It's preferred to configure backup retention directly on the Proxmox Backup Server.", - ), - }, - { - xtype: 'proxmoxintegerfield', - name: 'max-protected-backups', - fieldLabel: gettext('Maximum Protected'), - minValue: -1, - hidden: true, - disabled: true, - emptyText: 'unlimited with Datastore.Allocate privilege, 5 otherwise', - deleteEmpty: true, - autoEl: { - tag: 'div', - 'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), -1), - }, - }, - ], -}); -Ext.define('PVE.widget.HealthWidget', { - extend: 'Ext.Component', - alias: 'widget.pveHealthWidget', - - data: { - iconCls: PVE.Utils.get_health_icon(undefined, true), - text: '', - title: '', - }, - - style: { - 'text-align': 'center', - }, - - tpl: ['

    {title}

    ', '', '

    ', '{text}'], - - updateHealth: function (data) { - var me = this; - me.update(Ext.apply(me.data, data)); - }, - - initComponent: function () { - var me = this; - - if (me.title) { - me.config.data.title = me.title; - } - - me.callParent(); - }, -}); -Ext.define('pve-fw-ipsets', { - extend: 'Ext.data.Model', - fields: ['name', 'comment', 'digest'], - idProperty: 'name', -}); - -Ext.define('PVE.IPSetList', { - extend: 'Ext.grid.Panel', - alias: 'widget.pveIPSetList', - - stateful: true, - stateId: 'grid-firewall-ipsetlist', - - ipset_panel: undefined, - - base_url: undefined, - - addBtn: undefined, - removeBtn: undefined, - editBtn: undefined, - - initComponent: function () { - var me = this; - - if (typeof me.ipset_panel === 'undefined') { - throw 'no rule panel specified'; - } - - if (typeof me.ipset_panel === 'undefined') { - throw 'no base_url specified'; - } - - var store = new Ext.data.Store({ - model: 'pve-fw-ipsets', - proxy: { - type: 'proxmox', - url: '/api2/json' + me.base_url, - }, - sorters: { - property: 'name', - direction: 'ASC', - }, - }); - - var caps = Ext.state.Manager.get('GuiCap'); - let canEdit = - !!caps.vms['VM.Config.Network'] || - !!caps.dc['Sys.Modify'] || - !!caps.nodes['Sys.Modify']; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var reload = function () { - var oldrec = sm.getSelection()[0]; - store.load(function (records, operation, success) { - if (oldrec) { - let rec = store.findRecord('name', oldrec.data.name, 0, false, true, true); - if (rec) { - sm.select(rec); - } - } - }); - }; - - var run_editor = function () { - var rec = sm.getSelection()[0]; - if (!rec || !canEdit) { - return; - } - var win = Ext.create('Proxmox.window.Edit', { - subject: "IPSet '" + rec.data.name + "'", - url: me.base_url, - method: 'POST', - digest: rec.data.digest, - items: [ - { - xtype: 'hiddenfield', - name: 'rename', - value: rec.data.name, - }, - { - xtype: 'textfield', - name: 'name', - value: rec.data.name, - fieldLabel: gettext('Name'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'comment', - value: rec.data.comment, - fieldLabel: gettext('Comment'), - }, - ], - }); - win.show(); - win.on('destroy', reload); - }; - - me.editBtn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - enableFn: (rec) => canEdit, - selModel: sm, - handler: run_editor, - }); - - me.addBtn = new Proxmox.button.Button({ - text: gettext('Create'), - handler: function () { - sm.deselectAll(); - var win = Ext.create('Proxmox.window.Edit', { - subject: 'IPSet', - url: me.base_url, - method: 'POST', - items: [ - { - xtype: 'textfield', - name: 'name', - value: '', - fieldLabel: gettext('Name'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'comment', - value: '', - fieldLabel: gettext('Comment'), - }, - ], - }); - win.show(); - win.on('destroy', reload); - }, - }); - - me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { - enableFn: (rec) => canEdit, - selModel: sm, - baseurl: me.base_url + '/', - callback: reload, - }); - - Ext.apply(me, { - store: store, - tbar: ['IPSet:', me.addBtn, me.removeBtn, me.editBtn], - selModel: sm, - columns: [ - { - header: 'IPSet', - dataIndex: 'name', - minWidth: 150, - flex: 1, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 4, - }, - ], - listeners: { - itemdblclick: run_editor, - select: function (_, rec) { - var url = me.base_url + '/' + rec.data.name; - me.ipset_panel.setBaseUrl(url); - }, - deselect: function () { - me.ipset_panel.setBaseUrl(undefined); - }, - show: reload, - }, - }); - - if (!canEdit) { - me.addBtn.setDisabled(true); - } - - me.callParent(); - - store.load(); - }, -}); - -Ext.define('PVE.IPSetCidrEdit', { - extend: 'Proxmox.window.Edit', - - cidr: undefined, - - initComponent: function () { - var me = this; - - me.isCreate = me.cidr === undefined; - - if (me.isCreate) { - me.url = '/api2/extjs' + me.base_url; - me.method = 'POST'; - } else { - me.url = '/api2/extjs' + me.base_url + '/' + me.cidr; - me.method = 'PUT'; - } - - var column1 = []; - - if (me.isCreate) { - if (!me.list_refs_url) { - throw 'no alias_base_url specified'; - } - - column1.push({ - xtype: 'pveIPRefSelector', - name: 'cidr', - ref_type: 'alias', - autoSelect: false, - editable: true, - base_url: me.list_refs_url, - allowBlank: false, - fieldLabel: gettext('IP/CIDR'), - }); - } else { - column1.push({ - xtype: 'displayfield', - name: 'cidr', - value: '', - fieldLabel: gettext('IP/CIDR'), - }); - } - - var ipanel = Ext.create('Proxmox.panel.InputPanel', { - isCreate: me.isCreate, - column1: column1, - column2: [ - { - xtype: 'proxmoxcheckbox', - name: 'nomatch', - checked: false, - uncheckedValue: 0, - fieldLabel: 'nomatch', - }, - ], - columnB: [ - { - xtype: 'textfield', - name: 'comment', - value: '', - fieldLabel: gettext('Comment'), - }, - ], - }); - - Ext.apply(me, { - subject: gettext('IP/CIDR'), - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - ipanel.setValues(values); - }, - }); - } - }, -}); - -Ext.define( - 'PVE.IPSetGrid', - { - extend: 'Ext.grid.Panel', - alias: 'widget.pveIPSetGrid', - - stateful: true, - stateId: 'grid-firewall-ipsets', - - base_url: undefined, - list_refs_url: undefined, - - addBtn: undefined, - removeBtn: undefined, - editBtn: undefined, - - setBaseUrl: function (url) { - var me = this; - - me.base_url = url; - - if (url === undefined) { - me.addBtn.setDisabled(true); - me.store.removeAll(); - } else { - if (me.canEdit) { - me.addBtn.setDisabled(false); - } - me.removeBtn.baseurl = url + '/'; - me.store.setProxy({ - type: 'proxmox', - url: '/api2/json' + url, - }); - - me.store.load(); - } - }, - - initComponent: function () { - var me = this; - - if (!me.list_refs_url) { - throw 'no1 list_refs_url specified'; - } - - var store = new Ext.data.Store({ - model: 'pve-ipset', - }); - - var reload = function () { - store.load(); - }; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - me.caps = Ext.state.Manager.get('GuiCap'); - me.canEdit = - !!me.caps.vms['VM.Config.Network'] || - !!me.caps.dc['Sys.Modify'] || - !!me.caps.nodes['Sys.Modify']; - - var run_editor = function () { - var rec = sm.getSelection()[0]; - if (!rec || !me.canEdit) { - return; - } - var win = Ext.create('PVE.IPSetCidrEdit', { - base_url: me.base_url, - cidr: rec.data.cidr, - }); - win.show(); - win.on('destroy', reload); - }; - - me.editBtn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - enableFn: (rec) => me.canEdit, - selModel: sm, - handler: run_editor, - }); - - me.addBtn = new Proxmox.button.Button({ - text: gettext('Add'), - disabled: true, - enableFn: (rec) => me.canEdit, - handler: function () { - if (!me.base_url) { - return; - } - var win = Ext.create('PVE.IPSetCidrEdit', { - base_url: me.base_url, - list_refs_url: me.list_refs_url, - }); - win.show(); - win.on('destroy', reload); - }, - }); - - me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { - disabled: true, - enableFn: (rec) => me.canEdit, - selModel: sm, - baseurl: me.base_url + '/', - callback: reload, - }); - - var render_errors = function (value, metaData, record) { - var errors = record.data.errors; - if (errors) { - let msg = errors.cidr || errors.nomatch; - if (msg) { - metaData.tdCls = 'proxmox-invalid-row'; - let html = Ext.htmlEncode(`

    ${Ext.htmlEncode(msg)}

    `); - metaData.tdAttr = `data-qwidth=600 data-qtitle="ERROR" data-qtip="${html}"`; - } - } - return Ext.htmlEncode(value); - }; - - Ext.apply(me, { - tbar: ['IP/CIDR:', me.addBtn, me.removeBtn, me.editBtn], - store: store, - selModel: sm, - listeners: { - itemdblclick: run_editor, - }, - columns: [ - { - xtype: 'rownumberer', - // cannot use width on instantiation as rownumberer hard-wires that in the - // constructor to avoid being overridden by applyDefaults - minWidth: 40, - }, - { - header: gettext('IP/CIDR'), - dataIndex: 'cidr', - minWidth: 150, - flex: 1, - renderer: function (value, metaData, record) { - value = render_errors(value, metaData, record); - if (record.data.nomatch) { - return '! ' + value; - } - return value; - }, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - flex: 3, - renderer: function (value) { - return Ext.util.Format.htmlEncode(value); - }, - }, - ], - }); - - me.callParent(); - - if (me.base_url) { - me.setBaseUrl(me.base_url); // load - } - }, - }, - function () { - Ext.define('pve-ipset', { - extend: 'Ext.data.Model', - fields: [{ name: 'nomatch', type: 'boolean' }, 'cidr', 'comment', 'errors'], - idProperty: 'cidr', - }); - }, -); - -Ext.define('PVE.IPSet', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveIPSet', - - title: 'IPSet', - - onlineHelp: 'pve_firewall_ip_sets', - - list_refs_url: undefined, - - initComponent: function () { - var me = this; - - if (!me.list_refs_url) { - throw 'no list_refs_url specified'; - } - - var ipset_panel = Ext.createWidget('pveIPSetGrid', { - region: 'center', - list_refs_url: me.list_refs_url, - border: false, - }); - - var ipset_list = Ext.createWidget('pveIPSetList', { - region: 'west', - ipset_panel: ipset_panel, - base_url: me.base_url, - width: '50%', - border: false, - split: true, - }); - - Ext.apply(me, { - layout: 'border', - items: [ipset_list, ipset_panel], - listeners: { - show: function () { - ipset_list.fireEvent('show', ipset_list); - }, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.window.IPInfo', { - extend: 'Ext.window.Window', - width: 600, - title: gettext('Network Information'), - height: 300, - layout: { - type: 'fit', - }, - modal: true, - items: [ - { - xtype: 'grid', - store: {}, - emptyText: gettext('No network information'), - viewConfig: { - enableTextSelection: true, - }, - columns: [ - { - dataIndex: 'name', - text: gettext('Name'), - renderer: Ext.htmlEncode, - flex: 3, - }, - { - dataIndex: 'hardware-address', - text: gettext('MAC address'), - renderer: Ext.htmlEncode, - width: 140, - }, - { - dataIndex: 'ip-addresses', - text: gettext('IP address'), - align: 'right', - flex: 4, - renderer: function (val) { - if (!Ext.isArray(val)) { - return ''; - } - var ips = []; - val.forEach(function (ip) { - var addr = ip['ip-address']; - var pref = ip.prefix; - if (addr && pref) { - ips.push(Ext.htmlEncode(addr + '/' + pref)); - } - }); - return ips.join('
    '); - }, - }, - ], - }, - ], -}); - -Ext.define('PVE.panel.IPViewBase', { - extend: 'Ext.container.Container', - xtype: 'pveIPViewBase', - - layout: { - type: 'hbox', - align: 'top', - }, - - nics: [], - - items: [ - { - xtype: 'box', - html: ' IPs', - }, - { - xtype: 'container', - flex: 1, - layout: { - type: 'vbox', - align: 'right', - pack: 'end', - }, - items: [ - { - xtype: 'label', - flex: 1, - itemId: 'ipBox', - style: { - 'text-align': 'right', - }, - }, - { - xtype: 'button', - itemId: 'moreBtn', - hidden: true, - ui: 'default-toolbar', - handler: function (btn) { - let view = this.up('pveIPViewBase'); - - var win = Ext.create('PVE.window.IPInfo'); - win.down('grid').getStore().setData(view.nics); - win.show(); - }, - text: gettext('More'), - }, - ], - }, - ], - - getDefaultIps: function (nics) { - var _me = this; - var ips = []; - nics.forEach(function (nic) { - if ( - nic['hardware-address'] && - nic['hardware-address'] !== '00:00:00:00:00:00' && - nic['hardware-address'] !== '0:0:0:0:0:0' - ) { - let nic_ips = nic['ip-addresses'] || []; - nic_ips.forEach(function (ip) { - var p = ip['ip-address']; - // show 2 ips at maximum - if (ips.length < 2) { - ips.push(Ext.htmlEncode(p)); - } - }); - } - }); - - return ips; - }, - - createUpdateStore: function (nodename, vmid) { - // implement me in sub-class - }, - - startIPStore: function (store, records, success) { - // implement me in sub-class - }, - - updateStatus: function (unsuccessful, defaultText) { - // implement me in sub-class - }, - - initComponent: function () { - var me = this; - - if (!me.rstore) { - throw 'rstore not given'; - } - - if (!me.pveSelNode) { - throw 'pveSelNode not given'; - } - - me.callParent(); - - let { node, vmid } = me.pveSelNode.data; - me.createUpdateStore(node, vmid); - - me.on('destroy', me.ipStore.stopUpdate, me.ipStore); - - // if we already have info about the vm, use it immediately - if (me.rstore.getCount()) { - me.startIPStore(me.rstore, me.rstore.getData(), false); - } - - me.mon(me.rstore, 'load', me.startIPStore, me); - }, -}); - -Ext.define('PVE.panel.IPViewQEMU', { - extend: 'PVE.panel.IPViewBase', - xtype: 'pveIPViewQEMU', - - createUpdateStore: function (nodename, vmid) { - let me = this; - - me.ipStore = Ext.create('Proxmox.data.UpdateStore', { - interval: 10000, - storeid: `pve-qemu-agent-${vmid}`, - method: 'POST', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${nodename}/qemu/${vmid}/agent/network-get-interfaces`, - }, - }); - - me.mon(me.ipStore, 'load', function (_store, records, success) { - me.nics = records?.[0]?.data.result; - me.updateStatus(!success); - }); - }, - - updateStatus: function (unsuccessful, defaultText) { - let me = this; - - let text = defaultText || gettext('No network information'); - let more = false; - if (unsuccessful) { - text = gettext('Guest Agent not running'); - } else if (me.agent && me.running) { - if (Ext.isArray(me.nics) && me.nics.length) { - more = true; - let ips = me.getDefaultIps(me.nics); - if (ips.length !== 0) { - text = ips.join('
    '); - } - } else if (me.nics && me.nics.error) { - let msg = gettext('Cannot get info from Guest Agent
    Error: {0}'); - text = Ext.String.format(msg, Ext.htmlEncode(me.nics.error.desc)); - } - } else if (me.agent) { - text = gettext('Guest Agent not running'); - } else { - text = gettext('No Guest Agent configured'); - } - - me.down('#ipBox').update(text); - me.down('#moreBtn').setVisible(more); - }, - - startIPStore: function (store, records, success) { - let me = this; - - let agentRec = store.getById('agent'); - let state = store.getById('status'); - - me.agent = agentRec && agentRec.data.value === 1; - me.running = state && state.data.value === 'running'; - - let caps = Ext.state.Manager.get('GuiCap'); - if (!caps.vms['VM.GuestAgent.Audit']) { - me.updateStatus( - false, - Ext.String.format(gettext("Requires '{0}' Privileges"), 'VM.GuestAgent.Audit'), - ); - return; - } - - if (me.agent && me.running && me.ipStore.isStopped) { - me.ipStore.startUpdate(); - } else if (me.ipStore.isStopped) { - me.updateStatus(); - } - }, -}); - -Ext.define('PVE.panel.IPViewLXC', { - extend: 'PVE.panel.IPViewBase', - xtype: 'pveIPViewLXC', - - createUpdateStore: function (nodename, vmid) { - let me = this; - - me.ipStore = Ext.create('Proxmox.data.UpdateStore', { - interval: 10000, - storeid: `lxc-interfaces-${vmid}`, - method: 'GET', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${nodename}/lxc/${vmid}/interfaces`, - }, - }); - - me.mon(me.ipStore, 'load', function (_store, records, success) { - me.nics = records?.map((r) => r.data); - me.updateStatus(!success); - }); - }, - - updateStatus: function (_unsuccessful, defaultText) { - let me = this; - - let text = defaultText || gettext('No network information'); - let more = false; - if (Ext.isArray(me.nics) && me.nics.length) { - more = true; - let ips = me.getDefaultIps(me.nics); - if (ips.length !== 0) { - text = ips.join('
    '); - } - } - me.down('#ipBox').update(text); - me.down('#moreBtn').setVisible(more); - }, - - startIPStore: function (store, records, success) { - let me = this; - - let state = store.getById('status'); - me.running = state && state.data.value === 'running'; - - var caps = Ext.state.Manager.get('GuiCap'); - - if (!caps.vms['VM.Audit']) { - me.updateStatus( - false, - Ext.String.format(gettext("Requires '{0}' Privileges"), 'VM.Audit'), - ); - return; - } - - if (me.running && me.ipStore.isStopped) { - me.ipStore.startUpdate(); - } else if (me.ipStore.isStopped) { - me.updateStatus(); - } - }, -}); -/* - * This is a running chart widget you add time datapoints to it, and we only - * show the last x of it used for ceph performance charts - */ -Ext.define('PVE.widget.RunningChart', { - extend: 'Ext.container.Container', - alias: 'widget.pveRunningChart', - - layout: { - type: 'hbox', - align: 'center', - }, - items: [ - { - width: 80, - xtype: 'box', - itemId: 'title', - data: { - title: '', - }, - tpl: '

    {title}:

    ', - }, - { - flex: 1, - xtype: 'cartesian', - height: '100%', - itemId: 'chart', - border: false, - axes: [ - { - type: 'numeric', - position: 'left', - hidden: true, - minimum: 0, - }, - { - type: 'numeric', - position: 'bottom', - hidden: true, - }, - ], - - store: { - trackRemoved: false, - data: {}, - }, - - sprites: [ - { - id: 'valueSprite', - type: 'text', - text: '0 B/s', - textAlign: 'end', - textBaseline: 'middle', - fontSize: 14, - }, - ], - - series: [ - { - type: 'line', - xField: 'time', - yField: 'val', - fill: 'true', - colors: ['#cfcfcf'], - tooltip: { - trackMouse: true, - renderer: function (tooltip, record, ctx) { - if (!record || !record.data) { - return; - } - const view = this.getChart(); - const date = new Date(record.data.time); - const value = view.up().renderer(record.data.val); - const line1 = `${view.up().title}: ${value}`; - const line2 = Ext.Date.format(date, 'H:i:s'); - tooltip.setHtml(`${line1}
    ${line2}`); - }, - }, - style: { - lineWidth: 1.5, - opacity: 0.6, - }, - marker: { - opacity: 0, - scaling: 0.01, - fx: { - duration: 200, - easing: 'easeOut', - }, - }, - highlightCfg: { - opacity: 1, - scaling: 1.5, - }, - }, - ], - }, - ], - - // the renderer for the tooltip and last value, default just the value - renderer: Ext.identityFn, - - // show the last x seconds default is 5 minutes - timeFrame: 5 * 60, - - checkThemeColors: function () { - let me = this; - let rootStyle = getComputedStyle(document.documentElement); - - // get color - let background = rootStyle.getPropertyValue('--pwt-panel-background').trim() || '#ffffff'; - let text = rootStyle.getPropertyValue('--pwt-text-color').trim() || '#000000'; - - // set the colors - me.chart.setBackground(background); - me.chart.valuesprite.setAttributes({ fillStyle: text }, true); - me.chart.redraw(); - }, - - addDataPoint: function (value, time) { - let view = this.chart; - let panel = view.up(); - let now = new Date().getTime(); - let begin = new Date(now - 1000 * panel.timeFrame).getTime(); - - view.store.add({ - time: time || now, - val: value || 0, - }); - - // delete all old records when we have 20 times more datapoints - // than seconds in our timeframe (so even a subsecond graph does - // not trigger this often) - // - // records in the store do not take much space, but like this, - // we prevent a memory leak when someone has the site open for a long time - // with minimal graphical glitches - if (view.store.count() > panel.timeFrame * 20) { - let oldData = view.store.getData().createFiltered(function (item) { - return item.data.time < begin; - }); - - view.store.remove(oldData.getRange()); - } - - view.timeaxis.setMinimum(begin); - view.timeaxis.setMaximum(now); - view.valuesprite.setText(panel.renderer(value || 0).toString()); - view.valuesprite.setAttributes( - { - x: view.getWidth() - 15, - y: view.getHeight() / 2, - }, - true, - ); - view.redraw(); - }, - - setTitle: function (title) { - this.title = title; - let titlebox = this.getComponent('title'); - titlebox.update({ title: title }); - }, - - initComponent: function () { - var me = this; - me.callParent(); - - if (me.title) { - me.getComponent('title').update({ title: me.title }); - } - me.chart = me.getComponent('chart'); - me.chart.timeaxis = me.chart.getAxes()[1]; - me.chart.valuesprite = me.chart.getSurface('chart').get('valueSprite'); - if (me.color) { - me.chart.series[0].setStyle({ - fill: me.color, - stroke: me.color, - }); - } - - me.checkThemeColors(); - - // switch colors on media query changes - me.mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); - me.themeListener = (e) => { - me.checkThemeColors(); - }; - me.mediaQueryList.addEventListener('change', me.themeListener); - }, - - doDestroy: function () { - let me = this; - - me.mediaQueryList.removeEventListener('change', me.themeListener); - - me.callParent(); - }, -}); -/* - * This class describes the bottom panel - */ -Ext.define('PVE.panel.StatusPanel', { - extend: 'Ext.tab.Panel', - alias: 'widget.pveStatusPanel', - - //title: "Logs", - //tabPosition: 'bottom', - - initComponent: function () { - var me = this; - - var stateid = 'ltab'; - var sp = Ext.state.Manager.getProvider(); - - var state = sp.get(stateid); - if (state && state.value) { - me.activeTab = state.value; - } - - Ext.apply(me, { - listeners: { - tabchange: function () { - var atab = me.getActiveTab().itemId; - let tabstate = { value: atab }; - sp.set(stateid, tabstate); - }, - }, - items: [ - { - itemId: 'tasks', - title: gettext('Tasks'), - xtype: 'pveClusterTasks', - }, - { - itemId: 'clog', - title: gettext('Cluster log'), - xtype: 'pveClusterLog', - }, - ], - }); - - me.callParent(); - - me.items.get(0).fireEvent('show', me.items.get(0)); - - var statechange = function (_, key, newstate) { - if (key === stateid) { - let atab = me.getActiveTab().itemId; - let ntab = newstate.value; - if (newstate && ntab && atab !== ntab) { - me.setActiveTab(ntab); - } - } - }; - - sp.on('statechange', statechange); - me.on('destroy', function () { - sp.un('statechange', statechange); - }); - }, -}); -Ext.define('PVE.panel.GuestStatusView', { - extend: 'Proxmox.panel.StatusView', - alias: 'widget.pveGuestStatusView', - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: function (initialConfig) { - var me = this; - return { - isQemu: me.pveSelNode.data.type === 'qemu', - isLxc: me.pveSelNode.data.type === 'lxc', - }; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - if (view.pveSelNode.data.type !== 'lxc') { - return; - } - - const nodename = view.pveSelNode.data.node; - const vmid = view.pveSelNode.data.vmid; - - Proxmox.Utils.API2Request({ - url: `/api2/extjs/nodes/${nodename}/lxc/${vmid}/config`, - waitMsgTargetView: view, - method: 'GET', - success: ({ result }) => { - view.down('#unprivileged').updateValue( - Proxmox.Utils.format_boolean(result.data.unprivileged), - ); - view.ostype = Ext.htmlEncode(result.data.ostype); - }, - }); - }, - }, - - layout: { - type: 'vbox', - align: 'stretch', - }, - - defaults: { - xtype: 'pmxInfoWidget', - padding: '2 25', - }, - items: [ - { - xtype: 'box', - height: 20, - }, - { - itemId: 'status', - title: gettext('Status'), - iconCls: 'fa fa-info fa-fw', - printBar: false, - multiField: true, - renderer: function (record) { - var _me = this; - var text = record.data.status; - var qmpstatus = record.data.qmpstatus; - if (qmpstatus && qmpstatus !== record.data.status) { - text += ' (' + qmpstatus + ')'; - } - return text; - }, - }, - { - itemId: 'hamanaged', - iconCls: 'fa fa-heartbeat fa-fw', - title: gettext('HA State'), - printBar: false, - textField: 'ha', - renderer: PVE.Utils.format_ha, - }, - { - itemId: 'node', - iconCls: 'fa fa-building fa-fw', - title: gettext('Node'), - cbind: { - text: '{pveSelNode.data.node}', - }, - printBar: false, - }, - { - itemId: 'unprivileged', - iconCls: 'fa fa-lock fa-fw', - title: gettext('Unprivileged'), - printBar: false, - cbind: { - hidden: '{isQemu}', - }, - }, - { - xtype: 'box', - height: 10, - }, - { - itemId: 'cpu', - iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', - title: gettext('CPU usage'), - valueField: 'cpu', - maxField: 'cpus', - renderer: Proxmox.Utils.render_cpu_usage, - // in this specific api call - // we already have the correct value for the usage - calculate: Ext.identityFn, - }, - { - itemId: 'memory', - iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', - title: gettext('Memory usage'), - valueField: 'mem', - maxField: 'maxmem', - warningThreshold: 0.9, - criticalThreshold: 0.975, - }, - { - itemId: 'memory-host', - iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', - title: gettext('Host memory usage'), - valueField: 'memhost', - printBar: false, - renderer: function (used, max) { - return Proxmox.Utils.render_size(used); - }, - cbind: { - hidden: '{isLxc}', - disabled: '{isLxc}', - }, - }, - { - itemId: 'swap', - iconCls: 'fa fa-refresh fa-fw', - title: gettext('SWAP usage'), - valueField: 'swap', - maxField: 'maxswap', - cbind: { - hidden: '{isQemu}', - disabled: '{isQemu}', - }, - }, - { - itemId: 'rootfs', - iconCls: 'fa fa-hdd-o fa-fw', - title: gettext('Bootdisk size'), - valueField: 'disk', - maxField: 'maxdisk', - printBar: false, - renderer: function (used, max) { - var me = this; - me.setPrintBar(used > 0); - if (used === 0) { - return Proxmox.Utils.render_size(max); - } else { - return Proxmox.Utils.render_size_usage(used, max); - } - }, - }, - { - xtype: 'box', - height: 10, - }, - { - cbind: { - xtype: (get) => (get('isQemu') ? 'pveIPViewQEMU' : 'pveIPViewLXC'), - rstore: '{rstore}', - pveSelNode: '{pveSelNode}', - }, - }, - ], - - updateTitle: function () { - var me = this; - var uptime = me.getRecordValue('uptime'); - - var text = ''; - if (Number(uptime) > 0) { - text = - ' (' + gettext('Uptime') + ': ' + Proxmox.Utils.format_duration_long(uptime) + ')'; - } - - let title = `
    ${me.getRecordValue('name') + text}
    `; - - if (me.pveSelNode.data.type === 'lxc' && me.ostype && me.ostype !== 'unmanaged') { - // Manual mappings for distros with special casing - const namemap = { - archlinux: 'Arch Linux', - nixos: 'NixOS', - opensuse: 'openSUSE', - centos: 'CentOS', - }; - - const distro = namemap[me.ostype] ?? Ext.String.capitalize(me.ostype); - title += `
     ${distro}
    `; - } - - me.setTitle(title); - }, -}); -Ext.define('PVE.guest.Summary', { - extend: 'Ext.panel.Panel', - xtype: 'pveGuestSummary', - - scrollable: true, - bodyPadding: 5, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - if (!me.workspace) { - throw 'no workspace specified'; - } - - if (!me.statusStore) { - throw 'no status storage specified'; - } - - var type = me.pveSelNode.data.type; - var template = !!me.pveSelNode.data.template; - var rstore = me.statusStore; - - let hideMemhostStateKey = 'pve-vm-hide-memhost'; - let sp = Ext.state.Manager.getProvider(); - - let memoryStats = { - fields: ['maxmem', 'mem'], - fieldTitles: [gettext('Total'), gettext('Used')], - }; - if (type === 'qemu') { - memoryStats.fields.push({ - type: 'line', - fill: false, - yField: 'memhost', - title: gettext('Host Memory Usage'), - hidden: sp.get(hideMemhostStateKey, true), - style: { - lineWidth: 2.5, - opacity: 1, - }, - }); - } - - var items = [ - { - xtype: template ? 'pveTemplateStatusView' : 'pveGuestStatusView', - flex: 1, - padding: template ? '5' : '0 5 0 0', - itemId: 'gueststatus', - pveSelNode: me.pveSelNode, - rstore: rstore, - }, - { - xtype: 'pmxNotesView', - flex: 1, - padding: template ? '5' : '0 0 0 5', - itemId: 'notesview', - pveSelNode: me.pveSelNode, - }, - ]; - - var rrdstore; - if (!template) { - // in non-template mode put the two panels always together - items = [ - { - xtype: 'container', - height: 300, - layout: { - type: 'hbox', - align: 'stretch', - }, - items: items, - }, - ]; - - rrdstore = Ext.create('Proxmox.data.RRDStore', { - rrdurl: `/api2/json/nodes/${nodename}/${type}/${vmid}/rrddata`, - model: 'pve-rrd-guest', - }); - - items.push( - { - xtype: 'proxmoxRRDChart', - title: gettext('CPU Usage'), - pveSelNode: me.pveSelNode, - fields: ['cpu'], - fieldTitles: [gettext('CPU usage')], - unit: 'percent', - store: rrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Memory Usage'), - pveSelNode: me.pveSelNode, - fields: memoryStats.fields, - fieldTitles: memoryStats.fieldTitles, - colors: ['#94ae0a', '#115fa6', '#c4c0c0'], - unit: 'bytes', - powerOfTwo: true, - store: rrdstore, - onLegendChange: function (_legend, record, _, seriesIndex) { - if (seriesIndex === 2) { - // third data series is clicked -> hostmem - sp.set(hideMemhostStateKey, record.data.disabled); - } - }, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Network Traffic'), - pveSelNode: me.pveSelNode, - fields: ['netin', 'netout'], - fieldTitles: [gettext('Incoming'), gettext('Outgoing')], - store: rrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Disk IO'), - pveSelNode: me.pveSelNode, - fields: ['diskread', 'diskwrite'], - fieldTitles: [gettext('Reads'), gettext('Writes')], - store: rrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('CPU Pressure Stall'), - pveSelNode: me.pveSelNode, - fieldTitles: ['Some', 'Full'], - fields: ['pressurecpusome', 'pressurecpufull'], - colors: ['#FFD13E', '#A61120'], - store: rrdstore, - unit: 'percent', - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('IO Pressure Stall'), - pveSelNode: me.pveSelNode, - fieldTitles: ['Some', 'Full'], - fields: ['pressureiosome', 'pressureiofull'], - colors: ['#FFD13E', '#A61120'], - store: rrdstore, - unit: 'percent', - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Memory Pressure Stall'), - pveSelNode: me.pveSelNode, - fieldTitles: ['Some', 'Full'], - fields: ['pressurememorysome', 'pressurememoryfull'], - colors: ['#FFD13E', '#A61120'], - store: rrdstore, - unit: 'percent', - }, - ); - } - - Ext.apply(me, { - tbar: ['->', { xtype: 'proxmoxRRDTypeSelector' }], - items: [ - { - xtype: 'container', - itemId: 'itemcontainer', - layout: { - type: 'column', - }, - minWidth: 700, - defaults: { - minHeight: 360, - padding: 5, - }, - items: items, - listeners: { - resize: function (container) { - Proxmox.Utils.updateColumns(container); - }, - }, - }, - ], - }); - - me.callParent(); - if (!template) { - rrdstore.startUpdate(); - me.on('destroy', rrdstore.stopUpdate); - } - me.mon(sp, 'statechange', function (provider, key, value) { - if (key !== 'summarycolumns') { - return; - } - Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); - }); - }, -}); -Ext.define('PVE.panel.TemplateStatusView', { - extend: 'Proxmox.panel.StatusView', - alias: 'widget.pveTemplateStatusView', - - layout: { - type: 'vbox', - align: 'stretch', - }, - - defaults: { - xtype: 'pmxInfoWidget', - printBar: false, - padding: '2 25', - }, - items: [ - { - xtype: 'box', - height: 20, - }, - { - itemId: 'hamanaged', - iconCls: 'fa fa-heartbeat fa-fw', - title: gettext('HA State'), - printBar: false, - textField: 'ha', - renderer: PVE.Utils.format_ha, - }, - { - itemId: 'node', - iconCls: 'fa fa-fw fa-building', - title: gettext('Node'), - }, - { - xtype: 'box', - height: 20, - }, - { - itemId: 'cpus', - iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', - title: gettext('Processors'), - textField: 'cpus', - }, - { - itemId: 'memory', - iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', - title: gettext('Memory'), - textField: 'maxmem', - renderer: Proxmox.Utils.render_size, - }, - { - itemId: 'swap', - iconCls: 'fa fa-refresh fa-fw', - title: gettext('Swap'), - textField: 'maxswap', - renderer: Proxmox.Utils.render_size, - }, - { - itemId: 'disk', - iconCls: 'fa fa-hdd-o fa-fw', - title: gettext('Bootdisk size'), - textField: 'maxdisk', - renderer: Proxmox.Utils.render_size, - }, - { - xtype: 'box', - height: 20, - }, - ], - - initComponent: function () { - var me = this; - - var name = me.pveSelNode.data.name; - if (!name) { - throw 'no name specified'; - } - - me.title = name; - - me.callParent(); - if (me.pveSelNode.data.type !== 'lxc') { - me.remove(me.getComponent('swap')); - } - me.getComponent('node').updateValue(me.pveSelNode.data.node); - }, -}); -Ext.define('PVE.panel.MultiDiskPanel', { - extend: 'Ext.panel.Panel', - - mixins: ['Proxmox.Mixin.CBind'], - - setNodename: function (nodename) { - this.items.each((panel) => panel.setNodename(nodename)); - }, - - border: false, - bodyBorder: false, - - importDisk: false, // allow import panel - - layout: 'card', - - controller: { - xclass: 'Ext.app.ViewController', - - vmconfig: {}, - - onAdd: function () { - this.addDiskChecked(false); - }, - - onImport: function () { - this.addDiskChecked(true); - }, - - addDiskChecked: function (importDisk) { - let me = this; - me.lookup('addButton').setDisabled(true); - me.lookup('addImportButton').setDisabled(true); - me.addDisk(importDisk); - let count = me.lookup('grid').getStore().getCount() + 1; // +1 is from ide2 - me.lookup('addButton').setDisabled(count >= me.maxCount); - me.lookup('addImportButton').setDisabled(count >= me.maxCount); - }, - - getNextFreeDisk: function (vmconfig) { - throw 'implement in subclass'; - }, - - addPanel: function (itemId, vmconfig, nextFreeDisk, importDisk) { - throw 'implement in subclass'; - }, - - // define in subclass - diskSorter: undefined, - - addDisk: function (importDisk) { - let me = this; - let grid = me.lookup('grid'); - let store = grid.getStore(); - - // get free disk id - let vmconfig = me.getVMConfig(true); - let nextFreeDisk = me.getNextFreeDisk(vmconfig); - if (!nextFreeDisk) { - return; - } - - // add store entry + panel - let itemId = 'disk-card-' + ++Ext.idSeed; - let rec = store.add({ - name: nextFreeDisk.confid, - itemId, - })[0]; - - let panel = me.addPanel(itemId, vmconfig, nextFreeDisk, importDisk); - panel.updateVMConfig(vmconfig); - - // we need to setup a validitychange handler, so that we can show - // that a disk has invalid fields - let fields = panel.query('field'); - fields.forEach((el) => - el.on('validitychange', () => { - let valid = fields.every((field) => field.isValid()); - rec.set('valid', valid); - me.checkValidity(); - }), - ); - - store.sort(me.diskSorter); - - // select if the panel added is the only one - if (store.getCount() === 1) { - grid.getSelectionModel().select(0, false); - } - }, - - getBaseVMConfig: function () { - throw 'implement in subclass'; - }, - - getVMConfig: function (all) { - let me = this; - - let vmconfig = me.getBaseVMConfig(); - - me.lookup('grid') - .getStore() - .each((rec) => { - if (all || rec.get('valid')) { - vmconfig[rec.get('name')] = rec.get('itemId'); - } - }); - - return vmconfig; - }, - - checkValidity: function () { - let me = this; - let valid = me.lookup('grid').getStore().findExact('valid', false) === -1; - me.lookup('validationfield').setValue(valid); - }, - - updateVMConfig: function () { - let me = this; - let view = me.getView(); - let grid = me.lookup('grid'); - let store = grid.getStore(); - - let vmconfig = me.getVMConfig(); - - let valid = true; - - store.each((rec) => { - let itemId = rec.get('itemId'); - let name = rec.get('name'); - let panel = view.getComponent(itemId); - if (!panel) { - throw 'unexpected missing panel'; - } - - // copy config for each panel and remote its own id - let panel_vmconfig = Ext.apply({}, vmconfig); - if (panel_vmconfig[name] === itemId) { - delete panel_vmconfig[name]; - } - - if (!rec.get('valid')) { - valid = false; - } - - panel.updateVMConfig(panel_vmconfig); - }); - - me.lookup('validationfield').setValue(valid); - - return vmconfig; - }, - - onChange: function (panel, newVal) { - let me = this; - let store = me.lookup('grid').getStore(); - - let el = store.findRecord('itemId', panel.itemId, 0, false, true, true); - if (el.get('name') === newVal) { - // do not update if there was no change - return; - } - - el.set('name', newVal); - el.commit(); - - store.sort(me.diskSorter); - - // so that it happens after the layouting - setTimeout(function () { - me.updateVMConfig(); - }, 10); - }, - - onRemove: function (tableview, rowIndex, colIndex, item, event, record) { - let me = this; - let grid = me.lookup('grid'); - let store = grid.getStore(); - let removed_idx = store.indexOf(record); - - let selection = grid.getSelection()[0]; - let selected_idx = store.indexOf(selection); - - if (selected_idx === removed_idx) { - let newidx = store.getCount() > removed_idx + 1 ? removed_idx + 1 : removed_idx - 1; - grid.getSelectionModel().select(newidx, false); - } - - store.remove(record); - me.getView().remove(record.get('itemId')); - me.lookup('addButton').setDisabled(false); - me.lookup('addImportButton').setDisabled(false); - me.updateVMConfig(); - me.checkValidity(); - }, - - onSelectionChange: function (grid, selection) { - let me = this; - if (!selection || selection.length < 1) { - return; - } - - me.getView().setActiveItem(selection[0].data.itemId); - }, - - control: { - inputpanel: { - diskidchange: 'onChange', - }, - 'grid[reference=grid]': { - selectionchange: 'onSelectionChange', - }, - }, - - init: function (view) { - let me = this; - me.onAdd(); - me.lookup('grid').getSelectionModel().select(0, false); - }, - }, - - dockedItems: [ - { - xtype: 'container', - layout: { - type: 'vbox', - align: 'stretch', - }, - dock: 'left', - border: false, - width: 130, - cbind: {}, // for nested cbinds - items: [ - { - xtype: 'grid', - hideHeaders: true, - reference: 'grid', - flex: 1, - emptyText: gettext('No Disks'), - margin: '0 0 5 0', - store: { - fields: ['name', 'itemId', 'valid'], - data: [], - }, - columns: [ - { - dataIndex: 'name', - renderer: function (val, md, rec) { - let warn = ''; - if (!rec.get('valid')) { - warn = ' '; - } - return val + warn; - }, - flex: 1, - }, - { - xtype: 'actioncolumn', - width: 30, - align: 'center', - menuDisabled: true, - items: [ - { - iconCls: 'x-fa fa-trash critical', - tooltip: 'Delete', - handler: 'onRemove', - isActionDisabled: 'deleteDisabled', - }, - ], - }, - ], - }, - { - xtype: 'button', - reference: 'addButton', - text: gettext('Add'), - iconCls: 'fa fa-plus-circle', - handler: 'onAdd', - }, - { - xtype: 'button', - reference: 'addImportButton', - text: gettext('Import'), - iconCls: 'fa fa-cloud-download', - handler: 'onImport', - margin: '5 0 0 0', - cbind: { - disabled: '{!importDisk}', - hidden: '{!importDisk}', - }, - }, - { - // dummy field to control wizard validation - xtype: 'textfield', - hidden: true, - reference: 'validationfield', - submitValue: false, - value: true, - validator: (val) => !!val, - }, - ], - }, - ], -}); -Ext.define('PVE.panel.TagConfig', { - extend: 'PVE.panel.Config', - alias: 'widget.pveTagConfig', - - onlineHelp: 'gui_tags', -}); -/* - * Left Treepanel, containing all the resources we manage in this datacenter: server nodes, server storages, VMs and Containers - */ -Ext.define('PVE.tree.ResourceTree', { - extend: 'Ext.tree.TreePanel', - alias: ['widget.pveResourceTree'], - - userCls: 'proxmox-tags-circle', - - statics: { - typeDefaults: { - node: { - iconCls: 'fa fa-building', - text: gettext('Nodes'), - }, - pool: { - iconCls: 'fa fa-tags', - text: gettext('Resource Pool'), - }, - storage: { - iconCls: 'fa fa-database', - text: gettext('Storage'), - }, - sdn: { - iconCls: 'fa fa-th', - text: gettext('SDN'), - }, - network: { - iconCls: 'fa fa-globe', - text: gettext('Network'), - }, - qemu: { - iconCls: 'fa fa-desktop', - text: gettext('Virtual Machine'), - }, - lxc: { - iconCls: 'fa fa-cube', - text: gettext('LXC Container'), - }, - template: { - iconCls: 'fa fa-file-o', - }, - tag: { - iconCls: 'fa fa-tag', - }, - }, - }, - - columns: [ - { - xtype: 'treecolumn', - flex: 1, - dataIndex: 'text', - renderer: function (val, meta, rec) { - let info = rec.data; - - let text = info.text; - let status = ''; - if (info.type === 'storage') { - let usage = info.disk / info.maxdisk; - if (usage >= 0.0 && usage <= 1.0) { - let barHeight = (usage * 100).toFixed(0); - let remainingHeight = (100 - barHeight).toFixed(0); - status = '
    '; - status += `
    `; - status += `
    `; - status += '
    '; - } - } - if (Ext.isNumeric(info.vmid) && info.vmid > 0) { - if (PVE.UIOptions.getTreeSortingValue('sort-field') !== 'vmid') { - text = `${info.name} (${String(info.vmid)})`; - } - } - text = `${status}${text}`; - text += PVE.Utils.renderTags(info.tags, PVE.UIOptions.tagOverrides); - - if (info.id === 'root' && PVE.ClusterName) { - text += ` (${PVE.ClusterName})`; - } - - return (info.renderedText = text); - }, - }, - ], - - useArrows: true, - - // private - getTypeOrder: function (type) { - switch (type) { - case 'lxc': - return 0; - case 'qemu': - return 1; - case 'node': - return 2; - case 'sdn': - return 3; - case 'network': - return 3.5; - case 'storage': - return 4; - default: - return 9; - } - }, - - // private - nodeSortFn: function (node1, node2) { - let me = this; - let n1 = node1.data, - n2 = node2.data; - - if (!n1.groupbyid === !n2.groupbyid) { - let n1IsGuest = n1.type === 'qemu' || n1.type === 'lxc'; - let n2IsGuest = n2.type === 'qemu' || n2.type === 'lxc'; - if (me['group-guest-types'] || !n1IsGuest || !n2IsGuest) { - // first sort (group) by type - let res = me.getTypeOrder(n1.type) - me.getTypeOrder(n2.type); - if (res !== 0) { - return res; - } - } - - // then sort (group) by ID - if (n1IsGuest) { - if (me['group-templates'] && !n1.template !== !n2.template) { - return n1.template ? 1 : -1; // sort templates after regular VMs - } - if (me['sort-field'] === 'vmid') { - if (n1.vmid > n2.vmid) { - // prefer VMID as metric for guests - return 1; - } else if (n1.vmid < n2.vmid) { - return -1; - } - } else { - return n1.name.localeCompare(n2.name); - } - } - // same types but not a guest - return n1.id > n2.id ? 1 : n1.id < n2.id ? -1 : 0; - } else if (n1.groupbyid) { - return -1; - } else if (n2.groupbyid) { - return 1; - } - return 0; // should not happen - }, - - // private: fast binary search - findInsertIndex: function (node, child, start, end) { - let me = this; - - let diff = end - start; - if (diff <= 0) { - return start; - } - let mid = start + (diff >> 1); - - let res = me.nodeSortFn(child, node.childNodes[mid]); - if (res <= 0) { - return me.findInsertIndex(node, child, start, mid); - } else { - return me.findInsertIndex(node, child, mid + 1, end); - } - }, - - setIconCls: function (info) { - let cls = PVE.Utils.get_object_icon_class(info.type, info); - if (cls !== '') { - info.iconCls = cls; - } - }, - - getToolTip: function (info) { - let qtips = []; - if (info.qmpstatus || info.status) { - qtips.push(Ext.String.format(gettext('Status: {0}'), info.qmpstatus || info.status)); - } - if (info.lock) { - qtips.push(Ext.String.format(gettext('Config locked ({0})'), info.lock)); - } - if (info.hastate !== 'unmanaged') { - qtips.push(Ext.String.format(gettext('HA State: {0}'), info.hastate)); - } - if (info.type === 'storage') { - let usage = info.disk / info.maxdisk; - if (usage >= 0.0 && usage <= 1.0) { - qtips.push(Ext.String.format(gettext('Usage: {0}%'), (usage * 100).toFixed(2))); - } - } - - if (qtips.length === 0) { - return undefined; - } - - let tip = qtips.join(', '); - info.tip = tip; - return tip; - }, - - // private - addChildSorted: function (node, info) { - let me = this; - - me.setIconCls(info); - - if (info.groupbyid) { - if (me.viewFilter.groupRenderer) { - info.text = me.viewFilter.groupRenderer(info); - } else if (info.type === 'type') { - let defaults = PVE.tree.ResourceTree.typeDefaults[info.groupbyid]; - if (defaults && defaults.text) { - info.text = defaults.text; - } - } else { - info.text = info.groupbyid; - } - } - let child = Ext.create('PVETree', info); - - if (node.childNodes) { - let pos = me.findInsertIndex(node, child, 0, node.childNodes.length); - node.insertBefore(child, node.childNodes[pos]); - } else { - node.insertBefore(child); - } - - return child; - }, - - // private - groupChild: function (node, info, groups, level) { - let me = this; - - let groupBy = groups[level]; - let v = info[groupBy]; - - if (v) { - let group = node.findChild('groupbyid', v); - if (!group) { - let groupinfo; - if (info.type === groupBy) { - groupinfo = info; - } else { - groupinfo = { - type: groupBy, - id: groupBy + '/' + v, - }; - if (groupBy !== 'type') { - groupinfo[groupBy] = v; - } - } - groupinfo.leaf = false; - groupinfo.groupbyid = v; - group = me.addChildSorted(node, groupinfo); - } - if (info.type === groupBy) { - return group; - } - if (group) { - return me.groupChild(group, info, groups, level + 1); - } - } - - return me.addChildSorted(node, info); - }, - - saveSortingOptions: function () { - let me = this; - let changed = false; - for (const key of ['sort-field', 'group-templates', 'group-guest-types']) { - let newValue = PVE.UIOptions.getTreeSortingValue(key); - if (me[key] !== newValue) { - me[key] = newValue; - changed = true; - } - } - return changed; - }, - - initComponent: function () { - let me = this; - me.saveSortingOptions(); - - let rstore = PVE.data.ResourceStore; - let sp = Ext.state.Manager.getProvider(); - - if (!me.viewFilter) { - me.viewFilter = {}; - } - - let pdata = { - dataIndex: {}, - updateCount: 0, - }; - - let store = Ext.create('Ext.data.TreeStore', { - model: 'PVETree', - root: { - expanded: true, - id: 'root', - text: gettext('Datacenter'), - iconCls: 'fa fa-server', - }, - }); - - let stateid = 'rid'; - - const changedFields = [ - 'disk', - 'maxdisk', - 'vmid', - 'name', - 'type', - 'running', - 'template', - 'status', - 'qmpstatus', - 'hastate', - 'lock', - 'tags', - ]; - - // special case ids from the tag view, since they change the id in the state - let idMapFn = function (id) { - if (!id) { - return undefined; - } - if (id.startsWith('qemu') || id.startsWith('lxc')) { - let [realId, _tag] = id.split('-'); - return realId; - } - return id; - }; - - let findNode = function (rootNode, id) { - if (!id) { - return undefined; - } - let node = rootNode.findChild('id', id, true); - if (!node) { - node = rootNode.findChildBy( - (r) => idMapFn(r.data.id) === idMapFn(id), - undefined, - true, - ); - } - return node; - }; - - let firstUpdate = true; - - let updateTree = function () { - store.suspendEvents(); - - let rootnode; - if (firstUpdate) { - rootnode = Ext.create('PVETree', { - expanded: true, - id: 'root', - text: gettext('Datacenter'), - iconCls: 'fa fa-server', - }); - } else { - rootnode = me.store.getRootNode(); - } - // remember selected node (and all parents) - let sm = me.getSelectionModel(); - let lastsel = sm.getSelection()[0]; - let parents = []; - let sorting_changed = me.saveSortingOptions(); - for (let node = lastsel; node; node = node.parentNode) { - parents.push(node); - } - - let groups = me.viewFilter.groups || []; - // explicitly check for node/template, as those are not always grouping attributes - let attrMoveChecks = me.viewFilter.attrMoveChecks ?? {}; - - // also check for name for when the tree is sorted by name - let moveCheckAttrs = groups.concat(['node', 'template', 'name']); - let filterFn = me.viewFilter.getFilterFn ? me.viewFilter.getFilterFn() : Ext.identityFn; - - let reselect = false; // for disappeared nodes - let index = pdata.dataIndex; - // remove vanished or moved items and update changed items in-place - for (const [key, olditem] of Object.entries(index)) { - // getById() use find(), which is slow (ExtJS4 DP5) - let oldid = olditem.data.id; - let id = idMapFn(olditem.data.id); - let item = rstore.data.get(id); - - let changed = sorting_changed, - moved = sorting_changed; - if (item) { - // test if any grouping attributes changed, catches migrated tree-nodes in server view too - for (const attr of moveCheckAttrs) { - if (attrMoveChecks[attr]) { - if (attrMoveChecks[attr](olditem, item)) { - moved = true; - break; - } - } else if (item.data[attr] !== olditem.data[attr]) { - moved = true; - break; - } - } - - // tree item has been updated - for (const field of changedFields) { - if (item.data[field] !== olditem.data[field]) { - changed = true; - break; - } - } - // FIXME: also test filterfn()? - } - - if (changed) { - olditem.beginEdit(); - let info = olditem.data; - Ext.apply(info, item.data); - if (info.id !== oldid) { - info.id = oldid; - } - me.setIconCls(info); - olditem.commit(); - } - if ((!item || moved) && olditem.isLeaf()) { - delete index[key]; - let parentNode = olditem.parentNode; - // a selected item moved (migration) or disappeared (destroyed), so deselect that - // node now and try to reselect the moved (or its parent) node later - if (lastsel && olditem.data.id === lastsel.data.id) { - reselect = true; - sm.deselect(olditem); - } - // store events are suspended, so remove the item manually - store.remove(olditem); - parentNode.removeChild(olditem, true); - if (parentNode.childNodes.length < 1 && parentNode.parentNode) { - let grandParent = parentNode.parentNode; - grandParent.removeChild(parentNode, true); - } - } - } - - let items = rstore.getData().items.flatMap(me.viewFilter.itemMap ?? Ext.identityFn); - items.forEach(function (item) { - // add new items - let olditem = index[item.data.id]; - if (olditem) { - return; - } - if (filterFn && !filterFn(item)) { - return; - } - let info = Ext.apply({ leaf: true }, item.data); - - let child = me.groupChild(rootnode, info, groups, 0); - if (child) { - index[item.data.id] = child; - } - }); - - store.resumeEvents(); - store.fireEvent('refresh', store); - - let foundChild = findNode(rootnode, lastsel?.data.id); - - // select parent node if original selected node vanished - if (lastsel && !foundChild) { - lastsel = rootnode; - for (const node of parents) { - if (rootnode.findChild('id', node.data.id, true)) { - lastsel = node; - break; - } - } - me.selectById(lastsel.data.id); - } else if (lastsel && reselect) { - me.selectById(lastsel.data.id); - } - - if (firstUpdate) { - me.store.setRoot(rootnode); - firstUpdate = false; - } - - // on first tree load set the selection from the stateful provider - if (!pdata.updateCount) { - rootnode.expand(); - me.applyState(sp.get(stateid)); - } - - pdata.updateCount++; - }; - - sp.on('statechange', (_sp, key, value) => { - if (key === stateid) { - me.applyState(value); - } - }); - - Ext.apply(me, { - allowSelection: true, - store: store, - viewConfig: { - animate: false, // note: animate cause problems with applyState - }, - listeners: { - itemcontextmenu: PVE.Utils.createCmdMenu, - destroy: function () { - rstore.un('load', updateTree); - }, - beforecellmousedown: function (tree, td, cellIndex, record, tr, rowIndex, ev) { - let sm = me.getSelectionModel(); - // disable selection when right clicking except if the record is already selected - me.allowSelection = ev.button !== 2 || sm.isSelected(record); - }, - beforeselect: function (tree, record, index, eopts) { - let allow = me.allowSelection; - me.allowSelection = true; - return allow; - }, - itemdblclick: PVE.Utils.openTreeConsole, - afterrender: function () { - if (me.tip) { - return; - } - let selectors = [ - '.x-tree-node-text > span:not(.proxmox-tag-dark):not(.proxmox-tag-light)', - '.x-tree-icon', - ]; - me.tip = Ext.create('Ext.tip.ToolTip', { - target: me.el, - delegate: selectors.join(', '), - trackMouse: true, - renderTo: Ext.getBody(), - listeners: { - beforeshow: function (tip) { - let rec = me.getView().getRecord(tip.triggerElement); - let tipText = me.getToolTip(rec.data); - if (tipText) { - tip.update(tipText); - return true; - } - return false; - }, - }, - }); - }, - }, - setViewFilter: function (view) { - me.viewFilter = view; - me.clearTree(); - updateTree(); - }, - clearTree: function () { - pdata.updateCount = 0; - let rootnode = me.store.getRootNode(); - rootnode.collapse(); - rootnode.removeAll(); - pdata.dataIndex = {}; - me.getSelectionModel().deselectAll(); - }, - selectExpand: function (node) { - let sm = me.getSelectionModel(); - if (!sm.isSelected(node)) { - sm.select(node); - for (let iter = node; iter; iter = iter.parentNode) { - if (!iter.isExpanded()) { - iter.expand(); - } - } - me.getView().focusRow(node); - } - }, - selectById: function (nodeid) { - let rootnode = me.store.getRootNode(); - let node; - if (nodeid === 'root') { - node = rootnode; - } else { - node = findNode(rootnode, nodeid); - } - if (node) { - me.selectExpand(node); - } - return node; - }, - applyState: function (state) { - if (state && state.value) { - me.selectById(state.value); - } else { - me.getSelectionModel().deselectAll(); - } - }, - }); - - me.callParent(); - - me.getSelectionModel().on('select', (_sm, n) => sp.set(stateid, { value: n.data.id })); - - rstore.on('load', updateTree); - rstore.startUpdate(); - - me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => { - me.store.getRootNode().cascadeBy({ - before: function (node) { - if (node.data.groupbyid) { - node.beginEdit(); - let info = node.data; - me.setIconCls(info); - if (me.viewFilter.groupRenderer) { - info.text = me.viewFilter.groupRenderer(info); - } - node.commit(); - } - return true; - }, - }); - }); - }, -}); -Ext.define('PVE.guest.SnapshotTree', { - extend: 'Ext.tree.Panel', - xtype: 'pveGuestSnapshotTree', - - stateful: true, - stateId: 'grid-snapshots', - - viewModel: { - data: { - // should be 'qemu' or 'lxc' - type: undefined, - nodename: undefined, - vmid: undefined, - vmname: undefined, - snapshotAllowed: false, - rollbackAllowed: false, - snapshotFeature: false, - running: false, - selected: '', - load_delay: 3000, - }, - formulas: { - canSnapshot: (get) => get('snapshotAllowed') && get('snapshotFeature'), - canRollback: (get) => get('rollbackAllowed') && get('isSnapshot'), - canRemove: (get) => get('snapshotAllowed') && get('isSnapshot'), - isSnapshot: (get) => get('selected') && get('selected') !== 'current', - buttonText: (get) => (get('snapshotAllowed') ? gettext('Edit') : gettext('View')), - showMemory: (get) => get('type') === 'qemu', - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - newSnapshot: function () { - this.run_editor(false); - }, - - editSnapshot: function () { - this.run_editor(true); - }, - - run_editor: function (edit) { - let me = this; - let vm = me.getViewModel(); - let snapname; - if (edit) { - snapname = vm.get('selected'); - if (!snapname || snapname === 'current') { - return; - } - } - let win = Ext.create('PVE.window.Snapshot', { - nodename: vm.get('nodename'), - vmid: vm.get('vmid'), - vmname: vm.get('vmname'), - viewonly: !vm.get('snapshotAllowed'), - type: vm.get('type'), - isCreate: !edit, - submitText: !edit ? gettext('Take Snapshot') : undefined, - snapname: snapname, - running: vm.get('running'), - }); - win.show(); - me.mon(win, 'destroy', me.reload, me); - }, - - snapshotAction: function (action, method) { - let me = this; - let view = me.getView(); - let vm = me.getViewModel(); - let snapname = vm.get('selected'); - if (!snapname) { - return; - } - - let nodename = vm.get('nodename'); - let type = vm.get('type'); - let vmid = vm.get('vmid'); - - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/${type}/${vmid}/snapshot/${snapname}/${action}`, - method: method, - waitMsgTarget: view, - callback: function () { - me.reload(); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, options) { - var upid = response.result.data; - var win = Ext.create('Proxmox.window.TaskProgress', { upid: upid }); - win.show(); - }, - }); - }, - - rollback: function () { - this.snapshotAction('rollback', 'POST'); - }, - remove: function () { - this.snapshotAction('', 'DELETE'); - }, - cancel: function () { - this.load_task.cancel(); - }, - - reload: function () { - let me = this; - let view = me.getView(); - let vm = me.getViewModel(); - let nodename = vm.get('nodename'); - let vmid = vm.get('vmid'); - let type = vm.get('type'); - let load_delay = vm.get('load_delay'); - - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/${type}/${vmid}/snapshot`, - method: 'GET', - failure: function (response, opts) { - if (me.destroyed) { - return; - } - Proxmox.Utils.setErrorMask(view, response.htmlStatus); - me.load_task.delay(load_delay); - }, - success: function (response, opts) { - if (me.destroyed) { - // this is in a delayed task, avoid dragons if view has - // been destroyed already and go home. - return; - } - Proxmox.Utils.setErrorMask(view, false); - var digest = 'invalid'; - var idhash = {}; - var root = { name: '__root', expanded: true, children: [] }; - Ext.Array.each(response.result.data, function (item) { - item.leaf = true; - item.children = []; - if (item.name === 'current') { - vm.set('running', !!item.running); - digest = item.digest + item.running; - item.iconCls = PVE.Utils.get_object_icon_class(vm.get('type'), item); - } else { - item.iconCls = 'fa fa-fw fa-history x-fa-tree'; - } - idhash[item.name] = item; - }); - - if (digest !== me.old_digest) { - me.old_digest = digest; - - Ext.Array.each(response.result.data, function (item) { - if (item.parent && idhash[item.parent]) { - let parent_item = idhash[item.parent]; - parent_item.children.push(item); - parent_item.leaf = false; - parent_item.expanded = true; - parent_item.expandable = false; - } else { - root.children.push(item); - } - }); - - me.getView().setRootNode(root); - } - - me.load_task.delay(load_delay); - }, - }); - - // if we do not have the permissions, we don't have to check - // if we can create a snapshot, since the butten stays disabled - if (!vm.get('snapshotAllowed')) { - return; - } - - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/${type}/${vmid}/feature`, - params: { feature: 'snapshot' }, - method: 'GET', - success: function (response, options) { - if (me.destroyed) { - // this is in a delayed task, the current view could been - // destroyed already; then we mustn't do viemodel set - return; - } - let res = response.result.data; - vm.set('snapshotFeature', !!res.hasFeature); - }, - }); - }, - - select: function (grid, val) { - let vm = this.getViewModel(); - if (val.length < 1) { - vm.set('selected', ''); - return; - } - vm.set('selected', val[0].data.name); - }, - - init: function (view) { - let me = this; - let vm = me.getViewModel(); - me.load_task = new Ext.util.DelayedTask(me.reload, me); - - if (!view.type) { - throw 'guest type not set'; - } - vm.set('type', view.type); - - if (!view.pveSelNode.data.node) { - throw 'no node name specified'; - } - vm.set('nodename', view.pveSelNode.data.node); - - if (!view.pveSelNode.data.vmid) { - throw 'no VM ID specified'; - } - vm.set('vmid', view.pveSelNode.data.vmid); - - vm.set('vmname', view.pveSelNode.data.name); - - let caps = Ext.state.Manager.get('GuiCap'); - vm.set('snapshotAllowed', !!caps.vms['VM.Snapshot']); - vm.set('rollbackAllowed', !!caps.vms['VM.Snapshot.Rollback']); - - view.getStore().sorters.add({ - property: 'order', - direction: 'ASC', - }); - - me.reload(); - }, - }, - - listeners: { - selectionchange: 'select', - itemdblclick: 'editSnapshot', - beforedestroy: 'cancel', - }, - - layout: 'fit', - rootVisible: false, - animate: false, - sortableColumns: false, - - tbar: [ - { - xtype: 'proxmoxButton', - text: gettext('Take Snapshot'), - disabled: true, - bind: { - disabled: '{!canSnapshot}', - }, - handler: 'newSnapshot', - }, - '-', - { - xtype: 'proxmoxButton', - text: gettext('Rollback'), - disabled: true, - bind: { - disabled: '{!canRollback}', - }, - confirmMsg: function () { - let view = this.up('treepanel'); - let rec = view.getSelection()[0]; - let vmid = view.getViewModel().get('vmid'); - let vmname = view.getViewModel().get('vmname'); - let message = - PVE.Utils.formatGuestTaskConfirmation('qmrollback', vmid, vmname) + - ` '${rec.data.name}'? ${gettext('Current state will be lost.')}`; - return Ext.htmlEncode(message); - }, - handler: 'rollback', - }, - '-', - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - bind: { - text: '{buttonText}', - disabled: '{!isSnapshot}', - }, - disabled: true, - edit: true, - handler: 'editSnapshot', - }, - { - xtype: 'proxmoxButton', - text: gettext('Remove'), - disabled: true, - dangerous: true, - bind: { - disabled: '{!canRemove}', - }, - confirmMsg: function () { - let view = this.up('treepanel'); - let { data } = view.getSelection()[0]; - return Ext.String.format( - gettext('Are you sure you want to remove entry {0}'), - `'${data.name}'`, - ); - }, - handler: 'remove', - }, - { - xtype: 'label', - text: gettext('The current guest configuration does not support taking new snapshots'), - hidden: true, - bind: { - hidden: '{canSnapshot}', - }, - }, - ], - - columnLines: true, - - fields: [ - 'name', - 'description', - 'snapstate', - 'vmstate', - 'running', - { name: 'snaptime', type: 'date', dateFormat: 'timestamp' }, - { - name: 'order', - calculate: function (data) { - return data.snaptime || (data.name === 'current' ? 'ZZZ' : data.snapstate); - }, - }, - ], - - columns: [ - { - xtype: 'treecolumn', - text: gettext('Name'), - dataIndex: 'name', - width: 200, - renderer: (value, _, { data }) => (data.name !== 'current' ? value : gettext('NOW')), - }, - { - text: gettext('RAM'), - hidden: true, - bind: { - hidden: '{!showMemory}', - }, - align: 'center', - resizable: false, - dataIndex: 'vmstate', - width: 50, - renderer: (value, _, { data }) => - data.name !== 'current' ? Proxmox.Utils.format_boolean(value) : '', - }, - { - text: gettext('Date') + '/' + gettext('Status'), - dataIndex: 'snaptime', - width: 150, - renderer: function (value, metaData, record) { - if (record.data.snapstate) { - return record.data.snapstate; - } else if (value) { - return Ext.Date.format(value, 'Y-m-d H:i:s'); - } - return ''; - }, - }, - { - text: gettext('Description'), - dataIndex: 'description', - flex: 1, - renderer: function (value, metaData, record) { - if (record.data.name === 'current') { - return gettext('You are here!'); - } else { - return Ext.String.htmlEncode(value); - } - }, - }, - ], -}); -Ext.define('PVE.tree.ResourceMapTree', { - extend: 'Ext.tree.Panel', - alias: 'widget.pveResourceMapTree', - mixins: ['Proxmox.Mixin.CBind'], - - rootVisible: false, - - emptyText: gettext('No Mapping found'), - - // will be opened on edit - editWindowClass: undefined, - - // The base url of the resource - baseUrl: undefined, - - // icon class to show on the entries - mapIconCls: undefined, - - // if given, should be a function that takes a nodename and returns - // the url for getting the data to check the status - getStatusCheckUrl: undefined, - - // the result of above api call and the nodename is passed and can set the status - checkValidity: undefined, - - // the property that denotes a single map entry for a node - entryIdProperty: undefined, - - cbindData: function (initialConfig) { - let me = this; - const caps = Ext.state.Manager.get('GuiCap'); - me.canConfigure = !!caps.mapping['Mapping.Modify']; - - return {}; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - addMapping: function () { - let me = this; - let view = me.getView(); - Ext.create(view.editWindowClass, { - url: view.baseUrl, - autoShow: true, - listeners: { - destroy: () => me.load(), - }, - }); - }, - - add: function (_grid, _rI, _cI, _item, _e, rec) { - let me = this; - if (rec.data.type !== 'entry') { - return; - } - - me.openMapEditWindow(rec.data.name); - }, - - editDblClick: function () { - let me = this; - let view = me.getView(); - let selection = view.getSelection(); - if (!selection || selection.length < 1) { - return; - } - - me.edit(selection[0]); - }, - - editAction: function (_grid, _rI, _cI, _item, _e, rec) { - this.edit(rec); - }, - - edit: function (rec) { - let me = this; - if (rec.data.type === 'map') { - return; - } - - me.openMapEditWindow(rec.data.name, rec.data.node, rec.data.type === 'entry'); - }, - - openMapEditWindow: function (name, nodename, entryOnly) { - let me = this; - let view = me.getView(); - - Ext.create(view.editWindowClass, { - url: `${view.baseUrl}/${name}`, - autoShow: true, - autoLoad: true, - entryOnly, - nodename, - name, - listeners: { - destroy: () => me.load(), - }, - }); - }, - - remove: function (_grid, _rI, _cI, _item, _e, rec) { - let me = this; - let msg, id; - let view = me.getView(); - let confirmMsg; - switch (rec.data.type) { - case 'entry': - msg = gettext("Are you sure you want to remove '{0}'"); - confirmMsg = Ext.String.format(msg, rec.data.name); - break; - case 'node': - msg = gettext("Are you sure you want to remove '{0}' entries for '{1}'"); - confirmMsg = Ext.String.format(msg, rec.data.node, rec.data.name); - break; - case 'map': - msg = gettext("Are you sure you want to remove '{0}' on '{1}' for '{2}'"); - id = rec.data[view.entryIdProperty]; - confirmMsg = Ext.String.format(msg, id, rec.data.node, rec.data.name); - break; - default: - throw 'invalid type'; - } - Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function (btn) { - if (btn === 'yes') { - me.executeRemove(rec.data); - } - }); - }, - - executeRemove: function (data) { - let me = this; - let view = me.getView(); - - let url = `${view.baseUrl}/${data.name}`; - let method = 'PUT'; - let params = { - digest: me.lookup[data.name].digest, - }; - let map = me.lookup[data.name].map; - switch (data.type) { - case 'entry': - method = 'DELETE'; - params = undefined; - break; - case 'node': - params.map = PVE.Parser.filterPropertyStringList( - map, - (e) => e.node !== data.node, - ); - break; - case 'map': - params.map = PVE.Parser.filterPropertyStringList(map, (e) => - Object.entries(e).some(([key, value]) => data[key] !== value), - ); - break; - default: - throw 'invalid type'; - } - if (!params?.map.length) { - method = 'DELETE'; - params = undefined; - } - Proxmox.Utils.API2Request({ - url, - method, - params, - success: function () { - me.load(); - }, - }); - }, - - load: function () { - let me = this; - let view = me.getView(); - Proxmox.Utils.API2Request({ - url: view.baseUrl, - method: 'GET', - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function ({ result: { data } }) { - let lookup = {}; - data.forEach((entry) => { - lookup[entry.id] = Ext.apply({}, entry); - entry.iconCls = 'fa fa-fw fa-folder-o'; - entry.name = entry.id; - entry.text = entry.id; - entry.type = 'entry'; - - let nodes = {}; - for (const map of entry.map) { - let parsed = PVE.Parser.parsePropertyString(map); - parsed.iconCls = view.mapIconCls; - parsed.leaf = true; - parsed.name = entry.id; - parsed.text = parsed[view.entryIdProperty]; - parsed.type = 'map'; - - if (nodes[parsed.node] === undefined) { - nodes[parsed.node] = { - children: [], - expanded: true, - iconCls: 'fa fa-fw fa-building-o', - leaf: false, - name: entry.id, - node: parsed.node, - text: parsed.node, - type: 'node', - }; - } - nodes[parsed.node].children.push(parsed); - } - delete entry.id; - entry.children = Object.values(nodes); - entry.leaf = entry.children.length === 0; - }); - me.lookup = lookup; - if (view.getStatusCheckUrl !== undefined && view.checkValidity !== undefined) { - me.loadStatusData(); - } - view.setRootNode({ - children: data, - }); - let root = view.getRootNode(); - root.expand(); - root.childNodes.forEach((node) => node.expand()); - }, - }); - }, - - nodeLoadingState: {}, - - loadStatusData: function () { - let me = this; - let view = me.getView(); - PVE.data.ResourceStore.getNodes().forEach(({ node }) => { - me.nodeLoadingState[node] = true; - let url = view.getStatusCheckUrl(node); - Proxmox.Utils.API2Request({ - url, - method: 'GET', - failure: function (response) { - me.nodeLoadingState[node] = false; - view.getRootNode()?.cascade(function (rec) { - if (rec.data.node !== node) { - return; - } - - rec.set('valid', 0); - rec.set('errmsg', response.htmlStatus); - rec.commit(); - }); - }, - success: function ({ result: { data } }) { - me.nodeLoadingState[node] = false; - view.checkValidity(data, node); - }, - }); - }); - }, - - renderStatus: function (value, _metadata, record) { - let me = this; - if (record.data.type !== 'map') { - return ''; - } - let iconCls; - let status; - if (value === undefined) { - if (me.nodeLoadingState[record.data.node]) { - iconCls = 'fa-spinner fa-spin'; - status = gettext('Loading...'); - } else { - iconCls = 'fa-question-circle'; - status = gettext('Unknown Node'); - } - } else { - let state = value ? 'good' : 'critical'; - iconCls = PVE.Utils.get_health_icon(state, true); - status = value - ? gettext('Mapping matches host data') - : record.data.errmsg || Proxmox.Utils.unknownText; - } - return ` ${status}`; - }, - - getAddClass: function (v, mD, rec) { - let cls = 'fa fa-plus-circle'; - if ( - rec.data.type !== 'entry' || - rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length - ) { - cls += ' pmx-action-hidden'; - } - return cls; - }, - - isAddDisabled: function (v, r, c, i, rec) { - return ( - rec.data.type !== 'entry' || - rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length - ); - }, - - init: function (view) { - let me = this; - - ['editWindowClass', 'baseUrl', 'mapIconCls', 'entryIdProperty'].forEach((property) => { - if (view[property] === undefined) { - throw `No ${property} defined`; - } - }); - - me.load(); - }, - }, - - store: { - sorters: 'text', - data: {}, - }, - - tbar: [ - { - text: gettext('Add'), - handler: 'addMapping', - cbind: { - disabled: '{!canConfigure}', - }, - }, - ], - - listeners: { - itemdblclick: 'editDblClick', - }, - - initComponent: function () { - let me = this; - - let columns = [...me.columns]; - columns.splice(1, 0, { - xtype: 'actioncolumn', - text: gettext('Actions'), - width: 80, - items: [ - { - getTip: (v, m, { data }) => - Ext.String.format(gettext("Add new host mapping for '{0}'"), data.name), - getClass: 'getAddClass', - isActionDisabled: 'isAddDisabled', - handler: 'add', - }, - { - iconCls: 'fa fa-pencil', - getTip: (v, m, { data }) => - data.type === 'entry' - ? Ext.String.format(gettext("Edit Mapping '{0}'"), data.name) - : Ext.String.format( - gettext("Edit Mapping '{0}' for '{1}'"), - data.name, - data.node, - ), - getClass: (v, m, { data }) => - data.type !== 'map' ? 'fa fa-pencil' : 'pmx-hidden', - isActionDisabled: (v, r, c, i, rec) => rec.data.type === 'map', - handler: 'editAction', - }, - { - iconCls: 'fa fa-trash-o', - getTip: (v, m, { data }) => - data.type === 'entry' - ? Ext.String.format(gettext("Remove '{0}'"), data.name) - : data.type === 'node' - ? Ext.String.format(gettext("Remove mapping for '{0}'"), data.node) - : Ext.String.format(gettext("Remove mapping '{0}'"), data.path), - handler: 'remove', - }, - ], - }); - me.columns = columns; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.DhcpTree', { - extend: 'Ext.tree.Panel', - xtype: 'pveDhcpTree', - - layout: 'fit', - rootVisible: false, - animate: false, - - store: { - sorters: ['ip', 'name'], - }, - - controller: { - xclass: 'Ext.app.ViewController', - - reload: function () { - let me = this; - - Proxmox.Utils.API2Request({ - url: `/cluster/sdn/ipams/pve/status`, - method: 'GET', - success: function (response, opts) { - let root = { - name: '__root', - expanded: true, - children: [], - }; - - let zones = {}; - let vnets = {}; - let subnets = {}; - - response.result.data.forEach((element) => { - element.leaf = true; - - if (!(element.zone in zones)) { - let zone = { - name: element.zone, - type: 'zone', - iconCls: 'fa fa-th', - expanded: true, - children: [], - }; - - zones[element.zone] = zone; - root.children.push(zone); - } - - if (!(element.vnet in vnets)) { - let vnet = { - name: element.vnet, - zone: element.zone, - type: 'vnet', - iconCls: 'fa fa-network-wired x-fa-treepanel', - expanded: true, - children: [], - }; - - vnets[element.vnet] = vnet; - zones[element.zone].children.push(vnet); - } - - if (!(element.subnet in subnets)) { - let subnet = { - name: element.subnet, - zone: element.zone, - vnet: element.vnet, - type: 'subnet', - iconCls: 'x-tree-icon-none', - expanded: true, - children: [], - }; - - subnets[element.subnet] = subnet; - vnets[element.vnet].children.push(subnet); - } - - element.name = element.vmid; // for sorting - element.type = 'mapping'; - element.iconCls = 'x-tree-icon-none'; - subnets[element.subnet].children.push(element); - }); - - me.getView().setRootNode(root); - }, - }); - }, - - init: function (view) { - let me = this; - me.reload(); - }, - - onDelete: function (table, rI, cI, item, e, { data }) { - let me = this; - let view = me.getView(); - - Ext.Msg.show({ - title: gettext('Confirm'), - icon: Ext.Msg.WARNING, - message: Ext.String.format( - gettext('Are you sure you want to remove DHCP mapping {0}'), - `${data.mac} / ${data.ip}`, - ), - buttons: Ext.Msg.YESNO, - defaultFocus: 'no', - callback: function (btn) { - if (btn !== 'yes') { - return; - } - - let params = { - zone: data.zone, - mac: data.mac, - ip: data.ip, - }; - - let encodedParams = Ext.Object.toQueryString(params); - - let url = `/cluster/sdn/vnets/${data.vnet}/ips?${encodedParams}`; - - Proxmox.Utils.API2Request({ - url, - method: 'DELETE', - waitMsgTarget: view, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - callback: me.reload.bind(me), - }); - }, - }); - }, - - editAction: function (_grid, _rI, _cI, _item, _e, rec) { - this.edit(rec); - }, - - editDblClick: function () { - let me = this; - - let view = me.getView(); - let selection = view.getSelection(); - - if (!selection || selection.length < 1) { - return; - } - - me.edit(selection[0]); - }, - - edit: function (rec) { - let me = this; - - if (rec.data.type === 'mapping' && !rec.data.gateway) { - me.openEditWindow(rec.data); - } - }, - - openEditWindow: function (data) { - let me = this; - - let extraRequestParams = { - mac: data.mac, - zone: data.zone, - vnet: data.vnet, - }; - - if (data.vmid) { - extraRequestParams.vmid = data.vmid; - } - - Ext.create('PVE.sdn.IpamEdit', { - autoShow: true, - mapping: data, - extraRequestParams, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }, - - listeners: { - itemdblclick: 'editDblClick', - }, - - tbar: [ - { - xtype: 'proxmoxButton', - text: gettext('Reload'), - handler: 'reload', - }, - ], - - columns: [ - { - xtype: 'treecolumn', - text: gettext('Name / VMID'), - dataIndex: 'name', - width: 200, - renderer: function (value, meta, record) { - if (record.get('gateway')) { - return gettext('Gateway'); - } - - return record.get('name') ?? record.get('vmid') ?? ' '; - }, - }, - { - text: gettext('IP Address'), - dataIndex: 'ip', - width: 200, - }, - { - text: 'MAC', - dataIndex: 'mac', - width: 200, - }, - { - text: gettext('Gateway'), - dataIndex: 'gateway', - width: 200, - }, - { - header: gettext('Actions'), - xtype: 'actioncolumn', - dataIndex: 'text', - width: 150, - items: [ - { - handler: function (table, rI, cI, item, e, { data }) { - let me = this; - - Ext.create('PVE.sdn.IpamEdit', { - autoShow: true, - mapping: {}, - isCreate: true, - extraRequestParams: { - vnet: data.name, - zone: data.zone, - }, - listeners: { - destroy: () => { - me.up('pveDhcpTree').controller.reload(); - }, - }, - }); - }, - getTip: (v, m, rec) => gettext('Add'), - getClass: (v, m, { data }) => { - if (data.type === 'vnet') { - return 'fa fa-plus-square'; - } - - return 'pmx-hidden'; - }, - }, - { - handler: 'editAction', - getTip: (v, m, rec) => gettext('Edit'), - getClass: (v, m, { data }) => { - if (data.type === 'mapping' && !data.gateway) { - return 'fa fa-pencil fa-fw'; - } - - return 'pmx-hidden'; - }, - }, - { - handler: 'onDelete', - getTip: (v, m, rec) => gettext('Delete'), - getClass: (v, m, { data }) => { - if (data.type === 'mapping' && !data.gateway) { - return 'fa critical fa-trash-o'; - } - - return 'pmx-hidden'; - }, - }, - ], - }, - ], -}); -Ext.define('PVE.window.Backup', { - extend: 'Ext.window.Window', - - resizable: false, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - if (!me.vmtype) { - throw 'no VM type specified'; - } - - let compressionSelector = Ext.create('PVE.form.BackupCompressionSelector', { - name: 'compress', - value: 'zstd', - fieldLabel: gettext('Compression'), - }); - - let modeSelector = Ext.create('PVE.form.BackupModeSelector', { - fieldLabel: gettext('Mode'), - value: 'snapshot', - name: 'mode', - }); - - let mailtoField = Ext.create('Ext.form.field.Text', { - fieldLabel: gettext('Send email to'), - name: 'mailto', - hidden: true, - emptyText: Proxmox.Utils.noneText, - }); - - let notificationModeSelector = Ext.create({ - xtype: 'proxmoxKVComboBox', - comboItems: [ - ['notification-system', gettext('Use global settings')], - // TRANSLATORS: sendmail is a piece of software - ['legacy-sendmail', gettext('Use sendmail (legacy)')], - ], - fieldLabel: gettext('Notification'), - name: 'notification-mode', - value: 'notification-system', - listeners: { - change: function (field, value) { - mailtoField.setHidden(value === 'notification-system'); - }, - }, - }); - - let pbsChangeDetectionModeSelector = Ext.create({ - xtype: 'proxmoxKVComboBox', - flex: 1, - disabled: true, - name: 'pbs-change-detection-mode', - deleteEmpty: true, - value: '__default__', - comboItems: [ - ['__default__', 'Default'], - ['data', 'Data'], - ['metadata', 'Metadata'], - ], - }); - - let pbsChangeDetection = Ext.create('Ext.form.FieldContainer', { - fieldLabel: gettext('PBS change detection mode'), - hidden: true, - layout: { - type: 'hbox', - align: 'center', - }, - items: [ - pbsChangeDetectionModeSelector, - { - xtype: 'box', - html: ``, - padding: 5, - }, - ], - }); - - const keepNames = [ - ['keep-last', gettext('Keep Last')], - ['keep-hourly', gettext('Keep Hourly')], - ['keep-daily', gettext('Keep Daily')], - ['keep-weekly', gettext('Keep Weekly')], - ['keep-monthly', gettext('Keep Monthly')], - ['keep-yearly', gettext('Keep Yearly')], - ]; - - let pruneSettings = keepNames.map((name) => - Ext.create('Ext.form.field.Display', { - name: name[0], - fieldLabel: name[1], - hidden: true, - }), - ); - - let removeCheckbox = Ext.create('Proxmox.form.Checkbox', { - name: 'remove', - checked: false, - hidden: true, - uncheckedValue: 0, - fieldLabel: gettext('Prune'), - autoEl: { - tag: 'div', - 'data-qtip': gettext('Prune older backups afterwards'), - }, - handler: function (checkbox, value) { - pruneSettings.forEach((field) => field.setHidden(!value)); - me.down('label[name="pruneLabel"]').setHidden(!value); - }, - }); - - let initialDefaults = false; - - var storagesel = Ext.create('PVE.form.StorageSelector', { - nodename: me.nodename, - name: 'storage', - fieldLabel: gettext('Storage'), - storageContent: 'backup', - allowBlank: false, - listeners: { - change: function (f, v) { - if (!initialDefaults) { - me.setLoading(false); - } - - if (v === null || v === undefined || v === '') { - return; - } - - let store = f.getStore(); - let rec = store.findRecord('storage', v, 0, false, true, true); - - if (rec && rec.data && rec.data.type === 'pbs') { - compressionSelector.setValue('zstd'); - compressionSelector.setDisabled(true); - if (me.vmtype === 'lxc') { - pbsChangeDetectionModeSelector.setValue('__default__'); - pbsChangeDetectionModeSelector.setDisabled(false); - pbsChangeDetection.setHidden(false); - } else { - pbsChangeDetectionModeSelector.setDisabled(true); - pbsChangeDetection.setHidden(true); - } - } else { - if (!compressionSelector.getEditable()) { - compressionSelector.setDisabled(false); - } - pbsChangeDetectionModeSelector.setDisabled(true); - pbsChangeDetection.setHidden(true); - } - - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/vzdump/defaults`, - method: 'GET', - params: { - storage: v, - }, - waitMsgTarget: me, - success: function (response, opts) { - const data = response.result.data; - - if (!initialDefaults) { - let notificationMode = data['notification-mode'] ?? 'auto'; - let mailto = data.mailto; - - if (notificationMode === 'auto' && mailto !== undefined) { - notificationMode = 'legacy-sendmail'; - } - if (notificationMode === 'auto' && mailto === undefined) { - notificationMode = 'notification-system'; - } - - notificationModeSelector.setValue(notificationMode); - if (mailto !== undefined) { - mailtoField.setValue(mailto); - } - } - if (!initialDefaults && data.mode !== undefined) { - modeSelector.setValue(data.mode); - } - if (!initialDefaults && (data['notes-template'] ?? false)) { - me.down('field[name=notes-template]').setValue( - PVE.Utils.unEscapeNotesTemplate(data['notes-template']), - ); - } - - initialDefaults = true; - - // always update storage dependent properties - if (data['prune-backups'] !== undefined) { - const keepParams = PVE.Parser.parsePropertyString( - data['prune-backups'], - ); - if (!keepParams['keep-all']) { - removeCheckbox.setHidden(false); - pruneSettings.forEach(function (field) { - const keep = keepParams[field.name]; - if (keep) { - field.setValue(keep); - } else { - field.reset(); - } - }); - return; - } - } - - // no defaults or keep-all=1 - removeCheckbox.setHidden(true); - removeCheckbox.setValue(false); - pruneSettings.forEach((field) => field.reset()); - }, - failure: function (response, opts) { - initialDefaults = true; - - removeCheckbox.setHidden(true); - removeCheckbox.setValue(false); - pruneSettings.forEach((field) => field.reset()); - - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - }, - }); - - let protectedCheckbox = Ext.create('Proxmox.form.Checkbox', { - name: 'protected', - checked: false, - uncheckedValue: 0, - fieldLabel: gettext('Protected'), - // Tiny amount of padding to stop the UI from shifting - // when the 'mailto' field is shown. - padding: '0 0 1 0', - }); - - me.formPanel = Ext.create('Proxmox.panel.InputPanel', { - bodyPadding: 10, - border: false, - column1: [storagesel, modeSelector, protectedCheckbox, pbsChangeDetection], - column2: [compressionSelector, notificationModeSelector, mailtoField, removeCheckbox], - columnB: [ - { - xtype: 'textareafield', - name: 'notes-template', - fieldLabel: gettext('Notes'), - anchor: '100%', - value: '{{guestname}}', - }, - { - xtype: 'box', - style: { - margin: '8px 0px', - 'line-height': '1.5em', - }, - html: Ext.String.format( - gettext('Possible template variables are: {0}'), - PVE.Utils.notesTemplateVars.map((v) => `{{${v}}}`).join(', '), - ), - }, - { - xtype: 'label', - name: 'pruneLabel', - text: gettext('Storage Retention Configuration') + ':', - hidden: true, - }, - { - layout: 'hbox', - border: false, - defaults: { - border: false, - layout: 'anchor', - flex: 1, - }, - items: [ - { - padding: '0 10 0 0', - defaults: { - labelWidth: 110, - }, - items: [pruneSettings[0], pruneSettings[2], pruneSettings[4]], - }, - { - padding: '0 0 0 10', - defaults: { - labelWidth: 110, - }, - items: [pruneSettings[1], pruneSettings[3], pruneSettings[5]], - }, - ], - }, - ], - }); - - var submitBtn = Ext.create('Ext.Button', { - text: gettext('Backup'), - handler: function () { - var storage = storagesel.getValue(); - let values = me.formPanel.getValues(); - var params = { - storage: storage, - vmid: me.vmid, - mode: values.mode, - remove: values.remove, - }; - - if (values.mailto) { - params.mailto = values.mailto; - } - - if (values['notification-mode']) { - params['notification-mode'] = values['notification-mode']; - } - - if (values.compress) { - params.compress = values.compress; - } - - if (values.protected) { - params.protected = values.protected; - } - - if (values['pbs-change-detection-mode']) { - params['pbs-change-detection-mode'] = values['pbs-change-detection-mode']; - } - - if (values['notes-template']) { - params['notes-template'] = PVE.Utils.escapeNotesTemplate( - values['notes-template'], - ); - } - - Proxmox.Utils.API2Request({ - url: '/nodes/' + me.nodename + '/vzdump', - params: params, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - success: function (response, options) { - // close later so we reload the grid - // after the task has completed - me.hide(); - - var upid = response.result.data; - - var win = Ext.create('Proxmox.window.TaskViewer', { - upid: upid, - listeners: { - close: function () { - me.close(); - }, - }, - }); - win.show(); - }, - }); - }, - }); - - var helpBtn = Ext.create('Proxmox.button.Help', { - onlineHelp: 'chapter_vzdump', - listenToGlobalEvent: false, - hidden: false, - }); - - let guestTypeStr = me.vmtype === 'lxc' ? 'CT' : 'VM'; - let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier(me.vmid, me.vmname); - let title = `${gettext('Backup')} ${guestTypeStr} ${formattedGuestIdentifier}`; - - Ext.apply(me, { - title: title, - modal: true, - layout: 'auto', - border: false, - width: 600, - items: [me.formPanel], - buttons: [helpBtn, '->', submitBtn], - listeners: { - afterrender: function () { - /// cleared within the storage selector's change listener - me.setLoading(gettext('Please wait...')); - storagesel.setValue(me.storage); - }, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.window.BackupConfig', { - extend: 'Ext.window.Window', - title: gettext('Configuration'), - width: 600, - height: 400, - layout: 'fit', - modal: true, - items: { - xtype: 'component', - itemId: 'configtext', - autoScroll: true, - style: { - 'white-space': 'pre', - 'font-family': 'monospace', - padding: '5px', - }, - }, - - initComponent: function () { - var me = this; - - if (!me.volume) { - throw 'no volume specified'; - } - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.callParent(); - - Proxmox.Utils.API2Request({ - url: '/nodes/' + nodename + '/vzdump/extractconfig', - method: 'GET', - params: { - volume: me.volume, - }, - failure: function (response, opts) { - me.close(); - Ext.Msg.alert('Error', response.htmlStatus); - }, - success: function (response, options) { - me.show(); - me.down('#configtext').update(Ext.htmlEncode(response.result.data)); - }, - }); - }, -}); -Ext.define('PVE.window.BulkAction', { - extend: 'Ext.window.Window', - - resizable: true, - width: 800, - height: 600, - modal: true, - layout: { - type: 'fit', - }, - border: false, - - // the action to set, currently there are: `startall`, `migrateall`, `stopall`, `suspendall` - action: undefined, - - // if set to true, the 'vms' parameter will be sent as an array' - // necessary for the cluster-wide api call - vmsAsArray: false, - - submit: function (params) { - let me = this; - - let url; - if (me.nodename) { - url = `/nodes/${me.nodename}/${me.action}`; - } else { - url = `/cluster/bulk-action/guest/${me.action}`; - } - - if (me.vmsAsArray) { - params.vms = params.vms.split(/[,; ]/); - } - - Proxmox.Utils.API2Request({ - params: params, - url, - waitMsgTarget: me, - method: 'POST', - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - success: function ({ result }, options) { - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: result.data, - listeners: { - destroy: () => me.close(), - }, - }); - me.hide(); - }, - }); - }, - - initComponent: function () { - let me = this; - - if (!me.action) { - throw 'no action specified'; - } - if (!me.btnText) { - throw 'no button text specified'; - } - if (!me.title) { - throw 'no title specified'; - } - - let items = []; - if (me.action === 'migrateall' || me.action === 'migrate') { - let disallowedNodes = []; - if (me.nodename) { - disallowedNodes.push(me.nodename); - } - items.push( - { - xtype: 'fieldcontainer', - layout: 'hbox', - items: [ - { - flex: 1, - xtype: 'pveNodeSelector', - name: 'target', - disallowedNodes, - fieldLabel: gettext('Target node'), - labelWidth: 200, - allowBlank: false, - onlineValidator: true, - padding: '0 10 0 0', - }, - { - xtype: 'proxmoxintegerfield', - // TODO: change to newer max-worker spelling for PVE 10 - name: 'maxworkers', - minValue: 1, - maxValue: 64, - value: 1, - fieldLabel: gettext('Parallel jobs'), - allowBlank: false, - flex: 1, - }, - ], - }, - { - xtype: 'fieldcontainer', - layout: 'hbox', - items: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Allow local disk migration'), - name: 'with-local-disks', - labelWidth: 200, - checked: true, - uncheckedValue: 0, - flex: 1, - padding: '0 10 0 0', - }, - { - itemId: 'lxcwarning', - xtype: 'displayfield', - userCls: 'pmx-hint', - value: 'Warning: Running CTs will be migrated in Restart Mode.', - hidden: true, // only visible if running container chosen - flex: 1, - }, - ], - }, - ); - if (me.action === 'migrate') { - items.push({ - xtype: 'hiddenfield', - name: 'online', - value: 1, - }); - } - } else if (me.action === 'startall') { - items.push({ - xtype: 'hiddenfield', - name: 'force', - value: 1, - }); - } else if (me.action === 'stopall' || me.action === 'shutdown') { - items.push({ - xtype: 'fieldcontainer', - layout: 'hbox', - items: [ - { - xtype: 'proxmoxcheckbox', - name: 'force-stop', - labelWidth: 120, - fieldLabel: gettext('Force Stop'), - boxLabel: gettext('Force stop guest if shutdown times out.'), - checked: true, - uncheckedValue: 0, - flex: 7, - }, - { - xtype: 'proxmoxintegerfield', - name: 'timeout', - fieldLabel: gettext('Timeout (s)'), - labelWidth: 120, - emptyText: '180', - minValue: 0, - maxValue: 7200, - allowBlank: true, - flex: 3, - }, - ], - }); - } - if (me.action !== 'migrateall' && me.action !== 'migrate') { - items.push({ - xtype: 'fieldcontainer', - layout: 'hbox', - items: [ - { - xtype: 'box', - flex: 7, - }, - { - xtype: 'proxmoxintegerfield', - name: 'max-workers', - minValue: 1, - maxValue: 64, - emptyText: 'auto', - fieldLabel: gettext('Parallel jobs'), - labelWidth: 120, - allowBlank: true, - flex: 3, - }, - ], - }); - } - - let refreshLxcWarning = function (vmids, records) { - let showWarning = records.some( - (item) => - vmids.includes(item.data.vmid) && - item.data.type === 'lxc' && - item.data.status === 'running', - ); - me.down('#lxcwarning').setVisible(showWarning); - }; - - let defaulStatusMap = { - migrateall: '', - migrate: '', - startall: 'stopped', - start: 'stopped', - stopall: 'running', - shutdown: 'running', - suspendall: 'running', - suspend: 'running', - }; - let defaultStatus = defaulStatusMap[me.action] ?? ''; - let defaultType = me.action === 'suspendall' ? 'qemu' : ''; - - let statusMap = []; - let poolMap = []; - let haMap = []; - let tagMap = []; - PVE.data.ResourceStore.each((rec) => { - if (['qemu', 'lxc'].indexOf(rec.data.type) !== -1) { - statusMap[rec.data.status] = true; - } - if (rec.data.type === 'pool') { - poolMap[rec.data.pool] = true; - } - if (rec.data.hastate !== '') { - haMap[rec.data.hastate] = true; - } - if (rec.data.tags !== '') { - rec.data.tags.split(/[,; ]/).forEach((tag) => { - if (tag !== '') { - tagMap[tag] = true; - } - }); - } - }); - - let statusList = Object.keys(statusMap).map((key) => [key, key]); - statusList.unshift(['', gettext('All')]); - let poolList = Object.keys(poolMap).map((key) => [key, key]); - let tagList = Object.keys(tagMap).map((key) => ({ value: key })); - let haList = Object.keys(haMap).map((key) => [key, key]); - - let clearFilters = function () { - me.down('#namefilter').setValue(''); - ['name', 'status', 'pool', 'type', 'hastate', 'includetag', 'excludetag'].forEach( - (filter) => { - me.down(`#${filter}filter`).setValue(''); - }, - ); - }; - - let filterChange = function () { - let nameValue = me.down('#namefilter').getValue(); - let filterCount = 0; - - if (nameValue !== '') { - filterCount++; - } - - let arrayFiltersData = []; - ['pool', 'hastate'].forEach((filter) => { - let selected = me.down(`#${filter}filter`).getValue() ?? []; - if (selected.length) { - filterCount++; - arrayFiltersData.push([filter, [...selected]]); - } - }); - - let singleFiltersData = []; - ['status', 'type'].forEach((filter) => { - let selected = me.down(`#${filter}filter`).getValue() ?? ''; - if (selected.length) { - filterCount++; - singleFiltersData.push([filter, selected]); - } - }); - - let includeTags = me.down('#includetagfilter').getValue() ?? []; - if (includeTags.length) { - filterCount++; - } - let excludeTags = me.down('#excludetagfilter').getValue() ?? []; - if (excludeTags.length) { - filterCount++; - } - - let fieldSet = me.down('#filters'); - let clearBtn = me.down('#clearBtn'); - if (filterCount) { - fieldSet.setTitle(Ext.String.format(gettext('Filters ({0})'), filterCount)); - clearBtn.setDisabled(false); - } else { - fieldSet.setTitle(gettext('Filters')); - clearBtn.setDisabled(true); - } - - let filterFn = function (value) { - let name = value.data.name.toLowerCase().indexOf(nameValue.toLowerCase()) !== -1; - let arrayFilters = arrayFiltersData.every( - ([filter, selected]) => - !selected.length || selected.indexOf(value.data[filter]) !== -1, - ); - let singleFilters = singleFiltersData.every( - ([filter, selected]) => - !selected.length || value.data[filter].indexOf(selected) !== -1, - ); - let tags = value.data.tags.split(/[;, ]/).filter((t) => !!t); - let includeFilter = - !includeTags.length || tags.some((tag) => includeTags.indexOf(tag) !== -1); - let excludeFilter = - !excludeTags.length || tags.every((tag) => excludeTags.indexOf(tag) === -1); - - return name && arrayFilters && singleFilters && includeFilter && excludeFilter; - }; - let vmselector = me.down('#vms'); - vmselector.getStore().setFilters({ - id: 'customFilter', - filterFn, - }); - vmselector.checkChange(); - if (me.action === 'migrateall') { - let records = vmselector.getSelection(); - refreshLxcWarning(vmselector.getValue(), records); - } - }; - - items.push({ - xtype: 'fieldset', - itemId: 'filters', - collapsible: true, - title: gettext('Filters'), - layout: 'hbox', - items: [ - { - xtype: 'container', - flex: 1, - padding: 5, - layout: { - type: 'vbox', - align: 'stretch', - }, - defaults: { - listeners: { - change: filterChange, - }, - isFormField: false, - }, - items: [ - { - fieldLabel: gettext('Name'), - itemId: 'namefilter', - xtype: 'textfield', - }, - { - xtype: 'combobox', - itemId: 'statusfilter', - fieldLabel: gettext('Status'), - emptyText: gettext('All'), - editable: false, - value: defaultStatus, - store: statusList, - }, - { - xtype: 'combobox', - itemId: 'poolfilter', - fieldLabel: gettext('Pool'), - emptyText: gettext('All'), - editable: false, - multiSelect: true, - store: poolList, - }, - ], - }, - { - xtype: 'container', - layout: { - type: 'vbox', - align: 'stretch', - }, - flex: 1, - padding: 5, - defaults: { - listeners: { - change: filterChange, - }, - isFormField: false, - }, - items: [ - { - xtype: 'combobox', - itemId: 'typefilter', - fieldLabel: gettext('Type'), - emptyText: gettext('All'), - editable: false, - value: defaultType, - store: [ - ['', gettext('All')], - ['lxc', gettext('CT')], - ['qemu', gettext('VM')], - ], - }, - { - xtype: 'proxmoxComboGrid', - itemId: 'includetagfilter', - fieldLabel: gettext('Include Tags'), - emptyText: gettext('All'), - editable: false, - multiSelect: true, - valueField: 'value', - displayField: 'value', - listConfig: { - userCls: 'proxmox-tags-full', - columns: [ - { - dataIndex: 'value', - flex: 1, - renderer: (value) => - PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), - }, - ], - }, - store: { - data: tagList, - }, - listeners: { - change: filterChange, - }, - }, - { - xtype: 'proxmoxComboGrid', - itemId: 'excludetagfilter', - fieldLabel: gettext('Exclude Tags'), - emptyText: gettext('None'), - multiSelect: true, - editable: false, - valueField: 'value', - displayField: 'value', - listConfig: { - userCls: 'proxmox-tags-full', - columns: [ - { - dataIndex: 'value', - flex: 1, - renderer: (value) => - PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), - }, - ], - }, - store: { - data: tagList, - }, - listeners: { - change: filterChange, - }, - }, - ], - }, - { - xtype: 'container', - layout: { - type: 'vbox', - align: 'stretch', - }, - flex: 1, - padding: 5, - defaults: { - listeners: { - change: filterChange, - }, - isFormField: false, - }, - items: [ - { - xtype: 'combobox', - itemId: 'hastatefilter', - fieldLabel: gettext('HA status'), - emptyText: gettext('All'), - multiSelect: true, - editable: false, - store: haList, - listeners: { - change: filterChange, - }, - }, - { - xtype: 'container', - layout: { - type: 'vbox', - align: 'end', - }, - items: [ - { - xtype: 'button', - itemId: 'clearBtn', - text: gettext('Clear Filters'), - disabled: true, - handler: clearFilters, - }, - ], - }, - ], - }, - ], - }); - - items.push({ - xtype: 'vmselector', - itemId: 'vms', - name: 'vms', - flex: 1, - height: 300, - selectAll: true, - allowBlank: false, - plugins: '', - nodename: me.nodename, - listeners: { - selectionchange: function (vmselector, records) { - if (me.action === 'migrateall') { - let vmids = me.down('#vms').getValue(); - refreshLxcWarning(vmids, records); - } - }, - }, - }); - - me.formPanel = Ext.create('Ext.form.Panel', { - bodyPadding: 10, - border: false, - layout: { - type: 'vbox', - align: 'stretch', - }, - fieldDefaults: { - anchor: '100%', - }, - items: items, - }); - - let form = me.formPanel.getForm(); - - let submitBtn = Ext.create('Ext.Button', { - text: me.btnText, - handler: function () { - form.isValid(); - me.submit(form.getValues()); - }, - }); - - Ext.apply(me, { - items: [me.formPanel], - buttons: [submitBtn], - }); - - me.callParent(); - - if (me.prefilterIncludeTag) { - me.down('#includetagfilter').setValue(me.prefilterIncludeTag); - } - - form.on('validitychange', function () { - let valid = form.isValid(); - submitBtn.setDisabled(!valid); - }); - form.isValid(); - - filterChange(); - }, -}); -Ext.define('PVE.ceph.Install', { - extend: 'Ext.window.Window', - xtype: 'pveCephInstallWindow', - mixins: ['Proxmox.Mixin.CBind'], - - width: 220, - header: false, - resizable: false, - draggable: false, - modal: true, - nodename: undefined, - shadow: false, - border: false, - bodyBorder: false, - closable: false, - cls: 'install-mask', - bodyCls: 'install-mask', - layout: { - align: 'stretch', - pack: 'center', - type: 'vbox', - }, - viewModel: { - data: { - isInstalled: false, - }, - formulas: { - buttonText: function (get) { - if (get('isInstalled')) { - return gettext('Configure Ceph'); - } else { - return gettext('Install Ceph'); - } - }, - windowText: function (get) { - if (get('isInstalled')) { - return `

    - ${gettext('Ceph is not initialized.')} - ${gettext('You need to create an initial config once.')}

    `; - } else { - return ( - '

    ' + - gettext('Ceph is not installed on this node.') + - '
    ' + - gettext('Would you like to install it now?') + - '

    ' - ); - } - }, - }, - }, - items: [ - { - bind: { - html: '{windowText}', - }, - border: false, - padding: 5, - bodyCls: 'install-mask', - }, - { - xtype: 'button', - bind: { - text: '{buttonText}', - }, - viewModel: {}, - cbind: { - nodename: '{nodename}', - }, - handler: function () { - let view = this.up('pveCephInstallWindow'); - let wizard = Ext.create('PVE.ceph.CephInstallWizard', { - nodename: view.nodename, - }); - wizard.getViewModel().set('isInstalled', this.getViewModel().get('isInstalled')); - wizard.show(); - view.mon(wizard, 'beforeClose', function () { - view.fireEvent('cephInstallWindowClosed'); - view.close(); - }); - }, - }, - ], -}); -Ext.define('PVE.window.Clone', { - extend: 'Ext.window.Window', - - resizable: false, - - isTemplate: false, - - onlineHelp: 'qm_copy_and_clone', - - controller: { - xclass: 'Ext.app.ViewController', - control: { - 'panel[reference=cloneform]': { - validitychange: 'disableSubmit', - }, - }, - disableSubmit: function (form) { - this.lookupReference('submitBtn').setDisabled(!form.isValid()); - }, - }, - - statics: { - // display a snapshot selector only if needed - wrap: function (nodename, vmid, vmname, isTemplate, guestType) { - Proxmox.Utils.API2Request({ - url: '/nodes/' + nodename + '/' + guestType + '/' + vmid + '/snapshot', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - success: function (response, opts) { - var snapshotList = response.result.data; - var hasSnapshots = !( - snapshotList.length === 1 && snapshotList[0].name === 'current' - ); - - Ext.create('PVE.window.Clone', { - nodename: nodename, - guestType: guestType, - vmid: vmid, - vmname: vmname, - isTemplate: isTemplate, - hasSnapshots: hasSnapshots, - }).show(); - }, - }); - }, - }, - - create_clone: function (values) { - var me = this; - - var params = { newid: values.newvmid }; - - if (values.snapname && values.snapname !== 'current') { - params.snapname = values.snapname; - } - - if (values.pool) { - params.pool = values.pool; - } - - if (values.name) { - if (me.guestType === 'lxc') { - params.hostname = values.name; - } else { - params.name = values.name; - } - } - - if (values.target) { - params.target = values.target; - } - - if (values.clonemode === 'copy') { - params.full = 1; - if (values.hdstorage) { - params.storage = values.hdstorage; - if (values.diskformat && me.guestType !== 'lxc') { - params.format = values.diskformat; - } - } - } - - Proxmox.Utils.API2Request({ - params: params, - url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/clone', - waitMsgTarget: me, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - success: function (response, options) { - me.close(); - }, - }); - }, - - // disable the Storage selector when clone mode is linked clone - updateVisibility: function () { - var me = this; - var clonemode = me.lookupReference('clonemodesel').getValue(); - var disksel = me.lookup('diskselector'); - disksel.setDisabled(clonemode === 'clone'); - }, - - // add to the list of valid nodes each node where - // all the VM disks are available - verifyFeature: function () { - var me = this; - - var snapname = me.lookupReference('snapshotsel').getValue(); - var clonemode = me.lookupReference('clonemodesel').getValue(); - - var params = { feature: clonemode }; - if (snapname !== 'current') { - params.snapname = snapname; - } - - Proxmox.Utils.API2Request({ - waitMsgTarget: me, - url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/feature', - params: params, - method: 'GET', - failure: function (response, opts) { - me.lookupReference('submitBtn').setDisabled(true); - Ext.Msg.alert('Error', response.htmlStatus); - }, - success: function (response, options) { - var res = response.result.data; - - me.lookupReference('targetsel').allowedNodes = res.nodes; - me.lookupReference('targetsel').validate(); - }, - }); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - if (!me.snapname) { - me.snapname = 'current'; - } - - if (!me.guestType) { - throw 'no Guest Type specified'; - } - - var titletext = me.guestType === 'lxc' ? 'CT' : 'VM'; - if (me.isTemplate) { - titletext += ' Template'; - } - - let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier(me.vmid, me.vmname); - me.title = `Clone ${titletext} ${formattedGuestIdentifier}`; - - var col1 = []; - var col2 = []; - - col1.push({ - xtype: 'pveNodeSelector', - name: 'target', - reference: 'targetsel', - fieldLabel: gettext('Target node'), - selectCurNode: true, - allowBlank: false, - onlineValidator: true, - listeners: { - change: function (f, value) { - me.lookup('diskselector').getComponent('hdstorage').setTargetNode(value); - }, - }, - }); - - var modelist = [['copy', gettext('Full Clone')]]; - if (me.isTemplate) { - modelist.push(['clone', gettext('Linked Clone')]); - } - - col1.push( - { - xtype: 'pveGuestIDSelector', - name: 'newvmid', - guestType: me.guestType, - value: '', - loadNextFreeID: true, - validateExists: false, - }, - { - xtype: 'textfield', - name: 'name', - vtype: 'DnsName', - allowBlank: true, - fieldLabel: me.guestType === 'lxc' ? gettext('Hostname') : gettext('Name'), - }, - { - xtype: 'pvePoolSelector', - fieldLabel: gettext('Resource Pool'), - name: 'pool', - value: '', - allowBlank: true, - }, - ); - - col2.push( - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('Mode'), - name: 'clonemode', - reference: 'clonemodesel', - allowBlank: false, - hidden: !me.isTemplate, - value: me.isTemplate ? 'clone' : 'copy', - comboItems: modelist, - listeners: { - change: function (t, value) { - me.updateVisibility(); - me.verifyFeature(); - }, - }, - }, - { - xtype: 'PVE.form.SnapshotSelector', - name: 'snapname', - reference: 'snapshotsel', - fieldLabel: gettext('Snapshot'), - nodename: me.nodename, - guestType: me.guestType, - vmid: me.vmid, - hidden: !!(me.isTemplate || !me.hasSnapshots), - disabled: false, - allowBlank: false, - value: me.snapname, - listeners: { - change: function (f, value) { - me.verifyFeature(); - }, - }, - }, - { - xtype: 'pveDiskStorageSelector', - reference: 'diskselector', - nodename: me.nodename, - autoSelect: false, - hideSize: true, - hideSelection: true, - storageLabel: gettext('Target Storage'), - allowBlank: true, - storageContent: me.guestType === 'qemu' ? 'images' : 'rootdir', - emptyText: gettext('Same as source'), - disabled: !!me.isTemplate, // because default mode is clone for templates - }, - ); - - var formPanel = Ext.create('Ext.form.Panel', { - bodyPadding: 10, - reference: 'cloneform', - border: false, - layout: 'hbox', - defaultType: 'container', - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - items: [ - { - flex: 1, - padding: '0 10 0 0', - layout: 'anchor', - items: col1, - }, - { - flex: 1, - padding: '0 0 0 10', - layout: 'anchor', - items: col2, - }, - ], - }); - - Ext.apply(me, { - modal: true, - width: 600, - height: 250, - border: false, - layout: 'fit', - buttons: [ - { - xtype: 'proxmoxHelpButton', - listenToGlobalEvent: false, - hidden: false, - onlineHelp: me.onlineHelp, - }, - '->', - { - reference: 'submitBtn', - text: gettext('Clone'), - disabled: true, - handler: function () { - var cloneForm = me.lookupReference('cloneform'); - if (cloneForm.isValid()) { - me.create_clone(cloneForm.getValues()); - } - }, - }, - ], - items: [formPanel], - }); - - me.callParent(); - - me.verifyFeature(); - }, -}); -/* - * ConfirmRemoveDialog window with additional checkboxes for removing resources - */ -Ext.define('PVE.window.ConfirmRemoveResource', { - extend: 'Proxmox.window.ConfirmRemoveDialog', - alias: 'widget.pveConfirmRemoveResource', - - additionalItems: [ - { - xtype: 'proxmoxcheckbox', - name: 'purge', - reference: 'purgeCheckbox', - boxLabel: gettext('Purge resource from referenced HA rules'), - padding: '5 0 0 0', - checked: true, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Also removes resource from HA rules and removes rule if there are no other resources in it', - ), - }, - }, - ], - - getText: function () { - let me = this; - - me.text = `Are you sure you want to remove resource '${me.getItem().id}'?`; - - return me.callParent(); - }, - - getParams: function () { - let me = this; - - const purgeCheckbox = me.lookupReference('purgeCheckbox'); - me.params.purge = purgeCheckbox.checked ? 1 : 0; - - return me.callParent(); - }, -}); -Ext.define('PVE.FirewallEnableEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveFirewallEnableEdit'], - mixins: ['Proxmox.Mixin.CBind'], - - subject: gettext('Firewall'), - cbindData: { - defaultValue: 0, - }, - width: 350, - - items: [ - { - xtype: 'proxmoxcheckbox', - name: 'enable', - uncheckedValue: 0, - cbind: { - defaultValue: '{defaultValue}', - checked: '{defaultValue}', - }, - deleteDefaultValue: false, - fieldLabel: gettext('Firewall'), - }, - { - xtype: 'displayfield', - name: 'warning', - userCls: 'pmx-hint', - value: gettext('Warning: Firewall still disabled at datacenter level!'), - hidden: true, - }, - ], - - beforeShow: function () { - var me = this; - - Proxmox.Utils.API2Request({ - url: '/api2/extjs/cluster/firewall/options', - method: 'GET', - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, opts) { - if (!response.result.data.enable) { - me.down('displayfield[name=warning]').setVisible(true); - } - }, - }); - }, -}); -Ext.define('PVE.FirewallLograteInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveFirewallLograteInputPanel', - - viewModel: {}, - - items: [ - { - xtype: 'proxmoxcheckbox', - name: 'enable', - reference: 'enable', - fieldLabel: gettext('Enable'), - value: true, - }, - { - layout: 'hbox', - border: false, - items: [ - { - xtype: 'numberfield', - name: 'rate', - fieldLabel: gettext('Log rate limit'), - minValue: 1, - maxValue: 99, - allowBlank: false, - flex: 2, - value: 1, - }, - { - xtype: 'box', - html: '
    /
    ', - }, - { - xtype: 'proxmoxKVComboBox', - name: 'unit', - comboItems: [ - ['second', 'second'], - ['minute', 'minute'], - ['hour', 'hour'], - ['day', 'day'], - ], - allowBlank: false, - flex: 1, - value: 'second', - }, - ], - }, - { - xtype: 'numberfield', - name: 'burst', - fieldLabel: gettext('Log burst limit'), - minValue: 1, - maxValue: 99, - value: 5, - }, - ], - - onGetValues: function (values) { - let _me = this; - - let cfg = { - enable: values.enable !== undefined ? 1 : 0, - rate: values.rate + '/' + values.unit, - burst: values.burst, - }; - let properties = PVE.Parser.printPropertyString(cfg, undefined); - if (properties === '') { - return { delete: 'log_ratelimit' }; - } - return { log_ratelimit: properties }; - }, - - setValues: function (values) { - let me = this; - - let properties = {}; - if (values.log_ratelimit !== undefined) { - properties = PVE.Parser.parsePropertyString(values.log_ratelimit, 'enable'); - if (properties.rate) { - let matches = properties.rate.match(/^(\d+)\/(second|minute|hour|day)$/); - if (matches) { - properties.rate = matches[1]; - properties.unit = matches[2]; - } - } - } - me.callParent([properties]); - }, -}); - -Ext.define('PVE.FirewallLograteEdit', { - extend: 'Proxmox.window.Edit', - xtype: 'pveFirewallLograteEdit', - - subject: gettext('Log rate limit'), - - items: [ - { - xtype: 'pveFirewallLograteInputPanel', - }, - ], - autoLoad: true, -}); -/*global u2f*/ -Ext.define('PVE.window.LoginWindow', { - extend: 'Ext.window.Window', - - viewModel: { - data: { - openid: false, - }, - formulas: { - button_text: function (get) { - if (get('openid') === true) { - return gettext('Login (OpenID redirect)'); - } else { - return gettext('Login'); - } - }, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - init: async function () { - if (Proxmox.ConsentText) { - let oidc_auth_redirect = Proxmox.Utils.getOpenIDRedirectionAuthorization(); - if (oidc_auth_redirect === undefined) { - Ext.create('Proxmox.window.ConsentModal', { - autoShow: true, - consent: Proxmox.Markdown.parse( - Proxmox.Utils.base64ToUtf8(Proxmox.ConsentText), - ), - }); - } - } - }, - - onLogon: async function () { - var me = this; - - var form = this.lookupReference('loginForm'); - var unField = this.lookupReference('usernameField'); - var saveunField = this.lookupReference('saveunField'); - var view = this.getView(); - - if (!form.isValid()) { - return; - } - - let creds = form.getValues(); - - if (this.getViewModel().data.openid === true) { - const redirectURL = location.origin; - Proxmox.Utils.API2Request({ - url: '/api2/extjs/access/openid/auth-url', - params: { - realm: creds.realm, - 'redirect-url': redirectURL, - }, - method: 'POST', - success: function (resp, opts) { - window.location = resp.result.data; - }, - failure: function (resp, opts) { - Proxmox.Utils.authClear(); - form.unmask(); - Ext.MessageBox.alert( - gettext('Error'), - gettext('OpenID redirect failed.') + `
    ${resp.htmlStatus}`, - ); - }, - }); - return; - } - - view.el.mask(gettext('Please wait...'), 'x-mask-loading'); - - // set or clear username - var sp = Ext.state.Manager.getProvider(); - if (saveunField.getValue() === true) { - sp.set(unField.getStateId(), unField.getValue()); - } else { - sp.clear(unField.getStateId()); - } - sp.set(saveunField.getStateId(), saveunField.getValue()); - - try { - // Request updated authentication mechanism: - creds['new-format'] = 1; - - let resp = await Proxmox.Async.api2({ - url: '/api2/extjs/access/ticket', - params: creds, - method: 'POST', - }); - - let data = resp.result.data; - if (data.ticket.startsWith('PVE:!tfa!')) { - // Store first factor login information first: - data.LoggedOut = true; - Proxmox.Utils.setAuthData(data); - - data = await me.performTFAChallenge(data); - - // Fill in what we copy over from the 1st factor: - data.CSRFPreventionToken = Proxmox.CSRFPreventionToken; - data.username = Proxmox.UserName; - me.success(data); - } else if (Ext.isDefined(data.NeedTFA)) { - // Store first factor login information first: - data.LoggedOut = true; - Proxmox.Utils.setAuthData(data); - - if (Ext.isDefined(data.U2FChallenge)) { - me.perform_u2f(data); - } else { - me.perform_otp(); - } - } else { - me.success(data); - } - } catch (error) { - me.failure(error); - } - }, - - /* START NEW TFA CODE (pbs copy) */ - performTFAChallenge: async function (data) { - let _me = this; - - let userid = data.username; - let ticket = data.ticket; - let challenge = JSON.parse( - decodeURIComponent(ticket.split(':')[1].slice('!tfa!'.length)), - ); - - let resp = await new Promise((resolve, reject) => { - Ext.create('Proxmox.window.TfaLoginWindow', { - userid, - ticket, - challenge, - onResolve: (value) => resolve(value), - onReject: reject, - }).show(); - }); - - return resp.result.data; - }, - /* END NEW TFA CODE (pbs copy) */ - - failure: function (resp) { - var me = this; - var view = me.getView(); - view.el.unmask(); - var handler = function () { - var uf = me.lookupReference('usernameField'); - uf.focus(true, true); - }; - - let emsg = gettext('Login failed. Please try again'); - if (resp.status) { - emsg = Ext.String.format( - '{0}
    {1}
    {2}', - gettext('Login failed:'), - resp.htmlStatus, - gettext('Please try again'), - ); - } - - if (resp.failureType === 'connect') { - emsg = gettext( - 'Connection failure. Network error or Proxmox VE services not running?', - ); - } - - Ext.MessageBox.alert(gettext('Error'), emsg, handler); - }, - success: function (data) { - var me = this; - var view = me.getView(); - var handler = view.handler || Ext.emptyFn; - handler.call(me, data); - view.close(); - }, - - perform_otp: function () { - var me = this; - var win = Ext.create('PVE.window.TFALoginWindow', { - onLogin: function (value) { - me.finish_tfa(value); - }, - onCancel: function () { - Proxmox.LoggedOut = false; - Proxmox.Utils.authClear(); - me.getView().show(); - }, - }); - win.show(); - }, - - perform_u2f: function (data) { - var me = this; - // Show the message: - var msg = Ext.Msg.show({ - title: 'U2F: ' + gettext('Verification'), - message: gettext('Please press the button on your U2F Device'), - buttons: [], - }); - var chlg = data.U2FChallenge; - var key = { - version: chlg.version, - keyHandle: chlg.keyHandle, - }; - u2f.sign(chlg.appId, chlg.challenge, [key], function (res) { - msg.close(); - if (res.errorCode) { - Proxmox.Utils.authClear(); - Ext.Msg.alert(gettext('Error'), PVE.Utils.render_u2f_error(res.errorCode)); - return; - } - delete res.errorCode; - me.finish_tfa(JSON.stringify(res)); - }); - }, - finish_tfa: function (res) { - var me = this; - var view = me.getView(); - view.el.mask(gettext('Please wait...'), 'x-mask-loading'); - Proxmox.Utils.API2Request({ - url: '/api2/extjs/access/tfa', - params: { - response: res, - }, - method: 'POST', - timeout: 5000, // it'll delay both success & failure - success: function (resp, opts) { - view.el.unmask(); - // Fill in what we copy over from the 1st factor: - var data = resp.result.data; - data.CSRFPreventionToken = Proxmox.CSRFPreventionToken; - data.username = Proxmox.UserName; - // Finish logging in: - me.success(data); - }, - failure: function (resp, opts) { - Proxmox.Utils.authClear(); - me.failure(resp); - }, - }); - }, - - control: { - 'field[name=username]': { - specialkey: function (f, e) { - if (e.getKey() === e.ENTER) { - let pf = this.lookupReference('passwordField'); - if (!pf.getValue()) { - pf.focus(false); - } - } - }, - }, - 'field[name=lang]': { - change: function (f, value) { - var dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10); - Ext.util.Cookies.set('PVELangCookie', value, dt); - this.getView().mask(gettext('Please wait...'), 'x-mask-loading'); - window.location.reload(); - }, - }, - 'field[name=realm]': { - change: function (f, value) { - let record = f.store.getById(value); - if (record === undefined) { - return; - } - let data = record.data; - this.getViewModel().set('openid', data.type === 'openid'); - }, - }, - 'button[reference=loginButton]': { - click: 'onLogon', - }, - '#': { - show: function () { - var me = this; - - var sp = Ext.state.Manager.getProvider(); - var checkboxField = this.lookupReference('saveunField'); - var unField = this.lookupReference('usernameField'); - - var checked = sp.get(checkboxField.getStateId()); - checkboxField.setValue(checked); - - if (checked === true) { - let username = sp.get(unField.getStateId()); - unField.setValue(username); - let pwField = this.lookupReference('passwordField'); - pwField.focus(); - } - - let auth = Proxmox.Utils.getOpenIDRedirectionAuthorization(); - if (auth !== undefined) { - Proxmox.Utils.authClear(); - - let loginForm = this.lookupReference('loginForm'); - loginForm.mask(gettext('OpenID login - please wait...'), 'x-mask-loading'); - - const redirectURL = location.origin; - - Proxmox.Utils.API2Request({ - url: '/api2/extjs/access/openid/login', - params: { - state: auth.state, - code: auth.code, - 'redirect-url': redirectURL, - }, - method: 'POST', - failure: function (response) { - loginForm.unmask(); - let error = response.htmlStatus; - Ext.MessageBox.alert( - gettext('Error'), - gettext('OpenID login failed, please try again') + - `
    ${error}`, - () => { - window.location = redirectURL; - }, - ); - }, - success: function (response, options) { - loginForm.unmask(); - let data = response.result.data; - history.replaceState(null, '', redirectURL); - me.success(data); - }, - }); - } - }, - }, - }, - }, - - width: 400, - modal: true, - border: false, - draggable: true, - closable: false, - resizable: false, - layout: 'auto', - - title: gettext('Proxmox VE Login'), - - defaultFocus: 'usernameField', - defaultButton: 'loginButton', - - items: [ - { - xtype: 'form', - layout: 'form', - url: '/api2/extjs/access/ticket', - reference: 'loginForm', - - fieldDefaults: { - labelAlign: 'right', - allowBlank: false, - }, - - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('User name'), - name: 'username', - itemId: 'usernameField', - reference: 'usernameField', - stateId: 'login-username', - inputAttrTpl: 'autocomplete=username', - bind: { - visible: '{!openid}', - disabled: '{openid}', - }, - }, - { - xtype: 'textfield', - inputType: 'password', - fieldLabel: gettext('Password'), - name: 'password', - reference: 'passwordField', - inputAttrTpl: 'autocomplete=current-password', - bind: { - visible: '{!openid}', - disabled: '{openid}', - }, - }, - { - xtype: 'pmxRealmComboBox', - name: 'realm', - }, - { - xtype: 'proxmoxLanguageSelector', - fieldLabel: gettext('Language'), - value: PVE.Utils.getUiLanguage(), - name: 'lang', - reference: 'langField', - submitValue: false, - }, - ], - buttons: [ - { - xtype: 'checkbox', - fieldLabel: gettext('Save User name'), - name: 'saveusername', - reference: 'saveunField', - stateId: 'login-saveusername', - labelWidth: 250, - labelAlign: 'right', - submitValue: false, - bind: { - visible: '{!openid}', - }, - }, - { - bind: { - text: '{button_text}', - }, - reference: 'loginButton', - }, - ], - }, - ], -}); -Ext.define('PVE.window.Migrate', { - extend: 'Ext.window.Window', - - vmtype: undefined, - nodename: undefined, - vmid: undefined, - vmname: undefined, - maxHeight: 450, - - viewModel: { - data: { - vmid: undefined, - nodename: undefined, - vmtype: undefined, - running: false, - qemu: { - onlineHelp: 'qm_migration', - commonName: 'VM', - }, - lxc: { - onlineHelp: 'pct_migration', - commonName: 'CT', - }, - migration: { - possible: true, - preconditions: [], - 'with-local-disks': 0, - mode: undefined, - allowedNodes: undefined, - overwriteLocalResourceCheck: false, - hasLocalResources: false, - withConntrackState: true, - bothHaveDbusVmstate: false, - }, - }, - formulas: { - setMigrationMode: function (get) { - if (get('running')) { - if (get('vmtype') === 'qemu') { - return gettext('Online'); - } else { - return gettext('Restart Mode'); - } - } else { - return gettext('Offline'); - } - }, - setStorageselectorHidden: function (get) { - if (get('migration.with-local-disks') && get('running')) { - return false; - } else { - return true; - } - }, - setLocalResourceCheckboxHidden: function (get) { - if ( - get('running') || - !get('migration.hasLocalResources') || - Proxmox.UserName !== 'root@pam' - ) { - return true; - } else { - return false; - } - }, - conntrackStateCheckboxHidden: (get) => - !get('running') || - get('vmtype') !== 'qemu' || - !get('migration.bothHaveDbusVmstate'), - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - 'panel[reference=formPanel]': { - validityChange: function (panel, isValid) { - this.getViewModel().set('migration.possible', isValid); - this.checkMigratePreconditions(); - }, - }, - }, - - init: function (view) { - var me = this, - vm = view.getViewModel(); - - if (!view.nodename) { - throw 'missing custom view config: nodename'; - } - vm.set('nodename', view.nodename); - - if (!view.vmid) { - throw 'missing custom view config: vmid'; - } - vm.set('vmid', view.vmid); - - if (!view.vmtype) { - throw 'missing custom view config: vmtype'; - } - vm.set('vmtype', view.vmtype); - - let title = Ext.String.format( - '{0} {1} {2}', - gettext('Migrate'), - vm.get(view.vmtype).commonName, - PVE.Utils.getFormattedGuestIdentifier(view.vmid, view.vmname), - ); - view.setTitle(title); - - me.lookup('proxmoxHelpButton').setHelpConfig({ - onlineHelp: vm.get(view.vmtype).onlineHelp, - }); - me.lookup('formPanel').isValid(); - }, - - onTargetChange: function (nodeSelector) { - // Always display the storages of the currently selected migration target - this.lookup('pveDiskStorageSelector').setNodename(nodeSelector.value); - this.checkMigratePreconditions(true); - }, - - startMigration: function () { - var me = this, - view = me.getView(), - vm = me.getViewModel(); - - var values = me.lookup('formPanel').getValues(); - var params = { - target: values.target, - }; - - if (vm.get('migration.mode')) { - params[vm.get('migration.mode')] = 1; - } - if (vm.get('migration.with-local-disks')) { - params['with-local-disks'] = 1; - } - //offline migration to a different storage currently might fail at a late stage - //(i.e. after some disks have been moved), so don't expose it yet in the GUI - if (vm.get('migration.with-local-disks') && vm.get('running') && values.targetstorage) { - params.targetstorage = values.targetstorage; - } - - if (vm.get('migration.overwriteLocalResourceCheck')) { - params.force = 1; - } - - if (vm.get('migration.bothHaveDbusVmstate') && vm.get('migration.withConntrackState')) { - params['with-conntrack-state'] = 1; - } - - Proxmox.Utils.API2Request({ - params: params, - url: - '/nodes/' + - vm.get('nodename') + - '/' + - vm.get('vmtype') + - '/' + - vm.get('vmid') + - '/migrate', - waitMsgTarget: view, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus); - }, - success: function (response, options) { - var upid = response.result.data; - var extraTitle = Ext.String.format( - ' ({0} ---> {1})', - vm.get('nodename'), - params.target, - ); - - Ext.create('Proxmox.window.TaskViewer', { - upid: upid, - extraTitle: extraTitle, - }).show(); - - view.close(); - }, - }); - }, - - checkMigratePreconditions: async function (resetMigrationPossible) { - var me = this, - vm = me.getViewModel(); - - var vmrec = PVE.data.ResourceStore.findRecord( - 'vmid', - vm.get('vmid'), - 0, - false, - false, - true, - ); - if (vmrec && vmrec.data && vmrec.data.running) { - vm.set('running', true); - } - - me.lookup('pveNodeSelector').disallowedNodes = [vm.get('nodename')]; - - if (vm.get('vmtype') === 'qemu') { - await me.checkQemuPreconditions(resetMigrationPossible); - } else { - await me.checkLxcPreconditions(resetMigrationPossible); - } - - // Only allow nodes where the local storage is available in case of offline migration - // where storage migration is not possible - me.lookup('pveNodeSelector').allowedNodes = vm.get('migration.allowedNodes'); - - me.lookup('formPanel').isValid(); - }, - - checkQemuPreconditions: async function (resetMigrationPossible) { - let me = this, - vm = me.getViewModel(), - migrateStats; - - if (vm.get('running')) { - vm.set('migration.mode', 'online'); - } - - try { - if ( - me.fetchingNodeMigrateInfo && - me.fetchingNodeMigrateInfo === vm.get('nodename') - ) { - return; - } - me.fetchingNodeMigrateInfo = vm.get('nodename'); - let { result } = await Proxmox.Async.api2({ - url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`, - method: 'GET', - }); - migrateStats = result.data; - } catch (error) { - if (error?.result?.status !== 501) { - Ext.Msg.alert(Proxmox.Utils.errorText, error.htmlStatus); - } - me.fetchingNodeMigrateInfo = false; - return; - } - - const target = me.lookup('pveNodeSelector').value; - let targetCapabilities = {}; - - try { - const { result } = await Proxmox.Async.api2({ - url: `/nodes/${target}/capabilities/qemu/migration`, - method: 'GET', - }); - targetCapabilities = result.data; - } catch (err) { - // Only emit a warning in the case the target node does not (yet) support the - // `capabilites/qemu/migration` endpoint and simply treat all features as unsupported. - console.warn(`failed to query /capabilites/qemu/migration on '${target}':`, err); - } - - me.fetchingNodeMigrateInfo = false; - - if (migrateStats.running) { - vm.set('running', true); - } - // Get migration object from viewmodel to prevent to many bind callbacks - let migration = vm.get('migration'); - if (resetMigrationPossible) { - migration.possible = true; - } - migration.preconditions = []; - let disallowed = migrateStats.not_allowed_nodes?.[target] ?? {}; - - if (migrateStats.allowed_nodes && !vm.get('running')) { - migration.allowedNodes = migrateStats.allowed_nodes; - if (target.length && !migrateStats.allowed_nodes.includes(target)) { - if (disallowed.unavailable_storages !== undefined) { - let missingStorages = disallowed.unavailable_storages.join(', '); - const text = Ext.String.format( - gettext( - 'Storage(s) ({0}) not available on selected target. Start VM to use live storage migration or select other target node.', - ), - missingStorages, - ); - - migration.possible = false; - migration.preconditions.push({ text, severity: 'error' }); - } - } - } - - if (disallowed['unavailable-resources'] !== undefined) { - let unavailableResources = disallowed['unavailable-resources'].join(', '); - const text = Ext.String.format( - gettext('Mapped Resources ({0}) not available on selected target.'), - unavailableResources, - ); - - migration.possible = false; - migration.preconditions.push({ text, severity: 'error' }); - } - - let blockingResources = []; - let mappedResources = migrateStats['mapped-resource-info'] ?? {}; - - for (const res of migrateStats.local_resources) { - if (!mappedResources[res]) { - blockingResources.push(res); - } - } - - if (blockingResources.length) { - migration.hasLocalResources = true; - if (!migration.overwriteLocalResourceCheck || vm.get('running')) { - const text = Ext.String.format( - gettext('Cannot migrate VM with local resources: {0}'), - blockingResources.join(', '), - ); - - migration.possible = false; - migration.preconditions.push({ text, severity: 'error' }); - } else { - const text = Ext.String.format( - gettext( - 'Migrating VM with local resources: {0}. This might fail if the resources are not available on the target node.', - ), - blockingResources.join(', '), - ); - - migration.preconditions.push({ text, severity: 'warning' }); - } - } - - if (vm.get('running')) { - let allowed = []; - let notAllowed = []; - for (const [key, resource] of Object.entries(mappedResources)) { - if (resource['live-migration']) { - allowed.push(key); - } else { - notAllowed.push(key); - } - } - if (notAllowed.length > 0) { - const text = Ext.String.format( - gettext('Cannot migrate running VM with mapped resources: {0}'), - notAllowed.join(', '), - ); - - migration.possible = false; - migration.preconditions.push({ text, severity: 'error' }); - } else if (allowed.length > 0) { - const text = Ext.String.format( - gettext( - 'Live-migrating running VM with mapped resources (Experimental): {0}', - ), - allowed.join(', '), - ); - - migration.preconditions.push({ text, severity: 'warning' }); - } - } - - if (migrateStats.local_disks.length) { - migrateStats.local_disks.forEach(function (disk) { - if (disk.cdrom && disk.cdrom === 1) { - if (!disk.volid.includes('vm-' + vm.get('vmid') + '-cloudinit')) { - migration.possible = false; - migration.preconditions.push({ - text: gettext('Cannot migrate VM with local CD/DVD'), - severity: 'error', - }); - } - } else { - let size = disk.size - ? '(' + Proxmox.Utils.render_size(disk.size) + ')' - : ''; - const text = Ext.String.format( - gettext('Migration with local disk might take long: {0} {1}'), - disk.volid, - size, - ); - - migration['with-local-disks'] = 1; - migration.preconditions.push({ text, severity: 'warning' }); - } - }); - } - - migration.bothHaveDbusVmstate = - migrateStats['has-dbus-vmstate'] && targetCapabilities['has-dbus-vmstate']; - if (vm.get('running')) { - if (migration.withConntrackState && !migrateStats['has-dbus-vmstate']) { - migration.preconditions.push({ - text: gettext( - 'Cannot migrate conntrack state, source node is lacking support.', - ), - // user cannot really do anything about this, do not bother with scaring them! - severity: 'info', - }); - } - if (migration.withConntrackState && !targetCapabilities['has-dbus-vmstate']) { - migration.preconditions.push({ - text: gettext( - 'Cannot migrate conntrack state, target node is lacking support. Active network connections might get dropped.', - ), - severity: 'warning', - }); - } - - if (migration.bothHaveDbusVmstate && !migration.withConntrackState) { - migration.preconditions.push({ - text: gettext( - 'Conntrack state migration disabled. Active network connections might get dropped.', - ), - severity: 'warning', - }); - } - } - - let blockingHAResources = disallowed['blocking-ha-resources'] ?? []; - if (blockingHAResources.length) { - migration.possible = false; - - for (const { sid, cause } of blockingHAResources) { - let reasonText; - if (cause === 'resource-affinity') { - reasonText = Ext.String.format( - gettext( - 'HA resource {0} with negative affinity to VM on selected target node', - ), - sid, - ); - } else { - reasonText = Ext.String.format( - gettext('blocking HA resource {0} on selected target node'), - sid, - ); - } - - migration.preconditions.push({ - severity: 'error', - text: Ext.String.format( - gettext('Cannot migrate VM, because {0}.'), - reasonText, - ), - }); - } - } - - let dependentHAResources = migrateStats['dependent-ha-resources']; - if (dependentHAResources !== undefined) { - for (const sid of dependentHAResources) { - const text = Ext.String.format( - gettext( - 'HA resource {0} with positive affinity to VM is also migrated to selected target node.', - ), - sid, - ); - - migration.preconditions.push({ text, severity: 'warning' }); - } - } - - vm.set('migration', migration); - }, - checkLxcPreconditions: async function (resetMigrationPossible) { - let me = this; - let vm = me.getViewModel(); - let migrateStats; - - if (vm.get('running')) { - vm.set('migration.mode', 'restart'); - } - - try { - if ( - me.fetchingNodeMigrateInfo && - me.fetchingNodeMigrateInfo === vm.get('nodename') - ) { - return; - } - me.fetchingNodeMigrateInfo = vm.get('nodename'); - let { result } = await Proxmox.Async.api2({ - url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`, - method: 'GET', - }); - migrateStats = result.data; - me.fetchingNodeMigrateInfo = false; - } catch (error) { - if (error?.result?.status !== 501) { - Ext.Msg.alert(Proxmox.Utils.errorText, error.htmlStatus); - } - me.fetchingNodeMigrateInfo = false; - return; - } - - if (migrateStats.running) { - vm.set('running', true); - } - - // Get migration object from viewmodel to prevent to many bind callbacks - let migration = vm.get('migration'); - if (resetMigrationPossible) { - migration.possible = true; - } - migration.preconditions = []; - let targetNode = me.lookup('pveNodeSelector').value; - let disallowed = migrateStats['not-allowed-nodes']?.[targetNode] ?? {}; - - let blockingHAResources = disallowed['blocking-ha-resources'] ?? []; - if (blockingHAResources.length) { - migration.possible = false; - - for (const { sid, cause } of blockingHAResources) { - let reasonText; - if (cause === 'resource-affinity') { - reasonText = Ext.String.format( - gettext( - 'HA resource {0} with negative affinity to container on selected target node', - ), - sid, - ); - } else { - reasonText = Ext.String.format( - gettext('blocking HA resource {0} on selected target node'), - sid, - ); - } - - migration.preconditions.push({ - severity: 'error', - text: Ext.String.format( - gettext('Cannot migrate container, because {0}.'), - reasonText, - ), - }); - } - } - - let dependentHAResources = migrateStats['dependent-ha-resources']; - if (dependentHAResources !== undefined) { - for (const sid of dependentHAResources) { - const text = Ext.String.format( - gettext( - 'HA resource {0} with positive affinity to container is also migrated to selected target node.', - ), - sid, - ); - - migration.preconditions.push({ text, severity: 'warning' }); - } - } - - vm.set('migration', migration); - }, - }, - - width: 600, - modal: true, - layout: { - type: 'vbox', - align: 'stretch', - }, - border: false, - items: [ - { - xtype: 'form', - reference: 'formPanel', - bodyPadding: 10, - border: false, - layout: 'hbox', - items: [ - { - xtype: 'container', - flex: 1, - items: [ - { - xtype: 'displayfield', - name: 'source', - fieldLabel: gettext('Source node'), - bind: { - value: '{nodename}', - }, - }, - { - xtype: 'displayfield', - reference: 'migrationMode', - fieldLabel: gettext('Mode'), - bind: { - value: '{setMigrationMode}', - }, - }, - ], - }, - { - xtype: 'container', - flex: 1, - items: [ - { - xtype: 'pveNodeSelector', - reference: 'pveNodeSelector', - name: 'target', - fieldLabel: gettext('Target node'), - allowBlank: false, - disallowedNodes: undefined, - onlineValidator: true, - listeners: { - change: 'onTargetChange', - }, - }, - { - xtype: 'pveStorageSelector', - reference: 'pveDiskStorageSelector', - name: 'targetstorage', - fieldLabel: gettext('Target storage'), - storageContent: 'images', - allowBlank: true, - autoSelect: false, - emptyText: gettext('Current layout'), - bind: { - hidden: '{setStorageselectorHidden}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'overwriteLocalResourceCheck', - fieldLabel: gettext('Force'), - autoEl: { - tag: 'div', - 'data-qtip': gettext('Overwrite local resources unavailable check'), - }, - bind: { - hidden: '{setLocalResourceCheckboxHidden}', - value: '{migration.overwriteLocalResourceCheck}', - }, - listeners: { - change: { - fn: 'checkMigratePreconditions', - extraArg: true, - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'withConntrackState', - // TRANSLATORS: See https://www.kernel.org/doc/html/next/networking/netlink_spec/conntrack.html - fieldLabel: gettext('Conntrack state'), - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Enables live migration of conntrack entries for this VM.', - ), - }, - bind: { - hidden: '{conntrackStateCheckboxHidden}', - value: '{migration.withConntrackState}', - }, - listeners: { - change: { - fn: 'checkMigratePreconditions', - extraArg: true, - }, - }, - }, - ], - }, - ], - }, - { - xtype: 'gridpanel', - reference: 'preconditionGrid', - selectable: false, - flex: 1, - columns: [ - { - text: '', - dataIndex: 'severity', - renderer: function (v) { - switch (v) { - case 'warning': - return ' '; - case 'error': - return ''; - case 'info': - return ''; - default: - return v; - } - }, - width: 35, - }, - { - text: 'Info', - dataIndex: 'text', - cellWrap: true, - flex: 1, - }, - ], - bind: { - hidden: '{!migration.preconditions.length}', - store: { - fields: ['severity', 'text'], - data: '{migration.preconditions}', - sorters: 'text', - }, - }, - }, - ], - buttons: [ - { - xtype: 'proxmoxHelpButton', - reference: 'proxmoxHelpButton', - onlineHelp: 'pct_migration', - listenToGlobalEvent: false, - hidden: false, - }, - '->', - { - xtype: 'button', - reference: 'submitButton', - text: gettext('Migrate'), - handler: 'startMigration', - bind: { - disabled: '{!migration.possible}', - }, - }, - ], -}); -Ext.define('pve-prune-list', { - extend: 'Ext.data.Model', - fields: [ - 'type', - 'vmid', - { - name: 'ctime', - type: 'date', - dateFormat: 'timestamp', - }, - ], -}); - -Ext.define('PVE.PruneInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pvePruneInputPanel', - mixins: ['Proxmox.Mixin.CBind'], - - onGetValues: function (values) { - let me = this; - - // the API expects a single prune-backups property string - let pruneBackups = PVE.Parser.printPropertyString(values); - values = { - 'prune-backups': pruneBackups, - type: me.backup_type, - vmid: me.backup_id, - }; - - return values; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - if (!view.url) { - throw 'no url specified'; - } - if (!view.backup_type) { - throw 'no backup_type specified'; - } - if (!view.backup_id) { - throw 'no backup_id specified'; - } - - this.reload(); // initial load - }, - - reload: function () { - let view = this.getView(); - - // helper to allow showing why a backup is kept - let addKeepReasons = function (backups, params) { - const rules = [ - 'keep-last', - 'keep-hourly', - 'keep-daily', - 'keep-weekly', - 'keep-monthly', - 'keep-yearly', - 'keep-all', // when all keep options are not set - ]; - let counter = {}; - - backups.sort((a, b) => b.ctime - a.ctime); - - let ruleIndex = -1; - let nextRule = function () { - let rule; - do { - ruleIndex++; - rule = rules[ruleIndex]; - } while (!params[rule] && rule !== 'keep-all'); - counter[rule] = 0; - return rule; - }; - - let rule = nextRule(); - for (let backup of backups) { - if (backup.mark === 'keep') { - counter[rule]++; - if (rule !== 'keep-all') { - backup.keepReason = rule + ': ' + counter[rule]; - if (counter[rule] >= params[rule]) { - rule = nextRule(); - } - } else { - backup.keepReason = rule; - } - } - } - }; - - let params = view.getValues(); - let keepParams = PVE.Parser.parsePropertyString(params['prune-backups']); - - Proxmox.Utils.API2Request({ - url: view.url, - method: 'GET', - params: params, - callback: function () { - // for easy breakpoint setting - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, options) { - var data = response.result.data; - addKeepReasons(data, keepParams); - view.pruneStore.setData(data); - }, - }); - }, - - control: { - field: { change: 'reload' }, - }, - }, - - column1: [ - { - xtype: 'pmxPruneKeepField', - name: 'keep-last', - fieldLabel: gettext('keep-last'), - }, - { - xtype: 'pmxPruneKeepField', - name: 'keep-hourly', - fieldLabel: gettext('keep-hourly'), - }, - { - xtype: 'pmxPruneKeepField', - name: 'keep-daily', - fieldLabel: gettext('keep-daily'), - }, - { - xtype: 'pmxPruneKeepField', - name: 'keep-weekly', - fieldLabel: gettext('keep-weekly'), - }, - { - xtype: 'pmxPruneKeepField', - name: 'keep-monthly', - fieldLabel: gettext('keep-monthly'), - }, - { - xtype: 'pmxPruneKeepField', - name: 'keep-yearly', - fieldLabel: gettext('keep-yearly'), - }, - ], - - initComponent: function () { - var me = this; - - me.pruneStore = Ext.create('Ext.data.Store', { - model: 'pve-prune-list', - sorters: { property: 'ctime', direction: 'DESC' }, - }); - - me.column2 = [ - { - xtype: 'grid', - height: 200, - store: me.pruneStore, - columns: [ - { - header: gettext('Backup Time'), - sortable: true, - dataIndex: 'ctime', - renderer: function (value, metaData, record) { - let text = Ext.Date.format(value, 'Y-m-d H:i:s'); - if (record.data.mark === 'remove') { - return ( - '
    ' + text + '
    ' - ); - } else { - return text; - } - }, - flex: 1, - }, - { - text: 'Keep (reason)', - dataIndex: 'mark', - renderer: function (value, metaData, record) { - if (record.data.mark === 'keep') { - return 'true (' + record.data.keepReason + ')'; - } else if (record.data.mark === 'protected') { - return 'true (protected)'; - } else if (record.data.mark === 'renamed') { - return 'true (renamed)'; - } else { - return 'false'; - } - }, - flex: 1, - }, - ], - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.window.Prune', { - extend: 'Proxmox.window.Edit', - - method: 'DELETE', - submitText: gettext('Prune'), - - fieldDefaults: { labelWidth: 130 }, - - isCreate: true, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no nodename specified'; - } - if (!me.storage) { - throw 'no storage specified'; - } - if (!me.backup_type) { - throw 'no backup_type specified'; - } - if (me.backup_type !== 'qemu' && me.backup_type !== 'lxc') { - throw 'unknown backup type: ' + me.backup_type; - } - if (!me.backup_id) { - throw 'no backup_id specified'; - } - - let title = Ext.String.format( - gettext("Prune Backups for '{0}' on Storage '{1}'"), - me.backup_type + '/' + me.backup_id, - me.storage, - ); - - Ext.apply(me, { - url: '/api2/extjs/nodes/' + me.nodename + '/storage/' + me.storage + '/prunebackups', - title: title, - items: [ - { - xtype: 'pvePruneInputPanel', - url: - '/api2/extjs/nodes/' + - me.nodename + - '/storage/' + - me.storage + - '/prunebackups', - backup_type: me.backup_type, - backup_id: me.backup_id, - storage: me.storage, - }, - ], - }); - - me.callParent(); - }, -}); -Ext.define('PVE.window.Restore', { - extend: 'Ext.window.Window', // fixme: Proxmox.window.Edit? - - resizable: false, - width: 500, - modal: true, - layout: 'auto', - border: false, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - '#liveRestore': { - change: function (el, newVal) { - let liveWarning = this.lookupReference('liveWarning'); - liveWarning.setHidden(!newVal); - let start = this.lookupReference('start'); - start.setDisabled(newVal); - }, - }, - form: { - validitychange: function (f, valid) { - this.lookupReference('doRestoreBtn').setDisabled(!valid); - }, - }, - }, - - doRestore: function () { - let me = this; - let view = me.getView(); - - let values = view.down('form').getForm().getValues(); - - let params = { - vmid: view.vmid || values.vmid, - force: view.vmid ? 1 : 0, - }; - if (values.unique) { - params.unique = 1; - } - if (values.start && !values['live-restore']) { - params.start = 1; - } - if (values['ha-managed']) { - params['ha-managed'] = 1; - } - if (values['live-restore']) { - params['live-restore'] = 1; - } - if (values.storage) { - params.storage = values.storage; - } - - ['bwlimit', 'cores', 'name', 'memory', 'sockets'].forEach((opt) => { - if ((values[opt] ?? '') !== '') { - params[opt] = values[opt]; - } - }); - - if (params.name && view.vmtype === 'lxc') { - params.hostname = params.name; - delete params.name; - } - - let taskDescription; - if (view.vmtype === 'lxc') { - params.ostemplate = view.volid; - params.restore = 1; - if (values.unprivileged !== 'keep') { - params.unprivileged = values.unprivileged; - } - taskDescription = Proxmox.Utils.format_task_description('vzrestore', params.vmid); - } else if (view.vmtype === 'qemu') { - params.archive = view.volid; - taskDescription = Proxmox.Utils.format_task_description('qmrestore', params.vmid); - } else { - throw 'unknown VM type'; - } - let confirmMsg = Ext.htmlEncode(taskDescription); - - let executeRestore = () => { - Proxmox.Utils.API2Request({ - url: `/nodes/${view.nodename}/${view.vmtype}`, - params: params, - method: 'POST', - waitMsgTarget: view, - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function (response, options) { - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: response.result.data, - }); - view.close(); - }, - }); - }; - - if (view.vmid) { - if (view.vmtype === 'lxc') { - confirmMsg += `. ${gettext('This will permanently erase current CT data.')}`; - confirmMsg += `
    ${gettext('Mount point volumes are also erased.')}`; - } else { - confirmMsg += `. ${gettext('This will permanently erase current VM data.')}`; - } - Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function (btn) { - if (btn === 'yes') { - executeRestore(); - } - }); - } else { - executeRestore(); - } - }, - - afterRender: function () { - let view = this.getView(); - - Proxmox.Utils.API2Request({ - url: `/nodes/${view.nodename}/vzdump/extractconfig`, - method: 'GET', - waitMsgTarget: view, - params: { - volume: view.volid, - }, - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - success: function (response, options) { - let allStoragesAvailable = true; - - response.result.data.split('\n').forEach((line) => { - let [_, key, value] = line.match(/^([^:]+):\s*(\S+)\s*$/) ?? []; - - if (!key) { - return; - } - - if (key === '#qmdump#map') { - let match = value.match(/^(\S+):(\S+):(\S*):(\S*):$/) ?? []; - // if a /dev/XYZ disk was backed up, there is no storage hint - allStoragesAvailable &&= - !!match[3] && - !!PVE.data.ResourceStore.getById( - `storage/${view.nodename}/${match[3]}`, - ); - } else if (key === 'name' || key === 'hostname') { - view.lookupReference('nameField').setEmptyText(value); - } else if (key === 'memory' || key === 'cores' || key === 'sockets') { - view.lookupReference(`${key}Field`).setEmptyText(value); - } - }); - - if (!allStoragesAvailable) { - let storagesel = view.down('pveStorageSelector[name=storage]'); - storagesel.allowBlank = false; - storagesel.setEmptyText(''); - } - }, - }); - }, - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - if (!me.volid) { - throw 'no volume ID specified'; - } - if (!me.vmtype) { - throw 'no vmtype specified'; - } - - let storagesel = Ext.create('PVE.form.StorageSelector', { - nodename: me.nodename, - name: 'storage', - value: '', - fieldLabel: gettext('Storage'), - storageContent: me.vmtype === 'lxc' ? 'rootdir' : 'images', - // when restoring a container without specifying a storage, the backend defaults - // to 'local', which is unintuitive and 'rootdir' might not even be allowed on it - allowBlank: me.vmtype !== 'lxc', - emptyText: me.vmtype === 'lxc' ? '' : gettext('From backup configuration'), - autoSelect: me.vmtype === 'lxc', - }); - - let items = [ - { - xtype: 'displayfield', - value: me.volidText || me.volid, - fieldLabel: gettext('Source'), - }, - storagesel, - { - xtype: 'pmxDisplayEditField', - name: 'vmid', - fieldLabel: me.vmtype === 'lxc' ? 'CT' : 'VM', - value: me.vmid, - editable: !me.vmid, - editConfig: { - xtype: 'pveGuestIDSelector', - guestType: me.vmtype, - loadNextFreeID: true, - validateExists: false, - }, - }, - { - xtype: 'pveBandwidthField', - name: 'bwlimit', - backendUnit: 'KiB', - allowZero: true, - fieldLabel: gettext('Bandwidth Limit'), - emptyText: gettext('Defaults to target storage restore limit'), - autoEl: { - tag: 'div', - 'data-qtip': gettext("Use '0' to disable all bandwidth limits."), - }, - }, - { - xtype: 'fieldcontainer', - layout: 'hbox', - items: [ - { - xtype: 'proxmoxcheckbox', - name: 'unique', - fieldLabel: gettext('Unique'), - flex: 1, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Autogenerate unique properties, e.g., MAC addresses', - ), - }, - checked: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'start', - reference: 'start', - flex: 1, - fieldLabel: gettext('Start after restore'), - labelWidth: 105, - checked: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'ha-managed', - reference: 'ha-managed', - flex: 1, - fieldLabel: gettext('Add to HA'), - labelWidth: 120, - checked: false, - }, - ], - }, - ]; - - if (me.vmtype === 'lxc') { - items.push({ - xtype: 'radiogroup', - fieldLabel: gettext('Privilege Level'), - reference: 'noVNCScalingGroup', - height: '15px', // renders faster with value assigned - layout: { - type: 'hbox', - algin: 'stretch', - }, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Choose if you want to keep or override the privilege level of the restored Container.', - ), - }, - items: [ - { - xtype: 'radiofield', - name: 'unprivileged', - inputValue: 'keep', - boxLabel: gettext('From Backup'), - flex: 1, - checked: true, - }, - { - xtype: 'radiofield', - name: 'unprivileged', - inputValue: '1', - boxLabel: gettext('Unprivileged'), - flex: 1, - }, - { - xtype: 'radiofield', - name: 'unprivileged', - inputValue: '0', - boxLabel: gettext('Privileged'), - flex: 1, - //margin: '0 0 0 10', - }, - ], - }); - } else if (me.vmtype === 'qemu') { - items.push( - { - xtype: 'proxmoxcheckbox', - name: 'live-restore', - itemId: 'liveRestore', - flex: 1, - fieldLabel: gettext('Live restore'), - checked: false, - hidden: !me.isPBS, - }, - { - xtype: 'displayfield', - reference: 'liveWarning', - // TODO: Remove once more tested/stable? - value: gettext( - 'Note: If anything goes wrong during the live-restore, new data written by the VM may be lost.', - ), - userCls: 'pmx-hint', - hidden: true, - }, - ); - } - - items.push({ - xtype: 'fieldset', - title: `${gettext('Override Settings')}:`, - layout: 'hbox', - defaults: { - border: false, - layout: 'anchor', - flex: 1, - }, - items: [ - { - padding: '0 10 0 0', - items: [ - { - xtype: 'textfield', - fieldLabel: me.vmtype === 'lxc' ? gettext('Hostname') : gettext('Name'), - name: 'name', - vtype: 'DnsName', - reference: 'nameField', - allowBlank: true, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: gettext('Cores'), - name: 'cores', - reference: 'coresField', - minValue: 1, - maxValue: 128, - allowBlank: true, - }, - ], - }, - { - padding: '0 0 0 10', - items: [ - { - xtype: 'pveMemoryField', - fieldLabel: gettext('Memory'), - name: 'memory', - reference: 'memoryField', - value: '', - allowBlank: true, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: gettext('Sockets'), - name: 'sockets', - reference: 'socketsField', - minValue: 1, - maxValue: 4, - allowBlank: true, - hidden: me.vmtype !== 'qemu', - disabled: me.vmtype !== 'qemu', - }, - ], - }, - ], - }); - - let title = gettext('Restore') + ': ' + (me.vmtype === 'lxc' ? 'CT' : 'VM'); - if (me.vmid) { - let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier( - me.vmid, - me.vmname, - ); - title = `${gettext('Overwrite')} ${title} ${formattedGuestIdentifier}`; - } - - Ext.apply(me, { - title: title, - items: [ - { - xtype: 'form', - bodyPadding: 10, - border: false, - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - items: items, - }, - ], - buttons: [ - { - text: gettext('Restore'), - reference: 'doRestoreBtn', - handler: 'doRestore', - }, - ], - }); - - me.callParent(); - }, -}); -/* - * SafeDestroy window with additional checkboxes for removing guests - */ -Ext.define('PVE.window.SafeDestroyGuest', { - extend: 'Proxmox.window.SafeDestroy', - alias: 'widget.pveSafeDestroyGuest', - - additionalItems: [ - { - xtype: 'proxmoxcheckbox', - name: 'purge', - reference: 'purgeCheckbox', - boxLabel: gettext('Purge from job configurations'), - checked: false, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Remove from replication, HA and backup jobs'), - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'destroyUnreferenced', - reference: 'destroyUnreferencedCheckbox', - boxLabel: gettext('Destroy unreferenced disks owned by guest'), - checked: false, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Scan all enabled storages for unreferenced disks and delete them.', - ), - }, - }, - ], - - note: gettext('Referenced disks will always be destroyed.'), - - getParams: function () { - let me = this; - - const purgeCheckbox = me.lookupReference('purgeCheckbox'); - me.params.purge = purgeCheckbox.checked ? 1 : 0; - - const destroyUnreferencedCheckbox = me.lookupReference('destroyUnreferencedCheckbox'); - me.params['destroy-unreferenced-disks'] = destroyUnreferencedCheckbox.checked ? 1 : 0; - - return me.callParent(); - }, -}); -/* - * SafeDestroy window with additional checkboxes for removing a storage on the disk level. - */ -Ext.define('PVE.window.SafeDestroyStorage', { - extend: 'Proxmox.window.SafeDestroy', - alias: 'widget.pveSafeDestroyStorage', - - showProgress: true, - - additionalItems: [ - { - xtype: 'proxmoxcheckbox', - name: 'wipeDisks', - reference: 'wipeDisksCheckbox', - boxLabel: gettext('Cleanup Disks'), - checked: true, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Wipe labels and other left-overs'), - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'cleanupConfig', - reference: 'cleanupConfigCheckbox', - boxLabel: gettext('Cleanup Storage Configuration'), - checked: true, - }, - ], - - getParams: function () { - let me = this; - - me.params['cleanup-disks'] = me.lookupReference('wipeDisksCheckbox').checked ? 1 : 0; - me.params['cleanup-config'] = me.lookupReference('cleanupConfigCheckbox').checked ? 1 : 0; - - return me.callParent(); - }, -}); -Ext.define('PVE.window.Settings', { - extend: 'Ext.window.Window', - - width: '800px', - title: gettext('My Settings'), - iconCls: 'fa fa-gear', - modal: true, - bodyPadding: 10, - resizable: false, - - buttons: [ - { - xtype: 'proxmoxHelpButton', - onlineHelp: 'gui_my_settings', - hidden: false, - }, - '->', - { - text: gettext('Close'), - handler: function () { - this.up('window').close(); - }, - }, - ], - - layout: 'hbox', - - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - var me = this; - var sp = Ext.state.Manager.getProvider(); - - var username = sp.get('login-username') || Proxmox.Utils.noneText; - me.lookupReference('savedUserName').setValue(Ext.String.htmlEncode(username)); - var vncMode = sp.get('novnc-scaling') || 'auto'; - me.lookupReference('noVNCScalingGroup').setValue({ noVNCScalingField: vncMode }); - - let summarycolumns = sp.get('summarycolumns', 'auto'); - me.lookup('summarycolumns').setValue(summarycolumns); - - me.lookup('guestNotesCollapse').setValue(sp.get('guest-notes-collapse', 'never')); - me.lookup('editNotesOnDoubleClick').setValue( - sp.get('edit-notes-on-double-click', false), - ); - - var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight']; - settings.forEach(function (setting) { - var val = localStorage.getItem('pve-xterm-' + setting); - if (val !== undefined && val !== null) { - let field = me.lookup(setting); - field.setValue(val); - field.resetOriginalValue(); - } - }); - }, - - set_button_status: function () { - let me = this; - let form = me.lookup('xtermform'); - - let valid = form.isValid(), - dirty = form.isDirty(); - let hasValues = Object.values(form.getValues()).some((v) => !!v); - - me.lookup('xtermsave').setDisabled(!dirty || !valid); - me.lookup('xtermreset').setDisabled(!hasValues); - }, - - control: { - '#xtermjs form': { - dirtychange: 'set_button_status', - validitychange: 'set_button_status', - }, - '#xtermjs button': { - click: function (button) { - var me = this; - var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight']; - settings.forEach(function (setting) { - var field = me.lookup(setting); - if (button.reference === 'xtermsave') { - let value = field.getValue(); - if (value) { - localStorage.setItem('pve-xterm-' + setting, value); - } else { - localStorage.removeItem('pve-xterm-' + setting); - } - } else if (button.reference === 'xtermreset') { - field.setValue(undefined); - localStorage.removeItem('pve-xterm-' + setting); - } - field.resetOriginalValue(); - }); - me.set_button_status(); - }, - }, - 'button[name=reset]': { - click: function () { - let blacklist = ['GuiCap', 'login-username', 'dash-storages']; - let sp = Ext.state.Manager.getProvider(); - for (const state of Object.keys(sp.state)) { - if (!blacklist.includes(state)) { - sp.clear(state); - } - } - window.location.reload(); - }, - }, - 'button[name=clear-username]': { - click: function () { - let me = this; - me.lookupReference('savedUserName').setValue(Proxmox.Utils.noneText); - Ext.state.Manager.getProvider().clear('login-username'); - }, - }, - 'grid[reference=dashboard-storages]': { - selectionchange: function (grid, selected) { - var _me = this; - var sp = Ext.state.Manager.getProvider(); - - // saves the selected storageids as "id1,id2,id3,..." or clears the variable - if (selected.length > 0) { - sp.set('dash-storages', Ext.Array.pluck(selected, 'id').join(',')); - } else { - sp.clear('dash-storages'); - } - }, - afterrender: function (grid) { - let store = grid.getStore(); - let storages = Ext.state.Manager.getProvider().get('dash-storages') || ''; - - let items = []; - storages.split(',').forEach((storage) => { - if (storage !== '') { - // we have to get the records to be able to select them - let item = store.getById(storage); - if (item) { - items.push(item); - } - } - }); - grid.suspendEvent('selectionchange'); - grid.getSelectionModel().select(items); - grid.resumeEvent('selectionchange'); - }, - }, - 'field[reference=summarycolumns]': { - change: (el, newValue) => - Ext.state.Manager.getProvider().set('summarycolumns', newValue), - }, - 'field[reference=guestNotesCollapse]': { - change: (e, v) => Ext.state.Manager.getProvider().set('guest-notes-collapse', v), - }, - 'field[reference=editNotesOnDoubleClick]': { - change: (e, v) => - Ext.state.Manager.getProvider().set('edit-notes-on-double-click', v), - }, - }, - }, - - items: [ - { - xtype: 'fieldset', - flex: 1, - title: gettext('Webinterface Settings'), - margin: '5', - layout: { - type: 'vbox', - align: 'left', - }, - defaults: { - width: '100%', - margin: '0 0 10 0', - }, - items: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Dashboard Storages'), - labelAlign: 'left', - labelWidth: '50%', - }, - { - xtype: 'grid', - maxHeight: 150, - reference: 'dashboard-storages', - selModel: { - selType: 'checkboxmodel', - }, - columns: [ - { - header: gettext('Name'), - dataIndex: 'storage', - flex: 1, - }, - { - header: gettext('Node'), - dataIndex: 'node', - flex: 1, - }, - ], - store: { - type: 'diff', - field: ['type', 'storage', 'id', 'node'], - rstore: PVE.data.ResourceStore, - filters: [ - { - property: 'type', - value: 'storage', - }, - ], - sorters: ['node', 'storage'], - }, - }, - { - xtype: 'box', - autoEl: { tag: 'hr' }, - }, - { - xtype: 'container', - layout: 'hbox', - items: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Saved User Name') + ':', - labelWidth: 150, - stateId: 'login-username', - reference: 'savedUserName', - flex: 1, - value: '', - }, - { - xtype: 'button', - cls: 'x-btn-default-toolbar-small proxmox-inline-button', - text: gettext('Reset'), - name: 'clear-username', - }, - ], - }, - { - xtype: 'box', - autoEl: { tag: 'hr' }, - }, - { - xtype: 'container', - layout: 'hbox', - items: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Layout') + ':', - flex: 1, - }, - { - xtype: 'button', - cls: 'x-btn-default-toolbar-small proxmox-inline-button', - text: gettext('Reset'), - tooltip: gettext( - 'Reset all layout changes (for example, column widths)', - ), - name: 'reset', - }, - ], - }, - { - xtype: 'box', - autoEl: { tag: 'hr' }, - }, - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('Summary columns') + ':', - labelWidth: 125, - stateId: 'summarycolumns', - reference: 'summarycolumns', - comboItems: [ - ['auto', 'auto'], - ['1', '1'], - ['2', '2'], - ['3', '3'], - ], - }, - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('Guest Notes') + ':', - labelWidth: 125, - stateId: 'guest-notes-collapse', - reference: 'guestNotesCollapse', - comboItems: [ - ['never', 'Show by default'], - ['always', 'Collapse by default'], - ['auto', 'auto (Collapse if empty)'], - ], - }, - { - xtype: 'checkbox', - fieldLabel: gettext('Notes'), - labelWidth: 125, - boxLabel: gettext('Open editor on double-click'), - reference: 'editNotesOnDoubleClick', - inputValue: true, - uncheckedValue: false, - }, - ], - }, - { - xtype: 'container', - layout: 'vbox', - flex: 1, - margin: '5', - defaults: { - width: '100%', - // right margin ensures that the right border of the fieldsets - // is shown - margin: '0 2 10 0', - }, - items: [ - { - xtype: 'fieldset', - itemId: 'xtermjs', - title: gettext('xterm.js Settings'), - items: [ - { - xtype: 'form', - reference: 'xtermform', - border: false, - layout: { - type: 'vbox', - algin: 'left', - }, - defaults: { - width: '100%', - margin: '0 0 10 0', - }, - items: [ - { - xtype: 'textfield', - name: 'fontFamily', - reference: 'fontFamily', - emptyText: Proxmox.Utils.defaultText, - fieldLabel: gettext('Font-Family'), - }, - { - xtype: 'proxmoxintegerfield', - emptyText: Proxmox.Utils.defaultText, - name: 'fontSize', - reference: 'fontSize', - minValue: 1, - fieldLabel: gettext('Font-Size'), - }, - { - xtype: 'numberfield', - name: 'letterSpacing', - reference: 'letterSpacing', - emptyText: Proxmox.Utils.defaultText, - fieldLabel: gettext('Letter Spacing'), - }, - { - xtype: 'numberfield', - name: 'lineHeight', - minValue: 0.1, - reference: 'lineHeight', - emptyText: Proxmox.Utils.defaultText, - fieldLabel: gettext('Line Height'), - }, - { - xtype: 'container', - layout: { - type: 'hbox', - pack: 'end', - }, - defaults: { - margin: '0 0 0 5', - }, - items: [ - { - xtype: 'button', - reference: 'xtermreset', - disabled: true, - text: gettext('Reset'), - }, - { - xtype: 'button', - reference: 'xtermsave', - disabled: true, - text: gettext('Save'), - }, - ], - }, - ], - }, - ], - }, - { - xtype: 'fieldset', - title: gettext('noVNC Settings'), - items: [ - { - xtype: 'radiogroup', - fieldLabel: gettext('Scaling mode'), - reference: 'noVNCScalingGroup', - height: '15px', // renders faster with value assigned - layout: { - type: 'hbox', - }, - items: [ - { - xtype: 'radiofield', - name: 'noVNCScalingField', - inputValue: 'auto', - boxLabel: 'Auto', - }, - { - xtype: 'radiofield', - name: 'noVNCScalingField', - inputValue: 'scale', - boxLabel: 'Local Scaling', - margin: '0 0 0 10', - }, - { - xtype: 'radiofield', - name: 'noVNCScalingField', - inputValue: 'off', - boxLabel: 'Off', - margin: '0 0 0 10', - }, - ], - listeners: { - change: function (el, { noVNCScalingField }) { - let provider = Ext.state.Manager.getProvider(); - if (noVNCScalingField === 'auto') { - provider.clear('novnc-scaling'); - } else { - provider.set('novnc-scaling', noVNCScalingField); - } - }, - }, - }, - ], - }, - ], - }, - ], -}); -Ext.define('PVE.window.Snapshot', { - extend: 'Proxmox.window.Edit', - - viewModel: { - data: { - type: undefined, - isCreate: undefined, - running: false, - guestAgentEnabled: false, - }, - formulas: { - runningWithoutGuestAgent: (get) => - get('type') === 'qemu' && get('running') && !get('guestAgentEnabled'), - shouldWarnAboutFS: (get) => - get('isCreate') && get('runningWithoutGuestAgent') && get('!vmstate.checked'), - }, - }, - - onGetValues: function (values) { - let me = this; - - if (me.type === 'lxc') { - delete values.vmstate; - } - - return values; - }, - - initComponent: function () { - var me = this; - var vm = me.getViewModel(); - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - if (!me.type) { - throw 'no type specified'; - } - - vm.set('type', me.type); - vm.set('running', me.running); - vm.set('isCreate', me.isCreate); - - if (me.type === 'qemu' && me.isCreate) { - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/${me.type}/${me.vmid}/config`, - params: { current: '1' }, - method: 'GET', - success: function (response, options) { - let res = response.result.data; - let enabled = PVE.Parser.parsePropertyString(res.agent, 'enabled'); - vm.set('guestAgentEnabled', !!PVE.Parser.parseBoolean(enabled.enabled)); - }, - }); - } - - me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'snapname', - value: me.snapname, - fieldLabel: gettext('Name'), - vtype: 'ConfigId', - allowBlank: false, - }, - { - xtype: 'displayfield', - hidden: me.isCreate, - disabled: me.isCreate, - name: 'snaptime', - renderer: PVE.Utils.render_timestamp_human_readable, - fieldLabel: gettext('Timestamp'), - }, - { - xtype: 'proxmoxcheckbox', - hidden: me.type !== 'qemu' || !me.isCreate || !me.running, - disabled: me.type !== 'qemu' || !me.isCreate || !me.running, - name: 'vmstate', - reference: 'vmstate', - uncheckedValue: 0, - defaultValue: 0, - checked: 1, - fieldLabel: gettext('Include RAM'), - }, - { - xtype: 'textareafield', - grow: true, - editable: !me.viewonly, - name: 'description', - fieldLabel: gettext('Description'), - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - name: 'fswarning', - hidden: true, - value: gettext( - 'It is recommended to either include the RAM or use the QEMU Guest Agent when taking a snapshot of a running VM to avoid inconsistencies.', - ), - bind: { - hidden: '{!shouldWarnAboutFS}', - }, - }, - { - title: gettext('Settings'), - hidden: me.isCreate, - xtype: 'grid', - itemId: 'summary', - border: true, - height: 200, - store: { - model: 'KeyValue', - sorters: [ - { - property: 'key', - direction: 'ASC', - }, - ], - }, - columns: [ - { - header: gettext('Key'), - width: 150, - dataIndex: 'key', - }, - { - header: gettext('Value'), - flex: 1, - dataIndex: 'value', - }, - ], - }, - ]; - - me.url = `/nodes/${me.nodename}/${me.type}/${me.vmid}/snapshot`; - - let subject; - if (me.isCreate) { - let guestTypeStr = me.type === 'qemu' ? 'VM' : 'CT'; - let formattedGuestIdentifier = PVE.Utils.getFormattedGuestIdentifier( - me.vmid, - me.vmname, - ); - subject = `${guestTypeStr} ${formattedGuestIdentifier} ${gettext('Snapshot')}`; - me.method = 'POST'; - me.showTaskViewer = true; - } else { - subject = `${gettext('Snapshot')} ${me.snapname}`; - me.url += `/${me.snapname}/config`; - } - - Ext.apply(me, { - subject: subject, - width: me.isCreate ? 450 : 620, - height: me.isCreate ? undefined : 420, - }); - - me.callParent(); - - if (!me.snapname) { - return; - } - - me.load({ - success: function (response) { - let kvarray = []; - Ext.Object.each(response.result.data, function (key, value) { - if (key === 'description' || key === 'snaptime') { - return; - } - kvarray.push({ key: key, value: value }); - }); - - let summarystore = me.down('#summary').getStore(); - summarystore.suspendEvents(); - summarystore.add(kvarray); - summarystore.sort(); - summarystore.resumeEvents(); - summarystore.fireEvent('refresh', summarystore); - - me.setValues(response.result.data); - }, - }); - }, -}); -Ext.define('PVE.panel.StartupInputPanel', { - extend: 'Proxmox.panel.InputPanel', - onlineHelp: 'qm_startup_and_shutdown', - - onGetValues: function (values) { - var _me = this; - - var res = PVE.Parser.printStartup(values); - - if (res === undefined || res === '') { - return { delete: 'startup' }; - } - - return { startup: res }; - }, - - setStartup: function (value) { - var me = this; - - var startup = PVE.Parser.parseStartup(value); - if (startup) { - me.setValues(startup); - } - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'textfield', - name: 'order', - defaultValue: '', - emptyText: 'any', - fieldLabel: gettext('Start/Shutdown order'), - }, - { - xtype: 'textfield', - name: 'up', - defaultValue: '', - emptyText: 'default', - fieldLabel: gettext('Startup delay'), - }, - { - xtype: 'textfield', - name: 'down', - defaultValue: '', - emptyText: 'default', - fieldLabel: gettext('Shutdown timeout'), - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.window.StartupEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveWindowStartupEdit', - onlineHelp: undefined, - - initComponent: function () { - let me = this; - - let ipanelConfig = me.onlineHelp ? { onlineHelp: me.onlineHelp } : {}; - let ipanel = Ext.create('PVE.panel.StartupInputPanel', ipanelConfig); - - Ext.applyIf(me, { - subject: gettext('Start/Shutdown order'), - fieldDefaults: { - labelWidth: 120, - }, - items: [ipanel], - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - me.vmconfig = response.result.data; - ipanel.setStartup(me.vmconfig.startup); - }, - }); - }, -}); -Ext.define('PVE.window.DownloadUrlToStorage', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveStorageDownloadUrl', - mixins: ['Proxmox.Mixin.CBind'], - - isCreate: true, - - method: 'POST', - - showTaskViewer: true, - - title: gettext('Download from URL'), - submitText: gettext('Download'), - - cbindData: function (initialConfig) { - var me = this; - return { - nodename: me.nodename, - storage: me.storage, - content: me.content, - }; - }, - - cbind: { - url: '/nodes/{nodename}/storage/{storage}/download-url', - }, - - viewModel: { - data: { - size: '-', - mimetype: '-', - enableQuery: true, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - urlChange: function (field) { - this.resetMetaInfo(); - this.setQueryEnabled(); - }, - setQueryEnabled: function () { - this.getViewModel().set('enableQuery', true); - }, - resetMetaInfo: function () { - let vm = this.getViewModel(); - vm.set('size', '-'); - vm.set('mimetype', '-'); - }, - - urlCheck: function (field) { - let me = this; - let view = me.getView(); - - const queryParam = view.getValues(); - - me.getViewModel().set('enableQuery', false); - me.resetMetaInfo(); - let urlField = view.down('[name=url]'); - - Proxmox.Utils.API2Request({ - url: `/nodes/${view.nodename}/query-url-metadata`, - method: 'GET', - params: { - url: queryParam.url, - 'verify-certificates': queryParam['verify-certificates'], - }, - waitMsgTarget: view, - failure: (res) => { - urlField.setValidation(res.result.message); - urlField.validate(); - Ext.MessageBox.alert(gettext('Error'), res.htmlStatus); - // re-enable so one can directly requery, e.g., if it was just a network hiccup - me.setQueryEnabled(); - }, - success: function (res, opt) { - urlField.setValidation(); - urlField.validate(); - - let data = res.result.data; - - let filename = data.filename || ''; - let compression = '__default__'; - if (view.content === 'iso') { - const matches = filename.match(/^(.+)\.(gz|lzo|zst|bz2)$/i); - if (matches) { - filename = matches[1]; - compression = matches[2].toLowerCase(); - } - } else if (view.content === 'import') { - if (filename.endsWith('.img')) { - filename += '.raw'; - } - } - - view.setValues({ - filename, - compression, - size: - (data.size && Proxmox.Utils.format_size(data.size)) || - gettext('Unknown'), - mimetype: data.mimetype || gettext('Unknown'), - }); - }, - }); - }, - - hashChange: function (field) { - let checksum = Ext.getCmp('downloadUrlChecksum'); - if (field.getValue() === '__default__') { - checksum.setDisabled(true); - checksum.setValue(''); - checksum.allowBlank = true; - } else { - checksum.setDisabled(false); - checksum.allowBlank = false; - } - }, - }, - - items: [ - { - xtype: 'inputpanel', - border: false, - onGetValues: function (values) { - if (typeof values.checksum === 'string') { - values.checksum = values.checksum.trim(); - } - return values; - }, - columnT: [ - { - xtype: 'fieldcontainer', - layout: 'hbox', - fieldLabel: gettext('URL'), - items: [ - { - xtype: 'textfield', - name: 'url', - emptyText: gettext('Enter URL to download'), - allowBlank: false, - flex: 1, - listeners: { - change: 'urlChange', - }, - }, - { - xtype: 'button', - name: 'check', - text: gettext('Query URL'), - margin: '0 0 0 5', - bind: { - disabled: '{!enableQuery}', - }, - listeners: { - click: 'urlCheck', - }, - }, - ], - }, - { - xtype: 'textfield', - name: 'filename', - allowBlank: false, - fieldLabel: gettext('File name'), - emptyText: gettext('Please (re-)query URL to get meta information'), - }, - ], - column1: [ - { - xtype: 'displayfield', - name: 'size', - fieldLabel: gettext('File size'), - bind: { - value: '{size}', - }, - }, - ], - column2: [ - { - xtype: 'displayfield', - name: 'mimetype', - fieldLabel: gettext('MIME type'), - bind: { - value: '{mimetype}', - }, - }, - ], - advancedColumn1: [ - { - xtype: 'pveHashAlgorithmSelector', - name: 'checksum-algorithm', - fieldLabel: gettext('Hash algorithm'), - allowBlank: true, - hasNoneOption: true, - value: '__default__', - listeners: { - change: 'hashChange', - }, - }, - { - xtype: 'textfield', - name: 'checksum', - fieldLabel: gettext('Checksum'), - allowBlank: true, - disabled: true, - emptyText: gettext('none'), - id: 'downloadUrlChecksum', - }, - ], - advancedColumn2: [ - { - xtype: 'proxmoxcheckbox', - name: 'verify-certificates', - fieldLabel: gettext('Verify certificates'), - uncheckedValue: 0, - checked: true, - listeners: { - change: 'setQueryEnabled', - }, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'compression', - fieldLabel: gettext('Decompression algorithm'), - allowBlank: true, - hasNoneOption: true, - deleteEmpty: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.NoneText], - ['lzo', 'LZO'], - ['gz', 'GZIP'], - ['zst', 'ZSTD'], - ['bz2', 'BZIP2'], - ], - cbind: { - hidden: (get) => get('content') !== 'iso', - }, - }, - ], - }, - { - xtype: 'hiddenfield', - name: 'content', - cbind: { - value: '{content}', - }, - }, - ], - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - if (!me.storage) { - throw 'no storage ID specified'; - } - me.callParent(); - }, -}); -Ext.define('PVE.window.UploadToStorage', { - extend: 'Ext.window.Window', - alias: 'widget.pveStorageUpload', - mixins: ['Proxmox.Mixin.CBind'], - - resizable: false, - modal: true, - - title: gettext('Upload'), - - acceptedExtensions: { - import: ['.ova', '.qcow2', '.raw', '.vmdk'], - iso: ['.img', '.iso'], - vztmpl: ['.tar.gz', '.tar.xz', '.tar.zst'], - }, - - // accepted for file selection, will be renamed to real extension - extensionAliases: { - import: { - '.img': '.raw', - }, - }, - - cbindData: function (initialConfig) { - const me = this; - const ext = me.acceptedExtensions[me.content] || []; - - me.url = `/nodes/${me.nodename}/storage/${me.storage}/upload`; - - let fileSelectorExt = ext.concat(Object.keys(me.extensionAliases[me.content] ?? {})); - - return { - extensions: fileSelectorExt.join(', '), - filenameRegex: new RegExp('^.*(?:' + ext.join('|').replaceAll('.', '\\.') + ')$', 'i'), - }; - }, - - viewModel: { - data: { - size: '-', - mimetype: '-', - filename: '', - }, - }, - - controller: { - submit: function (button) { - const view = this.getView(); - const form = this.lookup('formPanel').getForm(); - const abortBtn = this.lookup('abortBtn'); - const pbar = this.lookup('progressBar'); - - const updateProgress = function (per, bytes) { - let text = (per * 100).toFixed(2) + '%'; - if (bytes) { - text += ' (' + Proxmox.Utils.format_size(bytes) + ')'; - } - pbar.updateProgress(per, text); - }; - - const fd = new FormData(); - - button.setDisabled(true); - abortBtn.setDisabled(false); - - fd.append('content', view.content); - - const fileField = form.findField('file'); - const file = fileField.fileInputEl.dom.files[0]; - fileField.setDisabled(true); - - const filenameField = form.findField('filename'); - const filename = filenameField.getValue(); - filenameField.setDisabled(true); - - const algorithmField = form.findField('checksum-algorithm'); - algorithmField.setDisabled(true); - if (algorithmField.getValue() !== '__default__') { - fd.append('checksum-algorithm', algorithmField.getValue()); - - const checksumField = form.findField('checksum'); - fd.append('checksum', checksumField.getValue()?.trim()); - checksumField.setDisabled(true); - } - - fd.append('filename', file, filename); - - pbar.setVisible(true); - updateProgress(0); - - const xhr = new XMLHttpRequest(); - view.xhr = xhr; - - xhr.addEventListener( - 'load', - function (e) { - if (xhr.status === 200) { - view.hide(); - - const result = JSON.parse(xhr.response); - const upid = result.data; - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: upid, - taskDone: view.taskDone, - listeners: { - destroy: function () { - view.close(); - }, - }, - }); - - return; - } - const err = Ext.htmlEncode(xhr.statusText); - let msg = `${gettext('Error')} ${xhr.status.toString()}: ${err}`; - if (xhr.responseText !== '') { - const result = Ext.decode(xhr.responseText); - result.message = msg; - msg = Proxmox.Utils.extractRequestError(result, true); - } - Ext.Msg.alert(gettext('Error'), msg, (btn) => view.close()); - }, - false, - ); - - xhr.addEventListener('error', function (e) { - const err = e.target.status.toString(); - const msg = `Error '${err}' occurred while receiving the document.`; - Ext.Msg.alert(gettext('Error'), msg, (btn) => view.close()); - }); - - xhr.upload.addEventListener( - 'progress', - function (evt) { - if (evt.lengthComputable) { - const percentComplete = evt.loaded / evt.total; - updateProgress(percentComplete, evt.loaded); - } - }, - false, - ); - - xhr.open('POST', `/api2/json${view.url}`, true); - xhr.send(fd); - }, - - validitychange: function (f, valid) { - const submitBtn = this.lookup('submitBtn'); - submitBtn.setDisabled(!valid); - }, - - fileChange: function (input) { - const me = this; - const vm = me.getViewModel(); - const view = me.getView(); - let name = input.value.replace(/^.*(\/|\\)/, ''); - for (const [alias, real] of Object.entries(view.extensionAliases[view.content] ?? {})) { - if (name.endsWith(alias)) { - name += real; - } - } - const fileInput = input.fileInputEl.dom; - vm.set('filename', name); - vm.set( - 'size', - (fileInput.files[0] && Proxmox.Utils.format_size(fileInput.files[0].size)) || '-', - ); - vm.set('mimetype', (fileInput.files[0] && fileInput.files[0].type) || '-'); - }, - - hashChange: function (field, value) { - const checksum = this.lookup('downloadUrlChecksum'); - if (value === '__default__') { - checksum.setDisabled(true); - checksum.setValue(''); - } else { - checksum.setDisabled(false); - } - }, - }, - - items: [ - { - xtype: 'form', - reference: 'formPanel', - method: 'POST', - waitMsgTarget: true, - bodyPadding: 10, - border: false, - width: 400, - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - items: [ - { - xtype: 'filefield', - name: 'file', - buttonText: gettext('Select File'), - allowBlank: false, - fieldLabel: gettext('File'), - cbind: { - accept: '{extensions}', - }, - listeners: { - change: 'fileChange', - }, - }, - { - xtype: 'textfield', - name: 'filename', - allowBlank: false, - fieldLabel: gettext('File name'), - bind: { - value: '{filename}', - }, - cbind: { - regex: '{filenameRegex}', - }, - regexText: gettext('Wrong file extension'), - }, - { - xtype: 'displayfield', - name: 'size', - fieldLabel: gettext('File size'), - bind: { - value: '{size}', - }, - }, - { - xtype: 'displayfield', - name: 'mimetype', - fieldLabel: gettext('MIME type'), - bind: { - value: '{mimetype}', - }, - }, - { - xtype: 'pveHashAlgorithmSelector', - name: 'checksum-algorithm', - fieldLabel: gettext('Hash algorithm'), - allowBlank: true, - hasNoneOption: true, - value: '__default__', - listeners: { - change: 'hashChange', - }, - }, - { - xtype: 'textfield', - name: 'checksum', - fieldLabel: gettext('Checksum'), - allowBlank: false, - disabled: true, - emptyText: gettext('none'), - reference: 'downloadUrlChecksum', - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - "Uploads are stored temporarily in '/var/tmp/', make sure there is enough free space.", - ), - }, - { - xtype: 'progressbar', - text: 'Ready', - hidden: true, - reference: 'progressBar', - }, - { - xtype: 'hiddenfield', - name: 'content', - cbind: { - value: '{content}', - }, - }, - ], - listeners: { - validitychange: 'validitychange', - }, - }, - ], - - buttons: [ - { - xtype: 'button', - text: gettext('Abort'), - reference: 'abortBtn', - disabled: true, - handler: function () { - const me = this; - me.up('pveStorageUpload').close(); - }, - }, - { - text: gettext('Upload'), - reference: 'submitBtn', - disabled: true, - handler: 'submit', - }, - ], - - listeners: { - close: function () { - const me = this; - if (me.xhr) { - me.xhr.abort(); - delete me.xhr; - } - }, - }, - - initComponent: function () { - const me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - if (!me.storage) { - throw 'no storage ID specified'; - } - if (!me.acceptedExtensions[me.content]) { - throw 'content type not supported'; - } - - me.callParent(); - }, -}); -Ext.define('PVE.window.ScheduleSimulator', { - extend: 'Ext.window.Window', - - title: gettext('Job Schedule Simulator'), - - viewModel: { - data: { - simulatedOnce: false, - }, - formulas: { - gridEmptyText: (get) => - get('simulatedOnce') ? Proxmox.Utils.NoneText : gettext('No simulation done'), - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - close: function () { - this.getView().close(); - }, - simulate: function () { - let me = this; - let schedule = me.lookup('schedule').getValue(); - if (!schedule) { - return; - } - let iterations = me.lookup('iterations').getValue() || 10; - Proxmox.Utils.API2Request({ - url: '/cluster/jobs/schedule-analyze', - method: 'GET', - params: { - schedule, - iterations, - }, - failure: (response) => { - me.getViewModel().set('simulatedOnce', true); - me.lookup('grid').getStore().setData([]); - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response) { - let schedules = response.result.data; - me.lookup('grid').getStore().setData(schedules); - me.getViewModel().set('simulatedOnce', true); - }, - }); - }, - - scheduleChanged: function (field, value) { - this.lookup('simulateBtn').setDisabled(!value); - }, - - renderDate: function (value) { - let date = new Date(value * 1000); - return date.toLocaleDateString(); - }, - - renderTime: function (value) { - let date = new Date(value * 1000); - return date.toLocaleTimeString(); - }, - - init: function (view) { - let me = this; - if (view.schedule) { - me.lookup('schedule').setValue(view.schedule); - } - }, - }, - - bodyPadding: 10, - modal: true, - resizable: false, - width: 600, - - layout: 'fit', - - items: [ - { - xtype: 'inputpanel', - column1: [ - { - xtype: 'pveCalendarEvent', - reference: 'schedule', - fieldLabel: gettext('Schedule'), - listeners: { - change: 'scheduleChanged', - }, - }, - { - xtype: 'proxmoxintegerfield', - reference: 'iterations', - fieldLabel: gettext('Iterations'), - minValue: 1, - maxValue: 100, - value: 10, - }, - { - xtype: 'container', - layout: 'hbox', - items: [ - { - xtype: 'box', - flex: 1, - }, - { - xtype: 'button', - reference: 'simulateBtn', - text: gettext('Simulate'), - handler: 'simulate', - disabled: true, - }, - ], - }, - ], - - column2: [ - { - xtype: 'grid', - reference: 'grid', - bind: { - emptyText: '{gridEmptyText}', - }, - scrollable: true, - height: 300, - columns: [ - { - text: gettext('Date'), - renderer: 'renderDate', - dataIndex: 'timestamp', - flex: 1, - }, - { - text: gettext('Time'), - renderer: 'renderTime', - dataIndex: 'timestamp', - align: 'right', - flex: 1, - }, - ], - store: { - fields: ['timestamp'], - data: [], - sorter: 'timestamp', - }, - }, - ], - }, - ], - - buttons: [ - { - text: gettext('Done'), - handler: 'close', - }, - ], -}); -Ext.define('PVE.window.Wizard', { - extend: 'Ext.window.Window', - - activeTitle: '', // used for automated testing - - width: 720, - height: 540, - - modal: true, - border: false, - - draggable: true, - closable: true, - resizable: false, - - layout: 'border', - - getValues: function (dirtyOnly) { - let me = this; - - let values = {}; - - me.down('form') - .getForm() - .getFields() - .each((field) => { - if (!field.up('inputpanel') && (!dirtyOnly || field.isDirty())) { - Proxmox.Utils.assemble_field_data(values, field.getSubmitData()); - } - }); - - me.query('inputpanel').forEach((panel) => { - Proxmox.Utils.assemble_field_data(values, panel.getValues(dirtyOnly)); - }); - - return values; - }, - - initComponent: function () { - var me = this; - - var tabs = me.items || []; - delete me.items; - - /* - * Items may have the following functions: - * validator(): per tab custom validation - * onSubmit(): submit handler - * onGetValues(): overwrite getValues results - */ - - Ext.Array.each(tabs, function (tab) { - tab.disabled = true; - }); - tabs[0].disabled = false; - - let maxidx = 0, - curidx = 0; - - let check_card = function (card) { - let fields = card.query('field, fieldcontainer'); - if (card.isXType('fieldcontainer')) { - fields.unshift(card); - } - let valid = true; - for (const field of fields) { - // Note: not all fielcontainer have isValid() - if (Ext.isFunction(field.isValid) && !field.isValid()) { - valid = false; - } - } - if (Ext.isFunction(card.validator)) { - return card.validator(); - } - return valid; - }; - - let disableTab = function (card) { - let tp = me.down('#wizcontent'); - for (let idx = tp.items.indexOf(card); idx < tp.items.getCount(); idx++) { - let tab = tp.items.getAt(idx); - if (tab) { - tab.disable(); - } - } - }; - - let tabchange = function (tp, newcard, oldcard) { - if (newcard.onSubmit) { - me.down('#next').setVisible(false); - me.down('#submit').setVisible(true); - } else { - me.down('#next').setVisible(true); - me.down('#submit').setVisible(false); - } - let valid = check_card(newcard); - me.down('#next').setDisabled(!valid); - me.down('#submit').setDisabled(!valid); - me.down('#back').setDisabled(tp.items.indexOf(newcard) === 0); - - let idx = tp.items.indexOf(newcard); - if (idx > maxidx) { - maxidx = idx; - } - curidx = idx; - - let ntab = tp.items.getAt(idx + 1); - if (valid && ntab && !newcard.onSubmit) { - ntab.enable(); - } - }; - - if (me.subject && !me.title) { - me.title = Proxmox.Utils.dialog_title(me.subject, true, false); - } - - let sp = Ext.state.Manager.getProvider(); - let advancedOn = sp.get('proxmox-advanced-cb'); - - Ext.apply(me, { - items: [ - { - xtype: 'form', - region: 'center', - layout: 'fit', - border: false, - margins: '5 5 0 5', - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - items: [ - { - itemId: 'wizcontent', - xtype: 'tabpanel', - activeItem: 0, - bodyPadding: 0, - listeners: { - afterrender: function (tp) { - tabchange(tp, this.getActiveTab()); - }, - tabchange: function (tp, newcard, oldcard) { - tabchange(tp, newcard, oldcard); - }, - }, - defaults: { - padding: 10, - }, - items: tabs, - }, - ], - }, - ], - fbar: [ - { - xtype: 'proxmoxHelpButton', - itemId: 'help', - }, - '->', - { - xtype: 'proxmoxcheckbox', - boxLabelAlign: 'before', - boxLabel: gettext('Advanced'), - value: advancedOn, - listeners: { - change: function (_, value) { - let tp = me.down('#wizcontent'); - tp.query('inputpanel').forEach(function (ip) { - ip.setAdvancedVisible(value); - }); - sp.set('proxmox-advanced-cb', value); - }, - }, - }, - { - text: gettext('Back'), - disabled: true, - itemId: 'back', - minWidth: 60, - handler: function () { - let tp = me.down('#wizcontent'); - let prev = tp.items.indexOf(tp.getActiveTab()) - 1; - if (prev < 0) { - return; - } - let ntab = tp.items.getAt(prev); - if (ntab) { - tp.setActiveTab(ntab); - } - }, - }, - { - text: gettext('Next'), - disabled: true, - itemId: 'next', - minWidth: 60, - handler: function () { - let tp = me.down('#wizcontent'); - let activeTab = tp.getActiveTab(); - if (!check_card(activeTab)) { - return; - } - let next = tp.items.indexOf(activeTab) + 1; - let ntab = tp.items.getAt(next); - if (ntab) { - ntab.enable(); - tp.setActiveTab(ntab); - } - }, - }, - { - text: gettext('Finish'), - minWidth: 60, - hidden: true, - itemId: 'submit', - handler: function () { - let tp = me.down('#wizcontent'); - tp.getActiveTab().onSubmit(); - }, - }, - ], - }); - me.callParent(); - - Ext.Array.each(me.query('inputpanel'), function (panel) { - panel.setAdvancedVisible(advancedOn); - }); - - Ext.Array.each(me.query('field'), function (field) { - let validcheck = function () { - let tp = me.down('#wizcontent'); - - // check validity for current to last enabled tab, as local change may affect validity of a later one - for (let i = curidx; i <= maxidx && i < tp.items.getCount(); i++) { - let tab = tp.items.getAt(i); - let valid = check_card(tab); - - // only set the buttons on the current panel - if (i === curidx) { - me.down('#next').setDisabled(!valid); - me.down('#submit').setDisabled(!valid); - } - // if a panel is invalid, then disable all following, else enable the next tab - let nextTab = tp.items.getAt(i + 1); - if (!valid) { - disableTab(nextTab); - return; - } else if (nextTab && !tab.onSubmit) { - nextTab.enable(); - } - } - }; - field.on('change', validcheck); - field.on('validitychange', validcheck); - }); - }, -}); -Ext.define('PVE.window.GuestDiskReassign', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - resizable: false, - modal: true, - width: 350, - border: false, - layout: 'fit', - showReset: false, - showProgress: true, - method: 'POST', - - viewModel: { - data: { - mpType: '', - }, - formulas: { - mpMaxCount: (get) => - get('mpType') === 'mp' - ? PVE.Utils.lxc_mp_counts.mps - 1 - : PVE.Utils.lxc_mp_counts.unused - 1, - }, - }, - - cbindData: function () { - let me = this; - return { - vmid: me.vmid, - disk: me.disk, - isQemu: me.type === 'qemu', - nodename: me.nodename, - url: () => { - let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume'; - return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`; - }, - }; - }, - - cbind: { - title: (get) => (get('isQemu') ? gettext('Reassign Disk') : gettext('Reassign Volume')), - submitText: (get) => get('title'), - qemu: '{isQemu}', - url: '{url}', - }, - - getValues: function () { - let me = this; - let values = me.formPanel.getForm().getValues(); - - let params = { - vmid: me.vmid, - 'target-vmid': values.targetVmid, - }; - - params[me.qemu ? 'disk' : 'volume'] = me.disk; - - if (me.qemu) { - params['target-disk'] = `${values.controller}${values.deviceid}`; - } else { - params['target-volume'] = `${values.mpType}${values.mpId}`; - } - return params; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - initViewModel: function (model) { - let view = this.getView(); - let mpTypeValue = view.disk.match(/^unused\d+/) ? 'unused' : 'mp'; - model.set('mpType', mpTypeValue); - }, - - onMpTypeChange: function (value) { - let view = this.getView(); - view.getViewModel().set('mpType', value.getValue()); - view.lookup('mpIdSelector').validate(); - }, - - onTargetVMChange: function (f, vmid) { - let me = this; - let view = me.getView(); - let diskSelector = view.lookup('diskSelector'); - if (!vmid) { - diskSelector.setVMConfig(null); - me.VMConfig = null; - return; - } - - let url = `/nodes/${view.nodename}/${view.type}/${vmid}/config`; - Proxmox.Utils.API2Request({ - url: url, - method: 'GET', - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function ({ result }, options) { - if (view.qemu) { - diskSelector.setVMConfig(result.data); - diskSelector.setDisabled(false); - } else { - let mpIdSelector = view.lookup('mpIdSelector'); - let mpType = view.lookup('mpType'); - - view.VMConfig = result.data; - - mpIdSelector.setValue( - PVE.Utils.nextFreeLxcMP( - view.getViewModel().get('mpType'), - view.VMConfig, - ).id, - ); - - mpType.setDisabled(false); - mpIdSelector.setDisabled(false); - mpIdSelector.validate(); - } - }, - }); - }, - }, - - defaultFocus: 'sourceDisk', - items: [ - { - xtype: 'displayfield', - name: 'sourceDisk', - fieldLabel: gettext('Source'), - cbind: { - name: (get) => (get('isQemu') ? 'disk' : 'volume'), - value: '{disk}', - }, - allowBlank: false, - }, - { - xtype: 'vmComboSelector', - name: 'targetVmid', - allowBlank: false, - fieldLabel: gettext('Target Guest'), - store: { - model: 'PVEResources', - autoLoad: true, - sorters: 'vmid', - cbind: {}, // for nested cbinds - filters: [ - { - property: 'type', - cbind: { value: '{type}' }, - }, - { - property: 'node', - cbind: { value: '{nodename}' }, - }, - // FIXME: remove, artificial restriction that doesn't gains us anything.. - { - property: 'vmid', - operator: '!=', - cbind: { value: '{vmid}' }, - }, - { - property: 'template', - value: 0, - }, - ], - }, - listeners: { change: 'onTargetVMChange' }, - }, - { - xtype: 'pveControllerSelector', - reference: 'diskSelector', - withUnused: true, - disabled: true, - cbind: { - hidden: '{!isQemu}', - }, - }, - { - xtype: 'container', - layout: 'hbox', - cbind: { - hidden: '{isQemu}', - disabled: '{isQemu}', - }, - items: [ - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: (get) => !get('disk').match(/^unused\d+/), - value: (get) => (get('disk').match(/^unused\d+/) ? 'unused' : 'mp'), - }, - disabled: true, - name: 'mpType', - reference: 'mpType', - fieldLabel: gettext('Add as'), - submitValue: true, - flex: 4, - editConfig: { - xtype: 'proxmoxKVComboBox', - name: 'mpTypeCombo', - deleteEmpty: false, - cbind: { - hidden: '{isQemu}', - }, - comboItems: [ - ['mp', gettext('Mount Point')], - ['unused', gettext('Unused')], - ], - listeners: { change: 'onMpTypeChange' }, - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'mpId', - reference: 'mpIdSelector', - minValue: 0, - flex: 1, - allowBlank: false, - validateOnChange: true, - disabled: true, - bind: { - maxValue: '{mpMaxCount}', - }, - validator: function (value) { - let view = this.up('window'); - let type = view.getViewModel().get('mpType'); - if (Ext.isDefined(view.VMConfig[`${type}${value}`])) { - return 'Mount point is already in use.'; - } - return true; - }, - }, - ], - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - if (!me.type) { - throw 'no type specified'; - } - - me.callParent(); - }, -}); -Ext.define('PVE.GuestStop', { - extend: 'Ext.window.MessageBox', - - closeAction: 'destroy', - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - if (!me.vm) { - throw 'no vm specified'; - } - - let isQemuVM = me.vm.type === 'qemu'; - let overruleTaskType = isQemuVM ? 'qmshutdown' : 'vzshutdown'; - - me.taskType = isQemuVM ? 'qmstop' : 'vzstop'; - me.url = `/nodes/${me.nodename}/${me.vm.type}/${me.vm.vmid}/status/stop`; - - let caps = Ext.state.Manager.get('GuiCap'); - let hasSysModify = !!caps.nodes['Sys.Modify']; - - // offer to overrule if there is at least one matching shutdown task and the guest is not - // HA-enabled. Also allow users to abort tasks started by one of their API tokens. - let activeShutdownTask = - Ext.getStore('pve-cluster-tasks')?.findBy( - (task) => - (hasSysModify || task.data.user === Proxmox.UserName) && - task.data.id === me.vm.vmid.toString() && - task.data.status === undefined && - task.data.type === overruleTaskType, - ) !== -1; - let haEnabled = me.vm.hastate && me.vm.hastate !== 'unmanaged'; - - me.callParent(); - - // message box has its actual content in a sub-container, the top one is just for layouting - me.promptContainer.add({ - xtype: 'proxmoxcheckbox', - name: 'overrule-shutdown', - checked: !haEnabled && activeShutdownTask, - boxLabel: gettext('Overrule active shutdown tasks'), - hidden: !(hasSysModify || activeShutdownTask), - disabled: !(hasSysModify || activeShutdownTask) || haEnabled, - padding: '3 0 0 0', - }); - }, - - handler: function (btn) { - let me = this; - if (btn === 'yes') { - let overruleField = me.promptContainer.down('proxmoxcheckbox[name=overrule-shutdown]'); - let params = - !overruleField.isDisabled() && overruleField.getSubmitValue() - ? { 'overrule-shutdown': 1 } - : undefined; - Proxmox.Utils.API2Request({ - url: me.url, - waitMsgTarget: me, - method: 'POST', - params: params, - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - }); - } - }, - - show: function () { - let me = this; - let cfg = { - title: gettext('Confirm'), - icon: Ext.Msg.WARNING, - msg: PVE.Utils.formatGuestTaskConfirmation(me.taskType, me.vm.vmid, me.vm.name), - buttons: Ext.Msg.YESNO, - callback: (btn) => me.handler(btn), - }; - me.callParent([cfg]); - }, -}); -Ext.define('PVE.window.TreeSettingsEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveTreeSettingsEdit', - - title: gettext('Tree Settings'), - isCreate: false, - - url: '#', // ignored as submit() gets overridden here, but the parent class requires it - - width: 450, - fieldDefaults: { - labelWidth: 150, - }, - - items: [ - { - xtype: 'inputpanel', - items: [ - { - xtype: 'proxmoxKVComboBox', - name: 'sort-field', - fieldLabel: gettext('Sort Key'), - comboItems: [ - ['__default__', `${Proxmox.Utils.defaultText} (VMID)`], - ['vmid', 'VMID'], - ['name', gettext('Name')], - ], - defaultValue: '__default__', - value: '__default__', - deleteEmpty: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'group-templates', - fieldLabel: gettext('Group Templates'), - comboItems: [ - ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Yes')})`], - [1, gettext('Yes')], - [0, gettext('No')], - ], - defaultValue: '__default__', - value: '__default__', - deleteEmpty: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'group-guest-types', - fieldLabel: gettext('Group Guest Types'), - comboItems: [ - ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Yes')})`], - [1, gettext('Yes')], - [0, gettext('No')], - ], - defaultValue: '__default__', - value: '__default__', - deleteEmpty: false, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext('Settings are saved in the local storage of the browser'), - }, - ], - }, - ], - - submit: function () { - let me = this; - - let localStorage = Ext.state.Manager.getProvider(); - localStorage.set('pve-tree-sorting', me.down('inputpanel').getValues() || null); - - me.apiCallDone(); - me.close(); - }, - - initComponent: function () { - let me = this; - - me.callParent(); - - let localStorage = Ext.state.Manager.getProvider(); - me.down('inputpanel').setValues(localStorage.get('pve-tree-sorting')); - }, -}); -Ext.define('PVE.window.PCIMapEditWindow', { - extend: 'Proxmox.window.Edit', - - mixins: ['Proxmox.Mixin.CBind'], - - width: 800, - - subject: gettext('PCI mapping'), - - onlineHelp: 'resource_mapping', - - method: 'POST', - - cbindData: function (initialConfig) { - let me = this; - me.isCreate = (!me.name || !me.nodename) && !me.entryOnly; - me.method = me.name ? 'PUT' : 'POST'; - me.hideMapping = !!me.entryOnly; - me.globalEdit = !me.name || me.entryOnly; - me.hideNodeSelector = me.nodename || me.entryOnly; - me.hideNode = !me.nodename || !me.hideNodeSelector; - return { - name: me.name, - nodename: me.nodename, - }; - }, - - submitUrl: function (_url, data) { - let me = this; - let name = me.method === 'PUT' ? me.name : ''; - return `/cluster/mapping/pci/${name}`; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - onGetValues: function (values) { - let me = this; - let view = me.getView(); - if (view.method === 'POST') { - delete me.digest; - } - - if (values.iommugroup === -1) { - delete values.iommugroup; - } - - let nodename = values.node ?? view.nodename; - delete values.node; - if (me.originalMap) { - let otherMaps = PVE.Parser.filterPropertyStringList( - me.originalMap, - (e) => e.node !== nodename, - ); - if (otherMaps.length) { - values.map = values.map.concat(otherMaps); - } - } - - return values; - }, - - onSetValues: function (values) { - let me = this; - let view = me.getView(); - me.originalMap = [...values.map]; - let configuredNodes = []; - values.map = PVE.Parser.filterPropertyStringList(values.map, (e) => { - configuredNodes.push(e.node); - return e.node === view.nodename; - }); - - me.lookup('nodeselector').disallowedNodes = configuredNodes; - return values; - }, - - checkIommu: function (store, records, success) { - let me = this; - if (!success || !records.length) { - return; - } - me.lookup('iommu_warning').setVisible( - records.every((val) => val.data.iommugroup === -1), - ); - - let value = me.lookup('pciselector').getValue(); - me.checkIsolated(value); - }, - - checkIsolated: function (value) { - let me = this; - - let store = me.lookup('pciselector').getStore(); - - let isIsolated = function (entry) { - let isolated = true; - let parsed = PVE.Parser.parsePropertyString(entry); - parsed.iommugroup = parseInt(parsed.iommugroup, 10); - if (!parsed.iommugroup) { - return isolated; - } - store.each(({ data }) => { - let isSubDevice = data.id.startsWith(parsed.path); - if ( - data.iommugroup === parsed.iommugroup && - data.id !== parsed.path && - !isSubDevice - ) { - isolated = false; - return false; - } - return true; - }); - return isolated; - }; - - let showWarning = false; - if (Ext.isArray(value)) { - for (const entry of value) { - if (!isIsolated(entry)) { - showWarning = true; - break; - } - } - } else { - showWarning = isIsolated(value); - } - me.lookup('group_warning').setVisible(showWarning); - }, - - mdevChange: function (mdevField, value) { - this.lookup('pciselector').setMdev(value); - }, - - nodeChange: function (field, value) { - if (!field.isDisabled()) { - this.lookup('pciselector').setNodename(value); - } - }, - - pciChange: function (_field, value) { - let me = this; - me.lookup('multiple_warning').setVisible(Ext.isArray(value) && value.length > 1); - me.checkIsolated(value); - }, - - control: { - 'field[name=mdev]': { - change: 'mdevChange', - }, - pveNodeSelector: { - change: 'nodeChange', - }, - pveMultiPCISelector: { - change: 'pciChange', - }, - }, - }, - - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - return this.up('window').getController().onGetValues(values); - }, - - onSetValues: function (values) { - return this.up('window').getController().onSetValues(values); - }, - - columnT: [ - { - xtype: 'displayfield', - reference: 'iommu_warning', - hidden: true, - columnWidth: 1, - padding: '0 0 10 0', - value: gettext( - 'No IOMMU detected, please activate it. See Documentation for further information.', - ), - userCls: 'pmx-hint', - }, - { - xtype: 'displayfield', - reference: 'multiple_warning', - hidden: true, - columnWidth: 1, - padding: '0 0 10 0', - value: gettext( - 'When multiple devices are selected, the first free one will be chosen on guest start.', - ), - userCls: 'pmx-hint', - }, - { - xtype: 'displayfield', - reference: 'group_warning', - hidden: true, - columnWidth: 1, - padding: '0 0 10 0', - itemId: 'iommuwarning', - value: gettext( - 'A selected device is not in a separate IOMMU group, make sure this is intended.', - ), - userCls: 'pmx-hint', - }, - ], - - column1: [ - { - xtype: 'pmxDisplayEditField', - fieldLabel: gettext('Name'), - labelWidth: 120, - cbind: { - editable: '{!name}', - value: '{name}', - submitValue: '{isCreate}', - }, - name: 'id', - allowBlank: false, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Mapping on Node'), - labelWidth: 120, - name: 'node', - cbind: { - value: '{nodename}', - disabled: '{hideNode}', - hidden: '{hideNode}', - }, - allowBlank: false, - }, - { - xtype: 'pveNodeSelector', - reference: 'nodeselector', - fieldLabel: gettext('Mapping on Node'), - labelWidth: 120, - name: 'node', - cbind: { - disabled: '{hideNodeSelector}', - hidden: '{hideNodeSelector}', - }, - allowBlank: false, - }, - ], - - column2: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Use with Mediated Devices'), - labelWidth: 200, - reference: 'mdev', - name: 'mdev', - cbind: { - deleteEmpty: '{!isCreate}', - disabled: '{!globalEdit}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Live Migration Capable'), - labelWidth: 200, - boxLabel: ` ${gettext('Experimental')}`, - reference: 'live-migration-capable', - name: 'live-migration-capable', - cbind: { - deleteEmpty: '{!isCreate}', - disabled: '{!globalEdit}', - }, - }, - ], - - columnB: [ - { - xtype: 'pveMultiPCISelector', - fieldLabel: gettext('Device'), - labelWidth: 120, - height: 300, - reference: 'pciselector', - name: 'map', - cbind: { - nodename: '{nodename}', - disabled: '{hideMapping}', - hidden: '{hideMapping}', - }, - allowBlank: false, - onLoadCallBack: 'checkIommu', - margin: '0 0 10 0', - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Comment'), - labelWidth: 120, - submitValue: true, - name: 'description', - cbind: { - deleteEmpty: '{!isCreate}', - disabled: '{!globalEdit}', - hidden: '{!globalEdit}', - }, - }, - ], - }, - ], -}); -Ext.define('PVE.window.USBMapEditWindow', { - extend: 'Proxmox.window.Edit', - - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: function (initialConfig) { - let me = this; - me.isCreate = !me.name; - me.method = me.isCreate ? 'POST' : 'PUT'; - me.hideMapping = !!me.entryOnly; - me.hideComment = me.name && !me.entryOnly; - me.hideNodeSelector = me.nodename || me.entryOnly; - me.hideNode = !me.nodename || !me.hideNodeSelector; - return { - name: me.name, - nodename: me.nodename, - }; - }, - - submitUrl: function (_url, data) { - let me = this; - let name = me.isCreate ? '' : me.name; - return `/cluster/mapping/usb/${name}`; - }, - - title: gettext('Add USB mapping'), - - onlineHelp: 'resource_mapping', - - method: 'POST', - - controller: { - xclass: 'Ext.app.ViewController', - - onGetValues: function (values) { - let me = this; - let view = me.getView(); - values.node ??= view.nodename; - - let type = me.getView().down('radiofield').getGroupValue(); - let name = values.name; - let description = values.description; - delete values.description; - delete values.name; - - if (type === 'path') { - let usbsel = me.lookup(type); - let usbDev = usbsel - .getStore() - .findRecord('usbid', values[type], 0, false, true, true); - - if (!usbDev) { - return {}; - } - values.id = `${usbDev.data.vendid}:${usbDev.data.prodid}`; - } - - let map = []; - if (me.originalMap) { - map = PVE.Parser.filterPropertyStringList( - me.originalMap, - (e) => e.node !== values.node, - ); - } - if (values.id) { - map.push(PVE.Parser.printPropertyString(values)); - } - - values = { map }; - if (description) { - values.description = description; - } - - if (view.isCreate) { - values.id = name; - } - - return values; - }, - - onSetValues: function (values) { - let me = this; - let view = me.getView(); - me.originalMap = [...values.map]; - let configuredNodes = []; - PVE.Parser.filterPropertyStringList(values.map, (e) => { - configuredNodes.push(e.node); - if (e.node === view.nodename) { - values = e; - } - return false; - }); - - me.lookup('nodeselector').disallowedNodes = configuredNodes; - if (values.path) { - values.usb = 'path'; - } - - return values; - }, - - modeChange: function (field, value) { - let me = this; - let type = field.inputValue; - let usbsel = me.lookup(type); - usbsel.setDisabled(!value); - }, - - nodeChange: function (field, value) { - if (!field.isDisabled()) { - this.lookup('id').setNodename(value); - this.lookup('path').setNodename(value); - } - }, - - init: function (view) { - let _me = this; - - if (!view.nodename) { - //throw "no nodename given"; - } - }, - - control: { - radiofield: { - change: 'modeChange', - }, - pveNodeSelector: { - change: 'nodeChange', - }, - }, - }, - - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - return this.up('window').getController().onGetValues(values); - }, - - onSetValues: function (values) { - return this.up('window').getController().onSetValues(values); - }, - - column1: [ - { - xtype: 'pmxDisplayEditField', - fieldLabel: gettext('Name'), - cbind: { - editable: '{!name}', - value: '{name}', - submitValue: '{isCreate}', - }, - name: 'name', - allowBlank: false, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Mapping on Node'), - labelWidth: 120, - name: 'node', - cbind: { - value: '{nodename}', - disabled: '{hideNode}', - hidden: '{hideNode}', - }, - allowBlank: false, - }, - { - xtype: 'pveNodeSelector', - reference: 'nodeselector', - fieldLabel: gettext('Mapping on Node'), - labelWidth: 120, - name: 'node', - cbind: { - disabled: '{hideNodeSelector}', - hidden: '{hideNodeSelector}', - }, - allowBlank: false, - }, - ], - - column2: [ - { - xtype: 'fieldcontainer', - defaultType: 'radiofield', - layout: 'fit', - cbind: { - disabled: '{hideMapping}', - hidden: '{hideMapping}', - }, - items: [ - { - name: 'usb', - inputValue: 'id', - checked: true, - boxLabel: gettext('Use USB Vendor/Device ID'), - submitValue: false, - }, - { - xtype: 'pveUSBSelector', - type: 'device', - reference: 'id', - name: 'id', - cbind: { - nodename: '{nodename}', - disabled: '{hideMapping}', - }, - editable: true, - allowBlank: false, - fieldLabel: gettext('Choose Device'), - labelAlign: 'right', - }, - { - name: 'usb', - inputValue: 'path', - boxLabel: gettext('Use USB Port'), - submitValue: false, - }, - { - xtype: 'pveUSBSelector', - disabled: true, - name: 'path', - reference: 'path', - cbind: { - nodename: '{nodename}', - }, - editable: true, - type: 'port', - allowBlank: false, - fieldLabel: gettext('Choose Port'), - labelAlign: 'right', - }, - ], - }, - ], - - columnB: [ - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Comment'), - submitValue: true, - name: 'description', - cbind: { - disabled: '{hideComment}', - hidden: '{hideComment}', - }, - }, - ], - }, - ], -}); -Ext.define('PVE.window.DirMapEditWindow', { - extend: 'Proxmox.window.Edit', - - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: function (initialConfig) { - let me = this; - me.isCreate = !me.name; - me.method = me.isCreate ? 'POST' : 'PUT'; - me.hideMapping = !!me.entryOnly; - me.hideComment = me.name && !me.entryOnly; - me.hideNodeSelector = me.nodename || me.entryOnly; - me.hideNode = !me.nodename || !me.hideNodeSelector; - return { - name: me.name, - nodename: me.nodename, - }; - }, - - submitUrl: function (_url, data) { - let me = this; - let name = me.isCreate ? '' : me.name; - return `/cluster/mapping/dir/${name}`; - }, - - title: gettext('Add Directory Mapping'), - - onlineHelp: 'resource_mapping', - - method: 'POST', - - controller: { - xclass: 'Ext.app.ViewController', - - onGetValues: function (values) { - let me = this; - let view = me.getView(); - values.node ??= view.nodename; - - let name = values.name; - let description = values.description; - let deletes = values.delete; - - delete values.description; - delete values.name; - delete values.delete; - - let map = []; - if (me.originalMap) { - map = PVE.Parser.filterPropertyStringList( - me.originalMap, - (e) => e.node !== values.node, - ); - } - if (values.path) { - // TODO: Remove this when property string supports quotation of properties - if (!/^\/[^;,=()]+/.test(values.path)) { - let errMsg = - 'Value does not look like a valid absolute path.' + - ' These symbols are currently not allowed in path: ;,=()\n'; - Ext.Msg.alert(gettext('Error'), errMsg); - // prevent sending a broken property string to the API - throw errMsg; - } - map.push(PVE.Parser.printPropertyString(values)); - } - values = { map }; - - if (description) { - values.description = description; - } - if (deletes && !view.isCreate) { - values.delete = deletes; - } - if (view.isCreate) { - values.id = name; - } - - return values; - }, - - onSetValues: function (values) { - let me = this; - let view = me.getView(); - me.originalMap = [...values.map]; - let configuredNodes = []; - PVE.Parser.filterPropertyStringList(values.map, (e) => { - configuredNodes.push(e.node); - if (e.node === view.nodename) { - values = e; - } - return false; - }); - - me.lookup('nodeselector').disallowedNodes = configuredNodes; - - return values; - }, - - init: function (view) { - let _me = this; - - if (!view.nodename) { - //throw "no nodename given"; - } - }, - }, - - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - return this.up('window').getController().onGetValues(values); - }, - - onSetValues: function (values) { - return this.up('window').getController().onSetValues(values); - }, - - columnT: [ - { - xtype: 'displayfield', - reference: 'directory-hint', - columnWidth: 1, - value: 'Make sure the directory exists.', - cbind: { - disabled: '{hideMapping}', - hidden: '{hideMapping}', - }, - userCls: 'pmx-hint', - }, - ], - - column1: [ - { - xtype: 'pmxDisplayEditField', - fieldLabel: gettext('Name'), - cbind: { - editable: '{!name}', - value: '{name}', - submitValue: '{isCreate}', - }, - name: 'name', - allowBlank: false, - }, - { - xtype: 'pveNodeSelector', - reference: 'nodeselector', - fieldLabel: gettext('Node'), - name: 'node', - cbind: { - disabled: '{hideNodeSelector}', - hidden: '{hideNodeSelector}', - }, - allowBlank: false, - }, - ], - - column2: [ - { - xtype: 'fieldcontainer', - defaultType: 'radiofield', - layout: 'fit', - cbind: { - disabled: '{hideMapping}', - hidden: '{hideMapping}', - }, - items: [ - { - xtype: 'textfield', - name: 'path', - reference: 'path', - value: '', - emptyText: gettext('/some/path'), - cbind: { - nodename: '{nodename}', - disabled: '{hideMapping}', - }, - allowBlank: false, - fieldLabel: gettext('Path'), - }, - ], - }, - ], - - columnB: [ - { - xtype: 'fieldcontainer', - defaultType: 'radiofield', - layout: 'fit', - cbind: { - disabled: '{hideComment}', - hidden: '{hideComment}', - }, - items: [ - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Comment'), - submitValue: true, - name: 'description', - deleteEmpty: true, - }, - ], - }, - ], - }, - ], -}); -Ext.define('PVE.window.GuestImport', { - extend: 'Proxmox.window.Edit', // fixme: Proxmox.window.Edit? - alias: 'widget.pveGuestImportWindow', - - title: gettext('Import Guest'), - - onlineHelp: 'qm_import_virtual_machines', - - width: 720, - bodyPadding: 0, - - submitUrl: function () { - let me = this; - return `/nodes/${me.nodename}/qemu`; - }, - - isAdd: true, - isCreate: true, - submitText: gettext('Import'), - showTaskViewer: true, - method: 'POST', - - loadUrl: function (_url, { storage, nodename, volumeName }) { - let args = Ext.Object.toQueryString({ volume: volumeName }); - return `/nodes/${nodename}/storage/${storage}/import-metadata?${args}`; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - setNodename: function (_column, widget) { - let me = this; - let view = me.getView(); - widget.setNodename(view.nodename); - }, - - diskStorageChange: function (storageSelector, value) { - let me = this; - - let grid = me.lookup('diskGrid'); - let rec = storageSelector.getWidgetRecord(); - let validFormats = storageSelector.store.getById(value)?.data.format; - grid.query('pveDiskFormatSelector').some((selector) => { - if (selector.getWidgetRecord().data.id !== rec.data.id) { - return false; - } - - if (validFormats?.[0]?.qcow2) { - selector.setDisabled(false); - selector.setValue('qcow2'); - } else { - selector.setValue('raw'); - selector.setDisabled(true); - } - - return true; - }); - }, - - isoStorageChange: function (storageSelector, value) { - let me = this; - - let grid = me.lookup('cdGrid'); - let rec = storageSelector.getWidgetRecord(); - grid.query('pveFileSelector').some((selector) => { - if (selector.getWidgetRecord().data.id !== rec.data.id) { - return false; - } - - selector.setStorage(value); - if (!value) { - selector.setValue(''); - } - - return true; - }); - }, - - onOSBaseChange: function (_field, value) { - let me = this; - let ostype = me.lookup('ostype'); - let store = ostype.getStore(); - store.setData(PVE.Utils.kvm_ostypes[value]); - let old_val = ostype.getValue(); - if (old_val && store.find('val', old_val) !== -1) { - ostype.setValue(old_val); - } else { - ostype.setValue(store.getAt(0)); - } - }, - - calculateConfig: function () { - let me = this; - let inputPanel = me.lookup('mainInputPanel'); - let summaryGrid = me.lookup('summaryGrid'); - let values = inputPanel.getValues(); - summaryGrid - .getStore() - .setData(Object.entries(values).map(([key, value]) => ({ key, value }))); - }, - - calculateAdditionalCDIdx: function () { - let me = this; - - let maxIde = me.getMaxControllerId('ide'); - let maxSata = me.getMaxControllerId('sata'); - // only ide0 and ide2 can be used reliably for isos (e.g. for q35) - if (maxIde < 0) { - return 'ide0'; - } - if (maxIde < 2) { - return 'ide2'; - } - if (maxSata < PVE.Utils.diskControllerMaxIDs.sata - 1) { - return `sata${maxSata + 1}`; - } - - return ''; - }, - - // assume assigned sata disks indices are continuous, so without holes - getMaxControllerId: function (controller) { - let me = this; - let view = me.getView(); - if (!controller) { - return -1; - } - - let max = view[`max${controller}`]; - if (max !== undefined) { - return max; - } - - max = -1; - for (const key of Object.keys(me.getView().vmConfig)) { - if (!key.toLowerCase().startsWith(controller)) { - continue; - } - let idx = parseInt(key.slice(controller.length), 10); - if (idx > max) { - max = idx; - } - } - me.lookup('diskGrid') - .getStore() - .each((rec) => { - if (!rec.data.id.toLowerCase().startsWith(controller)) { - return; - } - let idx = parseInt(rec.data.id.slice(controller.length), 10); - if (idx > max) { - max = idx; - } - }); - me.lookup('cdGrid') - .getStore() - .each((rec) => { - if (!rec.data.id.toLowerCase().startsWith(controller) || rec.data.hidden) { - return; - } - let idx = parseInt(rec.data.id.slice(controller.length), 10); - if (idx > max) { - max = idx; - } - }); - - view[`max${controller}`] = max; - return max; - }, - - renderDisk: function (value, metaData, record, rowIndex, colIndex, store, tableView) { - let diskGrid = tableView.grid ?? this.lookup('diskGrid'); - if (diskGrid.diskMap) { - let mappedID = diskGrid.diskMap[value]; - if (mappedID) { - let prefix = ''; - if (mappedID === value) { - // mapped to the same value means we ran out of IDs - let warning = gettext('Too many disks, could not map to SATA.'); - prefix = ` `; - } - return `${prefix}${mappedID}`; - } - } - return value; - }, - - refreshGrids: function () { - this.lookup('diskGrid').reconfigure(); - this.lookup('cdGrid').reconfigure(); - this.lookup('netGrid').reconfigure(); - }, - - onOSTypeChange: function (_cb, value) { - let me = this; - if (!value) { - return; - } - let store = me.lookup('cdGrid').getStore(); - let collection = store.getData().getSource() ?? store.getData(); - let rec = collection.find('autogenerated', true); - - let isWindows = (value ?? '').startsWith('w'); - if (rec) { - rec.set('hidden', !isWindows); - rec.commit(); - } - let prepareVirtio = me.lookup('prepareForVirtIO').getValue(); - let defaultScsiHw = me.getView().vmConfig.scsihw ?? '__default__'; - me.lookup('scsihw').setValue( - prepareVirtio && isWindows ? 'virtio-scsi-single' : defaultScsiHw, - ); - - me.refreshGrids(); - }, - - onPrepareVirtioChange: function (_cb, value) { - let me = this; - let view = me.getView(); - let diskGrid = me.lookup('diskGrid'); - - diskGrid.diskMap = {}; - if (value) { - const hasAdditionalSataCDROM = - me.getViewModel().get('isWindows') && view.additionalCdIdx?.startsWith('sata'); - - diskGrid.getStore().each((rec) => { - let diskID = rec.data.id; - if (!diskID.toLowerCase().startsWith('scsi')) { - return; // continue - } - let offset = parseInt(diskID.slice(4), 10); - let newIdx = offset + me.getMaxControllerId('sata') + 1; - if (hasAdditionalSataCDROM) { - newIdx++; - } - let mappedID = `sata${newIdx}`; - if (newIdx >= PVE.Utils.diskControllerMaxIDs.sata) { - mappedID = diskID; // map to self so that the renderer can detect that we're out of IDs - } - diskGrid.diskMap[diskID] = mappedID; - }); - } - - let scsihw = me.lookup('scsihw'); - scsihw.suspendEvents(); - scsihw.setValue(value ? 'virtio-scsi-single' : me.getView().vmConfig.scsihw); - scsihw.resumeEvents(); - - me.refreshGrids(); - }, - - onScsiHwChange: function (_field, value) { - let me = this; - me.getView().vmConfig.scsihw = value; - }, - - onUniqueMACChange: function (_cb, value) { - let me = this; - - me.getViewModel().set('uniqueMACAdresses', value); - - me.lookup('netGrid').reconfigure(); - }, - - renderMacAddress: function (value, metaData, record, rowIndex, colIndex, store, view) { - let me = this; - let vm = me.getViewModel(); - - return !vm.get('uniqueMACAdresses') && value ? value : 'auto'; - }, - - control: { - 'grid field': { - // update records from widgetcolumns - change: function (widget, value) { - let rec = widget.getWidgetRecord(); - rec.set(widget.name, value); - rec.commit(); - }, - }, - 'grid[reference=diskGrid] pveStorageSelector': { - change: 'diskStorageChange', - }, - 'grid[reference=cdGrid] pveStorageSelector': { - change: 'isoStorageChange', - }, - 'field[name=osbase]': { - change: 'onOSBaseChange', - }, - 'panel[reference=summaryTab]': { - activate: 'calculateConfig', - }, - 'proxmoxcheckbox[reference=prepareForVirtIO]': { - change: 'onPrepareVirtioChange', - }, - 'combobox[name=ostype]': { - change: 'onOSTypeChange', - }, - pveScsiHwSelector: { - change: 'onScsiHwChange', - }, - 'proxmoxcheckbox[name=uniqueMACs]': { - change: 'onUniqueMACChange', - }, - }, - }, - - viewModel: { - data: { - coreCount: 1, - socketCount: 1, - liveImport: false, - os: 'l26', - maxCdDrives: false, - uniqueMACAdresses: false, - isOva: false, - warnings: [], - }, - - formulas: { - totalCoreCount: (get) => get('socketCount') * get('coreCount'), - hideWarnings: (get) => get('warnings').length === 0, - warningsText: (get) => - '
      ' + - get('warnings') - .map((w) => `
    • ${w}
    • `) - .join('') + - '
    ', - liveImportNote: (get) => - !get('liveImport') - ? '' - : gettext( - 'Note: If anything goes wrong during the live-import, new data written by the VM may be lost.', - ), - isWindows: (get) => (get('os') ?? '').startsWith('w'), - liveImportText: (get) => - get('isOva') - ? gettext('Starts a VM and imports the disks in the background') - : gettext( - 'Starts a previously stopped VM on Proxmox VE and imports the disks in the background.', - ), - }, - }, - - items: [ - { - xtype: 'tabpanel', - defaults: { - bodyPadding: 10, - }, - items: [ - { - title: gettext('General'), - xtype: 'inputpanel', - reference: 'mainInputPanel', - onGetValues: function (values) { - let me = this; - let view = me.up('pveGuestImportWindow'); - let vm = view.getViewModel(); - let diskGrid = view.lookup('diskGrid'); - - // from pveDiskStorageSelector - let defaultStorage = values.hdstorage; - let defaultFormat = values.diskformat; - delete values.hdstorage; - delete values.diskformat; - - let defaultBridge = values.defaultBridge; - delete values.defaultBridge; - - let config = { ...view.vmConfig }; - Ext.apply(config, values); - - if (config.scsi0) { - config.scsi0 = config.scsi0.replace( - 'local:0,', - 'local:0,format=qcow2,', - ); - } - - let parsedBoot = PVE.Parser.parsePropertyString(config.boot ?? ''); - if (parsedBoot.order) { - parsedBoot.order = parsedBoot.order.split(';'); - } - - let diskMap = diskGrid.diskMap ?? {}; - diskGrid.getStore().each((rec) => { - if (!rec.data.enable) { - return; - } - let id = diskMap[rec.data.id] ?? rec.data.id; - if (id !== rec.data.id && parsedBoot?.order) { - let idx = parsedBoot.order.indexOf(rec.data.id); - if (idx !== -1) { - parsedBoot.order[idx] = id; - } - } - let data = { - ...rec.data, - }; - delete data.enable; - delete data.id; - delete data.size; - if (!data.file) { - data.file = defaultStorage; - data.format = defaultFormat; - } - data.file += ':0'; // for our special api format - if (id === 'efidisk0') { - data.efitype = '4m'; - delete data['import-from']; - } - config[id] = PVE.Parser.printQemuDrive(data); - }); - - if (parsedBoot.order) { - parsedBoot.order = parsedBoot.order.join(';'); - } - config.boot = PVE.Parser.printPropertyString(parsedBoot); - - view.lookup('netGrid') - .getStore() - .each((rec) => { - if (!rec.data.enable) { - return; - } - let id = rec.data.id; - let data = { - ...rec.data, - }; - delete data.enable; - delete data.id; - if (!data.bridge) { - data.bridge = defaultBridge; - } - if (vm.get('uniqueMACAdresses')) { - data.macaddr = undefined; - } - config[id] = PVE.Parser.printQemuNetwork(data); - }); - - view.lookup('cdGrid') - .getStore() - .each((rec) => { - if (!rec.data.enable) { - return; - } - let id = rec.data.id; - let cd = { - media: 'cdrom', - file: rec.data.file ? rec.data.file : 'none', - }; - config[id] = PVE.Parser.printPropertyString(cd); - }); - - config.scsihw = view.lookup('scsihw').getValue(); - - if (view.lookup('liveimport').getValue()) { - config['live-restore'] = 1; - } - - // remove __default__ values - for (const [key, value] of Object.entries(config)) { - if (value === '__default__') { - delete config[key]; - } - } - - if (config['import-working-storage'] === '') { - delete config['import-working-storage']; - } - - return config; - }, - - column1: [ - { - xtype: 'pveGuestIDSelector', - name: 'vmid', - fieldLabel: 'VM', - guestType: 'qemu', - loadNextFreeID: true, - validateExists: false, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: gettext('Sockets'), - name: 'sockets', - reference: 'socketsField', - value: 1, - minValue: 1, - maxValue: 128, - allowBlank: true, - bind: { - value: '{socketCount}', - }, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: gettext('Cores'), - name: 'cores', - reference: 'coresField', - value: 1, - minValue: 1, - maxValue: 1024, - allowBlank: true, - bind: { - value: '{coreCount}', - }, - }, - { - xtype: 'pveMemoryField', - fieldLabel: gettext('Memory') + ' (MiB)', - name: 'memory', - reference: 'memoryField', - value: 512, - allowBlank: true, - }, - { xtype: 'displayfield' }, // spacer - { xtype: 'displayfield' }, // spacer - { - xtype: 'pveDiskStorageSelector', - reference: 'defaultStorage', - storageLabel: gettext('Default Storage'), - storageContent: 'images', - autoSelect: true, - hideSize: true, - name: 'defaultStorage', - }, - ], - - column2: [ - { - xtype: 'textfield', - fieldLabel: gettext('Name'), - name: 'name', - vtype: 'DnsName', - reference: 'nameField', - allowBlank: true, - }, - { - xtype: 'CPUModelSelector', - name: 'cpu', - reference: 'cputype', - value: 'x86-64-v2-AES', - fieldLabel: gettext('CPU Type'), - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Total cores'), - name: 'totalcores', - isFormField: false, - bind: { - value: '{totalCoreCount}', - }, - }, - { - xtype: 'combobox', - submitValue: false, - name: 'osbase', - fieldLabel: gettext('OS Type'), - editable: false, - queryMode: 'local', - value: 'Linux', - store: Object.keys(PVE.Utils.kvm_ostypes), - }, - { - xtype: 'combobox', - name: 'ostype', - reference: 'ostype', - fieldLabel: gettext('Version'), - value: 'l26', - allowBlank: false, - editable: false, - queryMode: 'local', - valueField: 'val', - displayField: 'desc', - bind: { - value: '{os}', - }, - store: { - fields: ['desc', 'val'], - data: PVE.Utils.kvm_ostypes.Linux, - }, - }, - { xtype: 'displayfield' }, // spacer - { - xtype: 'PVE.form.BridgeSelector', - reference: 'defaultBridge', - name: 'defaultBridge', - allowBlank: false, - fieldLabel: gettext('Default Bridge'), - }, - { - xtype: 'pveStorageSelector', - reference: 'extractionStorage', - fieldLabel: gettext('Import Working Storage'), - storageContent: 'images', - emptyText: gettext('Source Storage'), - autoSelect: false, - name: 'import-working-storage', - disabled: true, - hidden: true, - allowBlank: true, - bind: { - disabled: '{!isOva}', - hidden: '{!isOva}', - }, - }, - ], - - columnB: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Live Import'), - reference: 'liveimport', - isFormField: false, - bind: { - value: '{liveImport}', - boxLabel: '{liveImportText}', - }, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint black', - value: gettext( - 'Note: If anything goes wrong during the live-import, new data written by the VM may be lost.', - ), - bind: { - hidden: '{!liveImport}', - }, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Warnings'), - labelWidth: 200, - hidden: true, - bind: { - hidden: '{hideWarnings}', - }, - }, - { - xtype: 'displayfield', - reference: 'warningText', - userCls: 'pmx-hint', - hidden: true, - bind: { - hidden: '{hideWarnings}', - value: '{warningsText}', - }, - }, - ], - }, - { - title: gettext('Advanced'), - xtype: 'inputpanel', - - // the first inputpanel handles all values, so prevent value leakage here - onGetValues: () => ({}), - - columnT: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Disks'), - labelWidth: 200, - }, - { - xtype: 'grid', - reference: 'diskGrid', - minHeight: 60, - maxHeight: 150, - store: { - data: [], - sorters: ['id'], - }, - columns: [ - { - xtype: 'checkcolumn', - header: gettext('Use'), - width: 50, - dataIndex: 'enable', - listeners: { - checkchange: function ( - _column, - _rowIndex, - _checked, - record, - ) { - record.commit(); - }, - }, - }, - { - text: gettext('Disk'), - dataIndex: 'id', - renderer: 'renderDisk', - }, - { - text: gettext('Source'), - dataIndex: 'import-from', - flex: 1, - renderer: function (value) { - return value.replace(/^.*\//, ''); - }, - }, - { - text: gettext('Size'), - dataIndex: 'size', - renderer: (value) => { - if (Ext.isNumeric(value)) { - return Proxmox.Utils.render_size(value); - } - return value ?? Proxmox.Utils.unknownText; - }, - }, - { - text: gettext('Storage'), - dataIndex: 'file', - xtype: 'widgetcolumn', - width: 150, - widget: { - xtype: 'pveStorageSelector', - isFormField: false, - autoSelect: false, - allowBlank: true, - emptyText: gettext('From Default'), - name: 'file', - storageContent: 'images', - }, - onWidgetAttach: 'setNodename', - }, - { - text: gettext('Format'), - dataIndex: 'format', - xtype: 'widgetcolumn', - width: 150, - widget: { - xtype: 'pveDiskFormatSelector', - name: 'format', - disabled: true, - isFormField: false, - matchFieldWidth: false, - }, - }, - ], - }, - ], - - column1: [ - { - xtype: 'proxmoxcheckbox', - boxLabel: gettext('Prepare for VirtIO-SCSI'), - reference: 'prepareForVirtIO', - name: 'prepareForVirtIO', - submitValue: false, - disabled: true, - bind: { - disabled: '{!isWindows}', - }, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Maps SCSI disks to SATA and changes the SCSI Controller. Useful for a quicker switch to VirtIO-SCSI attached disks', - ), - }, - }, - ], - - column2: [ - { - xtype: 'pveScsiHwSelector', - reference: 'scsihw', - name: 'scsihw', - value: '__default__', - submitValue: false, - fieldLabel: gettext('SCSI Controller'), - }, - ], - - columnB: [ - { - xtype: 'displayfield', - fieldLabel: gettext('CD/DVD Drives'), - labelWidth: 200, - }, - { - xtype: 'grid', - reference: 'cdGrid', - minHeight: 60, - maxHeight: 150, - store: { - data: [], - sorters: ['id'], - filters: [ - function (rec) { - return !rec.data.hidden; - }, - ], - }, - columns: [ - { - xtype: 'checkcolumn', - header: gettext('Use'), - width: 50, - dataIndex: 'enable', - listeners: { - checkchange: function ( - _column, - _rowIndex, - _checked, - record, - ) { - record.commit(); - }, - }, - }, - { - text: gettext('Slot'), - dataIndex: 'id', - sorted: true, - }, - { - text: gettext('Storage'), - xtype: 'widgetcolumn', - width: 150, - widget: { - xtype: 'pveStorageSelector', - isFormField: false, - autoSelect: false, - allowBlank: true, - emptyText: Proxmox.Utils.noneText, - storageContent: 'iso', - }, - onWidgetAttach: 'setNodename', - }, - { - text: gettext('ISO'), - dataIndex: 'file', - xtype: 'widgetcolumn', - flex: 1, - widget: { - xtype: 'pveFileSelector', - name: 'file', - isFormField: false, - allowBlank: true, - emptyText: Proxmox.Utils.noneText, - storageContent: 'iso', - }, - onWidgetAttach: 'setNodename', - }, - ], - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Network Interfaces'), - labelWidth: 200, - style: { - paddingTop: '10px', - }, - }, - { - xtype: 'grid', - minHeight: 58, - maxHeight: 150, - reference: 'netGrid', - store: { - data: [], - sorters: ['id'], - }, - columns: [ - { - xtype: 'checkcolumn', - header: gettext('Use'), - width: 50, - dataIndex: 'enable', - listeners: { - checkchange: function ( - _column, - _rowIndex, - _checked, - record, - ) { - record.commit(); - }, - }, - }, - { - text: gettext('ID'), - dataIndex: 'id', - }, - { - text: gettext('MAC address'), - flex: 7, - dataIndex: 'macaddr', - renderer: 'renderMacAddress', - }, - { - text: gettext('Model'), - flex: 7, - dataIndex: 'model', - xtype: 'widgetcolumn', - widget: { - xtype: 'pveNetworkCardSelector', - name: 'model', - isFormField: false, - allowBlank: false, - }, - }, - { - text: gettext('Bridge'), - dataIndex: 'bridge', - xtype: 'widgetcolumn', - flex: 6, - widget: { - xtype: 'PVE.form.BridgeSelector', - name: 'bridge', - isFormField: false, - autoSelect: false, - allowBlank: true, - emptyText: gettext('From Default'), - }, - onWidgetAttach: 'setNodename', - }, - { - text: gettext('VLAN Tag'), - dataIndex: 'tag', - xtype: 'widgetcolumn', - flex: 5, - widget: { - xtype: 'pveVlanField', - fieldLabel: undefined, - name: 'tag', - isFormField: false, - allowBlank: true, - }, - }, - ], - }, - { - xtype: 'proxmoxcheckbox', - name: 'uniqueMACs', - boxLabel: gettext('Unique MAC addresses'), - uncheckedValue: false, - value: false, - }, - ], - }, - { - title: gettext('Resulting Config'), - reference: 'summaryTab', - items: [ - { - xtype: 'grid', - reference: 'summaryGrid', - maxHeight: 400, - scrollable: true, - store: { - model: 'KeyValue', - sorters: [ - { - property: 'key', - direction: 'ASC', - }, - ], - }, - columns: [ - { header: 'Key', width: 150, dataIndex: 'key' }, - { header: 'Value', flex: 1, dataIndex: 'value' }, - ], - }, - ], - }, - ], - }, - ], - - initComponent: function () { - let me = this; - - if (!me.volumeName) { - throw 'no volumeName given'; - } - - if (!me.storage) { - throw 'no storage given'; - } - - if (!me.nodename) { - throw 'no nodename given'; - } - - me.callParent(); - - me.setTitle( - Ext.String.format(gettext('Import Guest - {0}'), `${me.storage}:${me.volumeName}`), - ); - - me.lookup('defaultStorage').setNodename(me.nodename); - me.lookup('defaultBridge').setNodename(me.nodename); - me.lookup('extractionStorage').setNodename(me.nodename); - - let renderWarning = (w) => { - const warningsCatalogue = { - 'cdrom-image-ignored': gettext( - "CD-ROM images cannot get imported, if required you can reconfigure the '{0}' drive in the 'Advanced' tab.", - ), - 'nvme-unsupported': gettext( - "NVMe disks are currently not supported, '{0}' will get attached as SCSI", - ), - 'ovmf-with-lsi-unsupported': gettext( - "OVMF is built without LSI drivers, scsi hardware was set to '{1}'", - ), - 'serial-port-socket-only': gettext( - "Serial socket '{0}' will be mapped to a socket", - ), - 'guest-is-running': gettext( - 'Virtual guest seems to be running on source host. Import might fail or have inconsistent state!', - ), - 'efi-state-lost': Ext.String.format( - gettext( - 'EFI state cannot be imported, you may need to reconfigure the boot order (see {0})', - ), - 'OVMF/UEFI Boot Entries', - ), - 'ova-needs-extracting': gettext( - 'Importing an OVA temporarily requires extra space on the working storage while extracting the contained disks for further processing.', - ), - }; - let message = warningsCatalogue[w.type]; - if (!w.type || !message) { - return w.message ?? w.type ?? gettext('Unknown warning'); - } - return Ext.String.format(message, w.key ?? 'unknown', w.value ?? 'unknown'); - }; - - me.load({ - success: function (response) { - let data = response.result.data; - me.vmConfig = data['create-args']; - - let disks = []; - for (const [id, value] of Object.entries(data.disks ?? {})) { - let volid = Ext.htmlEncode(''); - let size = 'auto'; - if (Ext.isObject(value)) { - volid = value.volid; - size = value.size; - } - disks.push({ - id, - enable: true, - size, - 'import-from': volid, - format: 'raw', - }); - } - - let nets = []; - for (const [id, parsed] of Object.entries(data.net ?? {})) { - parsed.id = id; - parsed.enable = true; - nets.push(parsed); - } - - let cdroms = []; - for (const [id, value] of Object.entries(me.vmConfig)) { - if (!Ext.isString(value) || !value.match(/media=cdrom/)) { - continue; - } - cdroms.push({ - enable: true, - hidden: false, - id, - }); - delete me.vmConfig[id]; - } - - me.lookup('diskGrid').getStore().setData(disks); - me.lookup('netGrid').getStore().setData(nets); - me.lookup('cdGrid').getStore().setData(cdroms); - - let additionalCdIdx = me.getController().calculateAdditionalCDIdx(); - if (additionalCdIdx === '') { - me.getViewModel().set('maxCdDrives', true); - } else if (cdroms.length === 0) { - me.additionalCdIdx = additionalCdIdx; - me.lookup('cdGrid') - .getStore() - .add({ - enable: true, - hidden: !(me.vmConfig.ostype ?? '').startsWith('w'), - id: additionalCdIdx, - autogenerated: true, - }); - } - - me.getViewModel().set( - 'warnings', - data.warnings.map((w) => renderWarning(w)), - ); - me.getViewModel().set( - 'isOva', - data.warnings.map((w) => w.type).indexOf('ova-needs-extracting') !== -1, - ); - - let osinfo = PVE.Utils.get_kvm_osinfo(me.vmConfig.ostype ?? ''); - let prepareForVirtIO = - (me.vmConfig.ostype ?? '').startsWith('w') && - (me.vmConfig.bios ?? '').indexOf('ovmf') !== -1; - - me.setValues({ - osbase: osinfo.base, - ...me.vmConfig, - }); - - me.lookup('prepareForVirtIO').setValue(prepareForVirtIO); - }, - }); - }, -}); -Ext.define( - 'PVE.ha.FencingView', - { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveFencingView'], - - onlineHelp: 'ha_manager_fencing', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-ha-fencing', - data: [], - }); - - Ext.apply(me, { - store: store, - stateful: false, - viewConfig: { - trackOver: false, - deferEmptyText: false, - emptyText: gettext('Use watchdog based fencing.'), - }, - columns: [ - { - header: gettext('Node'), - width: 100, - sortable: true, - dataIndex: 'node', - }, - { - header: gettext('Command'), - flex: 1, - dataIndex: 'command', - }, - ], - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-ha-fencing', { - extend: 'Ext.data.Model', - fields: ['node', 'command', 'digest'], - }); - }, -); -Ext.define('PVE.ha.VMResourceInputPanel', { - extend: 'Proxmox.panel.InputPanel', - onlineHelp: 'ha_manager_resource_config', - vmid: undefined, - - onGetValues: function (values) { - var me = this; - - if (values.vmid) { - values.sid = values.vmid; - } - delete values.vmid; - - PVE.Utils.delete_if_default(values, 'failback', '1', me.isCreate); - PVE.Utils.delete_if_default(values, 'max_restart', '1', me.isCreate); - PVE.Utils.delete_if_default(values, 'max_relocate', '1', me.isCreate); - - return values; - }, - - initComponent: function () { - var me = this; - var MIN_QUORUM_VOTES = 3; - - var disabledHint = Ext.createWidget({ - xtype: 'displayfield', // won't get submitted by default - userCls: 'pmx-hint', - value: - 'Disabling the resource will stop the guest system. ' + - 'See the online help for details.', - hidden: true, - }); - - var fewVotesHint = Ext.createWidget({ - itemId: 'fewVotesHint', - xtype: 'displayfield', - userCls: 'pmx-hint', - value: 'At least three quorum votes are recommended for reliable HA.', - hidden: true, - }); - - Proxmox.Utils.API2Request({ - url: '/cluster/config/nodes', - method: 'GET', - failure: function (response) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response) { - var nodes = response.result.data; - var votes = 0; - Ext.Array.forEach(nodes, function (node) { - var vote = parseInt(node.quorum_votes, 10); // parse as base 10 - votes += vote || 0; // parseInt might return NaN, which is false - }); - - if (votes < MIN_QUORUM_VOTES) { - fewVotesHint.setVisible(true); - } - }, - }); - - var vmidStore = me.vmid - ? {} - : { - model: 'PVEResources', - autoLoad: true, - sorters: 'vmid', - filters: [ - { - property: 'type', - value: /lxc|qemu/, - }, - { - property: 'hastate', - value: /unmanaged/, - }, - ], - }; - - // value is a string above, but a number below - me.column1 = [ - { - xtype: me.vmid ? 'displayfield' : 'vmComboSelector', - submitValue: me.isCreate, - name: 'vmid', - fieldLabel: me.vmid && me.guestType === 'ct' ? 'CT' : 'VM', - value: me.vmid, - store: vmidStore, - allowBlank: false, - validateExists: true, - }, - { - xtype: 'proxmoxintegerfield', - name: 'max_restart', - fieldLabel: gettext('Max. Restart'), - value: 1, - minValue: 0, - maxValue: 10, - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'max_relocate', - fieldLabel: gettext('Max. Relocate'), - value: 1, - minValue: 0, - maxValue: 10, - allowBlank: false, - }, - ]; - - me.column2 = [ - { - xtype: 'proxmoxcheckbox', - name: 'failback', - fieldLabel: gettext('Failback'), - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Enable if HA resource should automatically adjust to HA rules.', - ), - }, - uncheckedValue: 0, - value: 1, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'state', - value: 'started', - fieldLabel: gettext('Request State'), - comboItems: [ - ['started', 'started'], - ['stopped', 'stopped'], - ['ignored', 'ignored'], - ['disabled', 'disabled'], - ], - listeners: { - change: function (field, newValue) { - if (newValue === 'disabled') { - disabledHint.setVisible(true); - } else if (disabledHint.isVisible()) { - disabledHint.setVisible(false); - } - }, - }, - }, - disabledHint, - ]; - - me.columnB = [ - { - xtype: 'textfield', - name: 'comment', - fieldLabel: gettext('Comment'), - }, - fewVotesHint, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.ha.VMResourceEdit', { - extend: 'Proxmox.window.Edit', - - vmid: undefined, - guestType: undefined, - isCreate: undefined, - defaultFocus: undefined, - - initComponent: function () { - var me = this; - - if (me.isCreate === undefined) { - me.isCreate = !me.vmid; - } - - if (me.isCreate) { - me.url = '/api2/extjs/cluster/ha/resources'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/cluster/ha/resources/' + me.vmid; - me.method = 'PUT'; - } - - var ipanel = Ext.create('PVE.ha.VMResourceInputPanel', { - isCreate: me.isCreate, - vmid: me.vmid, - guestType: me.guestType, - }); - - Ext.apply(me, { - subject: - gettext('Resource') + - ': ' + - gettext('Container') + - '/' + - gettext('Virtual Machine'), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - - var regex = /^(\S+):(\S+)$/; - var res = regex.exec(values.sid); - - if (res[1] !== 'vm' && res[1] !== 'ct') { - throw 'got unexpected resource type'; - } - - values.vmid = res[2]; - - ipanel.setValues(values); - }, - }); - } - }, -}); -Ext.define('PVE.ha.ResourcesView', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveHAResourcesView'], - - onlineHelp: 'ha_manager_resources', - - stateful: true, - stateId: 'grid-ha-resources', - - initComponent: function () { - let me = this; - - if (!me.rstore) { - throw 'no store given'; - } - - Proxmox.Utils.monStoreErrors(me, me.rstore); - let store = Ext.create('Proxmox.data.DiffStore', { - rstore: me.rstore, - filters: { - property: 'type', - value: 'service', - }, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - let sid = rec.data.sid; - - let res = sid.match(/^(\S+):(\S+)$/); - if (!res || (res[1] !== 'vm' && res[1] !== 'ct')) { - console.warn(`unknown HA service ID type ${sid}`); - return; - } - let [, guestType, vmid] = res; - Ext.create('PVE.ha.VMResourceEdit', { - guestType: guestType, - vmid: vmid, - listeners: { - destroy: () => me.rstore.load(), - }, - autoShow: true, - }); - }; - - let caps = Ext.state.Manager.get('GuiCap'); - - Ext.apply(me, { - store: store, - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [ - { - text: gettext('Add'), - disabled: !caps.nodes['Sys.Console'], - handler: function () { - Ext.create('PVE.ha.VMResourceEdit', { - listeners: { - destroy: () => me.rstore.load(), - }, - autoShow: true, - }); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }, - { - xtype: 'proxmoxButton', - text: gettext('Remove'), - selModel: sm, - itemId: 'removeBtn', - disabled: true, - handler: function (btn, e, rec) { - Ext.create('PVE.window.ConfirmRemoveResource', { - url: `/cluster/ha/resources/${rec.data.sid}`, - item: { - id: rec.data.sid, - }, - apiCallDone: () => me.rstore.load(), - }).show(); - }, - }, - ], - columns: [ - { - header: 'ID', - width: 100, - sortable: true, - dataIndex: 'sid', - }, - { - header: gettext('State'), - width: 100, - sortable: true, - dataIndex: 'state', - }, - { - header: gettext('Node'), - width: 100, - sortable: true, - dataIndex: 'node', - }, - { - header: gettext('Request State'), - width: 100, - hidden: true, - sortable: true, - renderer: (v) => v || 'started', - dataIndex: 'request_state', - }, - { - header: gettext('CRM State'), - width: 100, - hidden: true, - sortable: true, - dataIndex: 'crm_state', - }, - { - header: gettext('Name'), - width: 100, - sortable: true, - dataIndex: 'vname', - }, - { - header: gettext('Max. Restart'), - width: 100, - sortable: true, - renderer: (v) => (v === undefined ? '1' : v), - dataIndex: 'max_restart', - }, - { - header: gettext('Max. Relocate'), - width: 100, - sortable: true, - renderer: (v) => (v === undefined ? '1' : v), - dataIndex: 'max_relocate', - }, - { - header: gettext('Failback'), - width: 100, - sortable: true, - dataIndex: 'failback', - }, - { - header: gettext('Description'), - flex: 1, - renderer: Ext.String.htmlEncode, - dataIndex: 'comment', - }, - ], - listeners: { - beforeselect: (grid, record, index, eOpts) => caps.nodes['Sys.Console'], - itemdblclick: run_editor, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.ha.RuleInputPanel', { - extend: 'Proxmox.panel.InputPanel', - - onlineHelp: 'ha_manager_rules', - - formatResourceListString: function (resources) { - let me = this; - - return resources.map((vmid) => { - if (me.resourcesStore.getById(`qemu/${vmid}`)) { - return `vm:${vmid}`; - } else if (me.resourcesStore.getById(`lxc/${vmid}`)) { - return `ct:${vmid}`; - } else { - Ext.Msg.alert(gettext('Error'), `Could not find resource type for ${vmid}`); - throw `Unknown resource type: ${vmid}`; - } - }); - }, - - onGetValues: function (values) { - let me = this; - - values.type = me.ruleType; - - if (me.isCreate) { - values.rule = 'ha-rule-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); - } - - if (values.enable) { - if (!me.isCreate) { - Proxmox.Utils.assemble_field_data(values, { delete: 'disable' }); - } - } else { - values.disable = 1; - } - delete values.enable; - - values.resources = me.formatResourceListString(values.resources); - - return values; - }, - - initComponent: function () { - let me = this; - - let resourcesStore = Ext.create('Ext.data.Store', { - model: 'PVEResources', - autoLoad: true, - sorters: 'vmid', - filters: [ - { - property: 'type', - value: /lxc|qemu/, - }, - { - property: 'hastate', - operator: '!=', - value: 'unmanaged', - }, - ], - }); - - Ext.apply(me, { - resourcesStore: resourcesStore, - }); - - me.column1 = me.column1 ?? []; - me.column1.unshift( - { - xtype: 'proxmoxcheckbox', - name: 'enable', - fieldLabel: gettext('Enable'), - uncheckedValue: 0, - defaultValue: 1, - checked: true, - }, - { - xtype: 'vmComboSelector', - name: 'resources', - fieldLabel: gettext('HA Resources'), - store: me.resourcesStore, - allowBlank: false, - autoSelect: false, - multiSelect: true, - validateExists: true, - }, - ); - - me.column2 = me.column2 ?? []; - - me.columnB = me.columnB ?? []; - me.columnB.unshift({ - xtype: 'textfield', - name: 'comment', - fieldLabel: gettext('Comment'), - allowBlank: true, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.ha.RuleEdit', { - extend: 'Proxmox.window.Edit', - - defaultFocus: undefined, // prevent the vmComboSelector to be expanded when focusing the window - - initComponent: function () { - let me = this; - - me.isCreate = !me.ruleId; - - if (me.isCreate) { - me.url = '/api2/extjs/cluster/ha/rules'; - me.method = 'POST'; - } else { - me.url = `/api2/extjs/cluster/ha/rules/${me.ruleId}`; - me.method = 'PUT'; - } - - let inputPanel = Ext.create(me.panelType, { - ruleId: me.ruleId, - ruleType: me.ruleType, - isCreate: me.isCreate, - }); - - Ext.apply(me, { - subject: me.panelName, - isAdd: true, - items: [inputPanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: (response, options) => { - let values = response.result.data; - - values.resources = values.resources - .split(',') - .map((resource) => resource.split(':')[1]); - - values.enable = values.disable ? 0 : 1; - - inputPanel.setValues(values); - }, - }); - } - }, -}); -Ext.define('PVE.ha.RuleErrorsModal', { - extend: 'Ext.window.Window', - alias: ['widget.pveHARulesErrorsModal'], - mixins: ['Proxmox.Mixin.CBind'], - - modal: true, - scrollable: true, - resizable: false, - - title: gettext('HA rule errors'), - - initComponent: function () { - let me = this; - - let renderHARuleErrors = (errors) => { - if (!errors) { - return gettext('The HA rule has no errors.'); - } - - let errorListItemsHtml = ''; - - for (let [opt, messages] of Object.entries(errors)) { - errorListItemsHtml += messages - .map((message) => `
  • ${Ext.htmlEncode(`${opt}: ${message}`)}
  • `) - .join(''); - } - - return `
    -

    ${gettext('The HA rule has the following errors:')}

    -
      ${errorListItemsHtml}
    -
    `; - }; - - Ext.apply(me, { - modal: true, - border: false, - layout: 'fit', - items: [ - { - xtype: 'displayfield', - padding: 20, - scrollable: true, - value: renderHARuleErrors(me.errors), - }, - ], - }); - - me.callParent(); - }, -}); -Ext.define('pve-ha-rules', { - extend: 'Ext.data.Model', - fields: [ - 'rule', - 'type', - 'nodes', - 'digest', - 'errors', - 'disable', - 'comment', - 'affinity', - 'resources', - { - name: 'strict', - type: 'boolean', - }, - ], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/ha/rules', - }, - idProperty: 'rule', -}); -Ext.define('pve-ha-rules-memory', { - extend: 'pve-ha-rules', - proxy: { - type: 'memory', - }, -}); - -Ext.define('PVE.ha.RulesBaseView', { - extend: 'Ext.grid.GridPanel', - mixins: ['Proxmox.Mixin.CBind'], - - store: { - model: 'pve-ha-rules-memory', - cbind: {}, // empty cbind to ensure mixin iterates into filter array. - filters: [ - { - property: 'type', - cbind: { - value: '{ruleType}', - }, - }, - ], - }, - - initComponent: function () { - let me = this; - - if (!me.ruleType) { - throw 'no rule type given'; - } - - let reloadStore = () => me.up('pveHARulesView').store.load(); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let createRuleEditWindow = (ruleId) => { - if (!me.inputPanel) { - throw `no editor registered for ha rule type: ${me.ruleType}`; - } - - Ext.create('PVE.ha.RuleEdit', { - panelType: `PVE.ha.rules.${me.inputPanel}`, - panelName: me.ruleTitle, - ruleType: me.ruleType, - ruleId: ruleId, - autoShow: true, - listeners: { - destroy: reloadStore, - }, - }); - }; - - let runEditor = () => { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - let { rule } = rec.data; - createRuleEditWindow(rule); - }; - - let childColumns = me.columns || []; - - Ext.apply(me, { - selModel: sm, - viewConfig: { - trackOver: false, - }, - emptyText: Ext.String.format(gettext('No {0} rules configured.'), me.ruleTitle), - tbar: [ - { - text: gettext('Add'), - handler: () => createRuleEditWindow(), - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: runEditor, - }, - { - xtype: 'proxmoxStdRemoveButton', - selModel: sm, - baseurl: '/cluster/ha/rules/', - callback: reloadStore, - }, - ], - columns: [ - { - header: gettext('ID'), - dataIndex: 'rule', - width: 160, - hidden: true, - sortable: true, - }, - { - header: gettext('Enabled'), - width: 80, - dataIndex: 'disable', - align: 'center', - renderer: (value) => Proxmox.Utils.renderEnabledIcon(!value), - sortable: true, - }, - { - header: gettext('State'), - xtype: 'actioncolumn', - width: 65, - align: 'center', - dataIndex: 'errors', - items: [ - { - handler: (table, rowIndex, colIndex, item, event, { data }) => { - if (Object.keys(data.errors ?? {}.length)) { - Ext.create('PVE.ha.RuleErrorsModal', { - autoShow: true, - errors: data.errors, - }); - } - }, - getTip: (value) => - Object.keys(value ?? {}).length - ? gettext('HA Rule has conflicts and/or errors.') - : gettext('HA Rule is OK.'), - getClass: (value) => - Object.keys(value ?? {}).length - ? 'fa fa-exclamation-triangle' - : 'fa fa-check pmx-unclickable', - }, - ], - }, - ...childColumns, - { - header: gettext('Comment'), - flex: 1, - renderer: Ext.String.htmlEncode, - dataIndex: 'comment', - }, - ], - listeners: { - activate: reloadStore, - itemdblclick: runEditor, - }, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.ha.RulesView', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveHARulesView', - - onlineHelp: 'ha_manager_rules', - - layout: { - type: 'vbox', - align: 'stretch', - }, - - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - view.store = new Ext.data.Store({ - model: 'pve-ha-rules', - storeId: 'pve-ha-rules', - autoLoad: true, - }); - view.store.on('load', this.onStoreLoad, this); - }, - - onStoreLoad: function (store, records, success) { - let me = this; - let view = me.getView(); - - for (const grid of view.query('grid[ruleType]')) { - grid.getStore().setRecords(records); - } - }, - }, - - items: [ - { - title: gettext('HA Node Affinity Rules'), - xtype: 'pveHANodeAffinityRulesView', - flex: 2, - border: 0, - }, - { - xtype: 'splitter', - collapsible: false, - performCollapse: false, - }, - { - title: gettext('HA Resource Affinity Rules'), - xtype: 'pveHAResourceAffinityRulesView', - flex: 3, - border: 0, - }, - ], -}); -Ext.define('PVE.ha.Status', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveHAStatus', - - onlineHelp: 'chapter_ha_manager', - layout: { - type: 'vbox', - align: 'stretch', - }, - - initComponent: function () { - var me = this; - - me.rstore = Ext.create('Proxmox.data.ObjectStore', { - interval: me.interval, - model: 'pve-ha-status', - storeid: 'pve-store-' + ++Ext.idSeed, - groupField: 'type', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/ha/status/current', - }, - }); - - me.items = [ - { - xtype: 'pveHAStatusView', - title: gettext('Status'), - rstore: me.rstore, - border: 0, - collapsible: true, - padding: '0 0 20 0', - }, - { - xtype: 'pveHAResourcesView', - flex: 1, - collapsible: true, - title: gettext('Resources'), - border: 0, - rstore: me.rstore, - }, - ]; - - me.callParent(); - me.on('activate', me.rstore.startUpdate); - }, -}); -Ext.define( - 'PVE.ha.StatusView', - { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveHAStatusView'], - - onlineHelp: 'chapter_ha_manager', - - sortPriority: { - quorum: 1, - master: 2, - lrm: 3, - service: 4, - }, - - initComponent: function () { - var me = this; - - if (!me.rstore) { - throw 'no rstore given'; - } - - Proxmox.Utils.monStoreErrors(me, me.rstore); - - var store = Ext.create('Proxmox.data.DiffStore', { - rstore: me.rstore, - sortAfterUpdate: true, - sorters: [ - { - sorterFn: function (rec1, rec2) { - var p1 = me.sortPriority[rec1.data.type]; - var p2 = me.sortPriority[rec2.data.type]; - return p1 !== p2 ? (p1 > p2 ? 1 : -1) : 0; - }, - }, - ], - filters: { - property: 'type', - value: 'service', - operator: '!=', - }, - }); - - Ext.apply(me, { - store: store, - stateful: false, - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: gettext('Type'), - width: 80, - dataIndex: 'type', - }, - { - header: gettext('Status'), - width: 80, - flex: 1, - dataIndex: 'status', - }, - ], - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - }, - }, - function () { - Ext.define('pve-ha-status', { - extend: 'Ext.data.Model', - fields: [ - 'id', - 'type', - 'node', - 'status', - 'sid', - 'state', - 'comment', - { - name: 'failback', - type: 'boolean', - }, - 'max_restart', - 'max_relocate', - 'type', - 'crm_state', - 'request_state', - { - name: 'vname', - convert: function (value, record) { - let sid = record.data.sid; - if (!sid) { - return ''; - } - - let res = sid.match(/^(\S+):(\S+)$/); - if (res[1] !== 'vm' && res[1] !== 'ct') { - return '-'; - } - let vmid = res[2]; - return PVE.data.ResourceStore.guestName(vmid); - }, - }, - ], - idProperty: 'id', - }); - }, -); -Ext.define('PVE.ha.rules.NodeAffinityInputPanel', { - extend: 'PVE.ha.RuleInputPanel', - - initComponent: function () { - let me = this; - - /* TODO Node selector should be factored out in its own component */ - let update_nodefield, update_node_selection; - - let sm = Ext.create('Ext.selection.CheckboxModel', { - mode: 'SIMPLE', - listeners: { - selectionchange: function (model, selected) { - update_nodefield(selected); - }, - }, - }); - - let store = Ext.create('Ext.data.Store', { - fields: ['node', 'mem', 'cpu', 'priority'], - data: PVE.data.ResourceStore.getNodes(), // use already cached data to avoid an API call - proxy: { - type: 'memory', - reader: { type: 'json' }, - }, - sorters: [ - { - property: 'node', - direction: 'ASC', - }, - ], - }); - - var nodegrid = Ext.createWidget('grid', { - store: store, - border: true, - height: 300, - selModel: sm, - columns: [ - { - header: gettext('Node'), - flex: 1, - dataIndex: 'node', - }, - { - header: gettext('Memory usage') + ' %', - renderer: PVE.Utils.render_mem_usage_percent, - sortable: true, - width: 150, - dataIndex: 'mem', - }, - { - header: gettext('CPU usage'), - renderer: Proxmox.Utils.render_cpu, - sortable: true, - width: 150, - dataIndex: 'cpu', - }, - { - header: gettext('Priority'), - xtype: 'widgetcolumn', - dataIndex: 'priority', - sortable: true, - stopSelection: true, - widget: { - xtype: 'proxmoxintegerfield', - minValue: 0, - maxValue: 1000, - isFormField: false, - listeners: { - change: function (numberfield, value, old_value) { - let record = numberfield.getWidgetRecord(); - record.set('priority', value); - update_nodefield(sm.getSelection()); - record.commit(); - }, - }, - }, - }, - ], - }); - - let nodefield = Ext.create('Ext.form.field.Hidden', { - name: 'nodes', - value: '', - listeners: { - change: function (field, value) { - update_node_selection(value); - }, - }, - isValid: function () { - let value = this.getValue(); - return value && value.length !== 0; - }, - }); - - update_node_selection = function (string) { - sm.deselectAll(true); - - string.split(',').forEach(function (e, idx, array) { - let [node, priority] = e.split(':'); - store.each(function (record) { - if (record.get('node') === node) { - sm.select(record, true); - record.set('priority', priority); - record.commit(); - } - }); - }); - nodegrid.reconfigure(store); - }; - - update_nodefield = function (selected) { - let nodes = selected - .map(({ data }) => data.node + (data.priority ? `:${data.priority}` : '')) - .join(','); - - // nodefield change listener calls us again, which results in a - // endless recursion, suspend the event temporary to avoid this - nodefield.suspendEvent('change'); - nodefield.setValue(nodes); - nodefield.resumeEvent('change'); - }; - - me.column2 = [ - { - xtype: 'proxmoxcheckbox', - name: 'strict', - fieldLabel: gettext('Strict'), - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Enable if the HA Resources must be restricted to the nodes.', - ), - }, - uncheckedValue: 0, - defaultValue: 0, - }, - nodefield, - ]; - - me.columnB = [nodegrid]; - - me.callParent(); - }, -}); -Ext.define('PVE.ha.NodeAffinityRulesView', { - extend: 'PVE.ha.RulesBaseView', - alias: 'widget.pveHANodeAffinityRulesView', - - ruleType: 'node-affinity', - ruleTitle: gettext('HA Node Affinity'), - inputPanel: 'NodeAffinityInputPanel', - faIcon: 'map-pin', - - stateful: true, - stateId: 'grid-ha-node-affinity-rules', - - columns: [ - { - header: gettext('Strict'), - width: 75, - dataIndex: 'strict', - }, - { - header: gettext('HA Resources'), - flex: 3, - dataIndex: 'resources', - }, - { - header: gettext('Nodes'), - flex: 2, - dataIndex: 'nodes', - }, - ], -}); -Ext.define('PVE.ha.rules.ResourceAffinityInputPanel', { - extend: 'PVE.ha.RuleInputPanel', - - initComponent: function () { - let me = this; - - me.column1 = []; - - me.column2 = [ - { - xtype: 'proxmoxKVComboBox', - name: 'affinity', - fieldLabel: gettext('Affinity'), - allowBlank: false, - comboItems: [ - ['positive', gettext('Keep Together')], - ['negative', gettext('Keep Separate')], - ], - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.ha.ResourceAffinityRulesView', { - extend: 'PVE.ha.RulesBaseView', - alias: 'widget.pveHAResourceAffinityRulesView', - - ruleType: 'resource-affinity', - ruleTitle: gettext('HA Resource Affinity'), - inputPanel: 'ResourceAffinityInputPanel', - faIcon: 'link', - - stateful: true, - stateId: 'grid-ha-resource-affinity-rules', - - columns: [ - { - header: gettext('Affinity'), - width: 100, - dataIndex: 'affinity', - }, - { - header: gettext('HA Resources'), - flex: 5, - dataIndex: 'resources', - }, - ], -}); -Ext.define('PVE.dc.ACLAdd', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveACLAdd'], - - url: '/access/acl', - method: 'PUT', - isAdd: true, - isCreate: true, - - width: 400, - - initComponent: function () { - let me = this; - - let items = [ - { - xtype: me.path ? 'hiddenfield' : 'pvePermPathSelector', - name: 'path', - value: me.path, - allowBlank: false, - fieldLabel: gettext('Path'), - }, - ]; - - if (me.aclType === 'group') { - me.subject = gettext('Group Permission'); - items.push({ - xtype: 'pveGroupSelector', - name: 'groups', - fieldLabel: gettext('Group'), - }); - } else if (me.aclType === 'user') { - me.subject = gettext('User Permission'); - items.push({ - xtype: 'pmxUserSelector', - name: 'users', - fieldLabel: gettext('User'), - }); - } else if (me.aclType === 'token') { - me.subject = gettext('API Token Permission'); - items.push({ - xtype: 'pveTokenSelector', - name: 'tokens', - fieldLabel: gettext('API Token'), - }); - } else { - throw 'unknown ACL type'; - } - - items.push({ - xtype: 'pmxRoleSelector', - name: 'roles', - value: 'NoAccess', - fieldLabel: gettext('Role'), - }); - - if (!me.path) { - items.push({ - xtype: 'proxmoxcheckbox', - name: 'propagate', - checked: true, - uncheckedValue: 0, - fieldLabel: gettext('Propagate'), - }); - } - - let ipanel = Ext.create('Proxmox.panel.InputPanel', { - items: items, - onlineHelp: 'pveum_permission_management', - }); - - Ext.apply(me, { - items: [ipanel], - }); - - me.callParent(); - }, -}); - -Ext.define( - 'PVE.dc.ACLView', - { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveACLView'], - - onlineHelp: 'chapter_user_management', - - stateful: true, - stateId: 'grid-acls', - - // use fixed path - path: undefined, - - initComponent: function () { - let me = this; - - let store = Ext.create('Ext.data.Store', { - model: 'pve-acl', - proxy: { - type: 'proxmox', - url: '/api2/json/access/acl', - }, - sorters: { - property: 'path', - direction: 'ASC', - }, - }); - - if (me.path) { - store.addFilter( - Ext.create('Ext.util.Filter', { - filterFn: (item) => item.data.path === me.path, - }), - ); - } - - let render_ugid = function (ugid, metaData, record) { - if (record.data.type === 'group') { - return '@' + ugid; - } - - return Ext.String.htmlEncode(ugid); - }; - - let columns = [ - { - header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'), - flex: 1, - sortable: true, - renderer: render_ugid, - dataIndex: 'ugid', - }, - { - header: gettext('Role'), - flex: 1, - sortable: true, - dataIndex: 'roleid', - }, - ]; - - if (!me.path) { - columns.unshift({ - header: gettext('Path'), - flex: 1, - sortable: true, - dataIndex: 'path', - }); - columns.push({ - header: gettext('Propagate'), - width: 80, - sortable: true, - dataIndex: 'propagate', - }); - } - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let remove_btn = new Proxmox.button.Button({ - text: gettext('Remove'), - disabled: true, - selModel: sm, - confirmMsg: gettext('Are you sure you want to remove this entry'), - handler: function (btn, event, rec) { - var params = { - delete: 1, - path: rec.data.path, - roles: rec.data.roleid, - }; - if (rec.data.type === 'group') { - params.groups = rec.data.ugid; - } else if (rec.data.type === 'user') { - params.users = rec.data.ugid; - } else if (rec.data.type === 'token') { - params.tokens = rec.data.ugid; - } else { - throw 'unknown data type'; - } - - Proxmox.Utils.API2Request({ - url: '/access/acl', - params: params, - method: 'PUT', - waitMsgTarget: me, - callback: () => store.load(), - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, - }); - - Proxmox.Utils.monStoreErrors(me, store); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: [ - { - text: gettext('Add'), - menu: { - xtype: 'menu', - items: [ - { - text: gettext('Group Permission'), - iconCls: 'fa fa-fw fa-group', - handler: function () { - var win = Ext.create('PVE.dc.ACLAdd', { - aclType: 'group', - path: me.path, - }); - win.on('destroy', () => store.load()); - win.show(); - }, - }, - { - text: gettext('User Permission'), - iconCls: 'fa fa-fw fa-user', - handler: function () { - var win = Ext.create('PVE.dc.ACLAdd', { - aclType: 'user', - path: me.path, - }); - win.on('destroy', () => store.load()); - win.show(); - }, - }, - { - text: gettext('API Token Permission'), - iconCls: 'fa fa-fw fa-user-o', - handler: function () { - let win = Ext.create('PVE.dc.ACLAdd', { - aclType: 'token', - path: me.path, - }); - win.on('destroy', () => store.load()); - win.show(); - }, - }, - ], - }, - }, - remove_btn, - ], - viewConfig: { - trackOver: false, - }, - columns: columns, - listeners: { - activate: () => store.load(), - }, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-acl', { - extend: 'Ext.data.Model', - fields: [ - 'path', - 'type', - 'ugid', - 'roleid', - { - name: 'propagate', - type: 'boolean', - }, - ], - }); - }, -); -Ext.define('pve-acme-accounts', { - extend: 'Ext.data.Model', - fields: ['name'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/acme/account', - }, - idProperty: 'name', -}); - -Ext.define('pve-acme-plugins', { - extend: 'Ext.data.Model', - fields: ['type', 'plugin', 'api'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/acme/plugins', - }, - idProperty: 'plugin', -}); - -Ext.define('PVE.dc.ACMEClusterView', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveACMEClusterView', - - onlineHelp: 'sysadmin_certificate_management', - - items: [ - { - region: 'north', - border: false, - xtype: 'pmxACMEAccounts', - acmeUrl: '/cluster/acme', - }, - { - region: 'center', - border: false, - xtype: 'pmxACMEPluginView', - acmeUrl: '/cluster/acme', - }, - ], -}); -Ext.define('PVE.panel.AuthBase', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveAuthBasePanel', - - type: '', - - onGetValues: function (values) { - let me = this; - - if (!values.port) { - if (!me.isCreate) { - Proxmox.Utils.assemble_field_data(values, { delete: 'port' }); - } - delete values.port; - } - - if (me.isCreate) { - values.type = me.type; - } - - return values; - }, - - initComponent: function () { - let me = this; - - let options = PVE.Utils.authSchema[me.type]; - - if (!me.column1) { - me.column1 = []; - } - if (!me.column2) { - me.column2 = []; - } - if (!me.columnB) { - me.columnB = []; - } - - // first field is name - me.column1.unshift({ - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'realm', - fieldLabel: gettext('Realm'), - value: me.realm, - allowBlank: false, - }); - - // last field is default' - me.column1.push({ - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Default'), - name: 'default', - uncheckedValue: 0, - }); - - if (options.tfa) { - // last field of column2is tfa - me.column2.push({ - xtype: 'pveTFASelector', - deleteEmpty: !me.isCreate, - }); - } - - me.columnB.push({ - xtype: 'textfield', - name: 'comment', - fieldLabel: gettext('Comment'), - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.dc.AuthEditBase', { - extend: 'Proxmox.window.Edit', - - onlineHelp: 'pveum_authentication_realms', - - isAdd: true, - - fieldDefaults: { - labelWidth: 120, - }, - - initComponent: function () { - var me = this; - - me.isCreate = !me.realm; - - if (me.isCreate) { - me.url = '/api2/extjs/access/domains'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/access/domains/' + me.realm; - me.method = 'PUT'; - } - - let authConfig = PVE.Utils.authSchema[me.authType]; - if (!authConfig) { - throw 'unknown auth type'; - } else if (!authConfig.add && me.isCreate) { - throw 'trying to add non addable realm'; - } - - me.subject = authConfig.name; - - let items; - let bodyPadding; - if (authConfig.syncipanel) { - bodyPadding = 0; - items = { - xtype: 'tabpanel', - region: 'center', - layout: 'fit', - bodyPadding: 10, - items: [ - { - title: gettext('General'), - realm: me.realm, - xtype: authConfig.ipanel, - isCreate: me.isCreate, - type: me.authType, - }, - { - title: gettext('Sync Options'), - realm: me.realm, - xtype: authConfig.syncipanel, - isCreate: me.isCreate, - type: me.authType, - }, - ], - }; - } else { - items = [ - { - realm: me.realm, - xtype: authConfig.ipanel, - isCreate: me.isCreate, - type: me.authType, - }, - ]; - } - - Ext.apply(me, { - items, - bodyPadding, - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var data = response.result.data || {}; - // just to be sure (should not happen) - if (data.type !== me.authType) { - me.close(); - throw 'got wrong auth type'; - } - me.setValues(data); - }, - }); - } - }, -}); -Ext.define('PVE.panel.ADInputPanel', { - extend: 'PVE.panel.AuthBase', - xtype: 'pveAuthADPanel', - - initComponent: function () { - let me = this; - - if (me.type !== 'ad') { - throw 'invalid type'; - } - - me.column1 = [ - { - xtype: 'textfield', - name: 'domain', - fieldLabel: gettext('Domain'), - emptyText: 'company.net', - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Case-Sensitive'), - name: 'case-sensitive', - uncheckedValue: 0, - checked: true, - }, - ]; - - me.column2 = [ - { - xtype: 'textfield', - fieldLabel: gettext('Server'), - name: 'server1', - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Fallback Server'), - deleteEmpty: !me.isCreate, - name: 'server2', - }, - { - xtype: 'proxmoxintegerfield', - name: 'port', - fieldLabel: gettext('Port'), - minValue: 1, - maxValue: 65535, - emptyText: gettext('Default'), - submitEmptyText: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'mode', - fieldLabel: gettext('Mode'), - editable: false, - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'], - ['ldap', 'LDAP'], - ['ldap+starttls', 'STARTTLS'], - ['ldaps', 'LDAPS'], - ], - value: '__default__', - deleteEmpty: !me.isCreate, - listeners: { - change: function (field, newValue) { - let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]'); - if (newValue === 'ldap' || newValue === '__default__') { - verifyCheckbox.disable(); - verifyCheckbox.setValue(0); - } else { - verifyCheckbox.enable(); - } - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Verify Certificate'), - name: 'verify', - uncheckedValue: 0, - disabled: true, - checked: false, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Verify TLS certificate of the server'), - }, - }, - ]; - - me.advancedItems = [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Check connection'), - name: 'check-connection', - uncheckedValue: 0, - checked: true, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Verify connection parameters and bind credentials on save', - ), - }, - }, - ]; - - me.callParent(); - }, - onGetValues: function (values) { - let me = this; - - if (!values.verify) { - if (!me.isCreate) { - Proxmox.Utils.assemble_field_data(values, { delete: 'verify' }); - } - delete values.verify; - } - - if (!me.isCreate) { - // Delete old `secure` parameter. It has been deprecated in favor to the - // `mode` parameter. Migration happens automatically in `onSetValues`. - Proxmox.Utils.assemble_field_data(values, { delete: 'secure' }); - } - - return me.callParent([values]); - }, - - onSetValues(values) { - let me = this; - - if (values.secure !== undefined && !values.mode) { - // If `secure` is set, use it to determine the correct setting for `mode` - // `secure` is later deleted by `onSetValues` . - // In case *both* are set, we simply ignore `secure` and use - // whatever `mode` is set to. - values.mode = values.secure ? 'ldaps' : 'ldap'; - } - - return me.callParent([values]); - }, -}); -Ext.define('PVE.panel.LDAPInputPanel', { - extend: 'PVE.panel.AuthBase', - xtype: 'pveAuthLDAPPanel', - - initComponent: function () { - let me = this; - - if (me.type !== 'ldap') { - throw 'invalid type'; - } - - me.column1 = [ - { - xtype: 'textfield', - name: 'base_dn', - fieldLabel: gettext('Base Domain Name'), - emptyText: 'CN=Users,DC=Company,DC=net', - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'user_attr', - emptyText: 'uid / sAMAccountName', - fieldLabel: gettext('User Attribute Name'), - allowBlank: false, - }, - ]; - - me.column2 = [ - { - xtype: 'textfield', - fieldLabel: gettext('Server'), - name: 'server1', - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Fallback Server'), - deleteEmpty: !me.isCreate, - name: 'server2', - }, - { - xtype: 'proxmoxintegerfield', - name: 'port', - fieldLabel: gettext('Port'), - minValue: 1, - maxValue: 65535, - emptyText: gettext('Default'), - submitEmptyText: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'mode', - fieldLabel: gettext('Mode'), - editable: false, - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'], - ['ldap', 'LDAP'], - ['ldap+starttls', 'STARTTLS'], - ['ldaps', 'LDAPS'], - ], - value: '__default__', - deleteEmpty: !me.isCreate, - listeners: { - change: function (field, newValue) { - let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]'); - if (newValue === 'ldap' || newValue === '__default__') { - verifyCheckbox.disable(); - verifyCheckbox.setValue(0); - } else { - verifyCheckbox.enable(); - } - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Verify Certificate'), - name: 'verify', - uncheckedValue: 0, - disabled: true, - checked: false, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Verify TLS certificate of the server'), - }, - }, - ]; - - me.advancedItems = [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Check connection'), - name: 'check-connection', - uncheckedValue: 0, - checked: true, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Verify connection parameters and bind credentials on save', - ), - }, - }, - ]; - - me.callParent(); - }, - onGetValues: function (values) { - let me = this; - - if (!values.verify) { - if (!me.isCreate) { - Proxmox.Utils.assemble_field_data(values, { delete: 'verify' }); - } - delete values.verify; - } - - if (!me.isCreate) { - // Delete old `secure` parameter. It has been deprecated in favor to the - // `mode` parameter. Migration happens automatically in `onSetValues`. - Proxmox.Utils.assemble_field_data(values, { delete: 'secure' }); - } - - return me.callParent([values]); - }, - - onSetValues(values) { - let me = this; - - if (values.secure !== undefined && !values.mode) { - // If `secure` is set, use it to determine the correct setting for `mode` - // `secure` is later deleted by `onSetValues` . - // In case *both* are set, we simply ignore `secure` and use - // whatever `mode` is set to. - values.mode = values.secure ? 'ldaps' : 'ldap'; - } - - return me.callParent([values]); - }, -}); - -Ext.define('PVE.panel.LDAPSyncInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveAuthLDAPSyncPanel', - - editableAttributes: ['email'], - editableDefaults: ['scope', 'enable-new'], - default_opts: {}, - sync_attributes: {}, - - // (de)construct the sync-attributes from the list above, - // not touching all others - onGetValues: function (values) { - let me = this; - me.editableDefaults.forEach((attr) => { - if (values[attr]) { - me.default_opts[attr] = values[attr]; - delete values[attr]; - } else { - delete me.default_opts[attr]; - } - }); - let vanished_opts = []; - ['acl', 'entry', 'properties'].forEach((prop) => { - if (values[`remove-vanished-${prop}`]) { - vanished_opts.push(prop); - } - delete values[`remove-vanished-${prop}`]; - }); - me.default_opts['remove-vanished'] = vanished_opts.join(';'); - - values['sync-defaults-options'] = PVE.Parser.printPropertyString(me.default_opts); - me.editableAttributes.forEach((attr) => { - if (values[attr]) { - me.sync_attributes[attr] = values[attr]; - delete values[attr]; - } else { - delete me.sync_attributes[attr]; - } - }); - values.sync_attributes = PVE.Parser.printPropertyString(me.sync_attributes); - - PVE.Utils.delete_if_default(values, 'sync-defaults-options'); - PVE.Utils.delete_if_default(values, 'sync_attributes'); - - // Force values.delete to be an array - if (typeof values.delete === 'string') { - values.delete = values.delete.split(','); - } - - if (me.isCreate) { - delete values.delete; // on create we cannot delete values - } - - return values; - }, - - setValues: function (values) { - let me = this; - if (values.sync_attributes) { - me.sync_attributes = PVE.Parser.parsePropertyString(values.sync_attributes); - delete values.sync_attributes; - me.editableAttributes.forEach((attr) => { - if (me.sync_attributes[attr]) { - values[attr] = me.sync_attributes[attr]; - } - }); - } - if (values['sync-defaults-options']) { - me.default_opts = PVE.Parser.parsePropertyString(values['sync-defaults-options']); - delete values.default_opts; - me.editableDefaults.forEach((attr) => { - if (me.default_opts[attr]) { - values[attr] = me.default_opts[attr]; - } - }); - - if (me.default_opts['remove-vanished']) { - let opts = me.default_opts['remove-vanished'].split(';'); - for (const opt of opts) { - values[`remove-vanished-${opt}`] = 1; - } - } - } - return me.callParent([values]); - }, - - column1: [ - { - xtype: 'proxmoxtextfield', - name: 'bind_dn', - deleteEmpty: true, - emptyText: Proxmox.Utils.noneText, - fieldLabel: gettext('Bind User'), - }, - { - xtype: 'proxmoxtextfield', - inputType: 'password', - name: 'password', - emptyText: gettext('Unchanged'), - fieldLabel: gettext('Bind Password'), - }, - { - xtype: 'proxmoxtextfield', - name: 'email', - fieldLabel: gettext('E-Mail attribute'), - }, - { - xtype: 'proxmoxtextfield', - name: 'group_name_attr', - deleteEmpty: true, - fieldLabel: gettext('Groupname attr.'), - }, - { - xtype: 'displayfield', - value: gettext('Default Sync Options'), - }, - { - xtype: 'proxmoxKVComboBox', - name: 'scope', - emptyText: Proxmox.Utils.NoneText, - fieldLabel: gettext('Scope'), - value: '__default__', - deleteEmpty: false, - comboItems: [ - ['__default__', Proxmox.Utils.NoneText], - ['users', gettext('Users')], - ['groups', gettext('Groups')], - ['both', gettext('Users and Groups')], - ], - }, - ], - - column2: [ - { - xtype: 'proxmoxtextfield', - name: 'user_classes', - fieldLabel: gettext('User classes'), - deleteEmpty: true, - emptyText: 'inetorgperson, posixaccount, person, user', - }, - { - xtype: 'proxmoxtextfield', - name: 'group_classes', - fieldLabel: gettext('Group classes'), - deleteEmpty: true, - emptyText: 'groupOfNames, group, univentionGroup, ipausergroup', - }, - { - xtype: 'proxmoxtextfield', - name: 'filter', - fieldLabel: gettext('User Filter'), - deleteEmpty: true, - }, - { - xtype: 'proxmoxtextfield', - name: 'group_filter', - fieldLabel: gettext('Group Filter'), - deleteEmpty: true, - }, - { - // fake for spacing - xtype: 'displayfield', - value: ' ', - }, - { - xtype: 'proxmoxKVComboBox', - value: '__default__', - deleteEmpty: false, - comboItems: [ - [ - '__default__', - Ext.String.format( - gettext('{0} ({1})'), - Proxmox.Utils.yesText, - Proxmox.Utils.defaultText, - ), - ], - ['1', Proxmox.Utils.yesText], - ['0', Proxmox.Utils.noText], - ], - name: 'enable-new', - fieldLabel: gettext('Enable new users'), - }, - ], - - columnB: [ - { - xtype: 'fieldset', - title: gettext('Remove Vanished Options'), - items: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('ACL'), - name: 'remove-vanished-acl', - boxLabel: gettext('Remove ACLs of vanished users and groups.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Entry'), - name: 'remove-vanished-entry', - boxLabel: gettext('Remove vanished user and group entries.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Properties'), - name: 'remove-vanished-properties', - boxLabel: gettext('Remove vanished properties from synced users.'), - }, - ], - }, - ], -}); -Ext.define('PVE.panel.OpenIDInputPanel', { - extend: 'PVE.panel.AuthBase', - xtype: 'pveAuthOpenIDPanel', - mixins: ['Proxmox.Mixin.CBind'], - - onGetValues: function (values) { - let me = this; - - if (!values.verify) { - if (!me.isCreate) { - Proxmox.Utils.assemble_field_data(values, { delete: 'verify' }); - } - delete values.verify; - } - - return me.callParent([values]); - }, - - columnT: [ - { - xtype: 'textfield', - name: 'issuer-url', - fieldLabel: gettext('Issuer URL'), - allowBlank: false, - }, - ], - - column1: [ - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Client ID'), - name: 'client-id', - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Client Key'), - cbind: { - deleteEmpty: '{!isCreate}', - }, - name: 'client-key', - }, - { - xtype: 'proxmoxtextfield', - name: 'scopes', - fieldLabel: gettext('Scopes'), - emptyText: `${Proxmox.Utils.defaultText} (email profile)`, - submitEmpty: false, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - - column2: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Autocreate Users'), - name: 'autocreate', - value: 0, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'pmxDisplayEditField', - name: 'username-claim', - fieldLabel: gettext('Username Claim'), - editConfig: { - xtype: 'proxmoxKVComboBox', - editable: true, - comboItems: [ - ['__default__', Proxmox.Utils.defaultText], - ['subject', 'subject'], - ['username', 'username'], - ['email', 'email'], - ], - }, - cbind: { - value: (get) => (get('isCreate') ? '__default__' : Proxmox.Utils.defaultText), - deleteEmpty: '{!isCreate}', - editable: '{isCreate}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Autocreate Groups'), - name: 'groups-autocreate', - value: 0, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'groups-claim', - fieldLabel: gettext('Groups Claim'), - emptyText: `${Proxmox.Utils.defaultText} ${gettext('(none)')}`, - submitEmpty: false, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Overwrite Groups'), - name: 'groups-overwrite', - value: 0, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'prompt', - fieldLabel: gettext('Prompt'), - editable: true, - emptyText: gettext('Auth-Provider Default'), - comboItems: [ - ['__default__', gettext('Auth-Provider Default')], - ['none', 'none'], - ['login', 'login'], - ['consent', 'consent'], - ['select_account', 'select_account'], - ], - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - - advancedColumnB: [ - { - xtype: 'proxmoxtextfield', - name: 'acr-values', - fieldLabel: gettext('ACR Values'), - submitEmpty: false, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Query userinfo endpoint'), - name: 'query-userinfo', - checked: true, - uncheckedValue: 0, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - - initComponent: function () { - let me = this; - - if (me.type !== 'openid') { - throw 'invalid type'; - } - - me.callParent(); - }, -}); -Ext.define('PVE.dc.AuthView', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveAuthView'], - - onlineHelp: 'pveum_authentication_realms', - - stateful: true, - stateId: 'grid-authrealms', - - viewConfig: { - trackOver: false, - }, - - columns: [ - { - header: gettext('Realm'), - width: 100, - sortable: true, - dataIndex: 'realm', - }, - { - header: gettext('Type'), - width: 100, - sortable: true, - dataIndex: 'type', - }, - { - header: gettext('TFA'), - width: 100, - sortable: true, - dataIndex: 'tfa', - }, - { - header: gettext('Comment'), - sortable: false, - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - - store: { - model: 'pmx-domains', - sorters: { - property: 'realm', - direction: 'ASC', - }, - }, - - openEditWindow: function (authType, realm) { - let me = this; - Ext.create('PVE.dc.AuthEditBase', { - authType, - realm, - listeners: { - destroy: () => me.reload(), - }, - }).show(); - }, - - reload: function () { - let me = this; - me.getStore().load(); - }, - - run_editor: function () { - let me = this; - let rec = me.getSelection()[0]; - if (!rec) { - return; - } - me.openEditWindow(rec.data.type, rec.data.realm); - }, - - open_sync_window: function () { - let me = this; - let rec = me.getSelection()[0]; - if (!rec) { - return; - } - Ext.create('PVE.dc.SyncWindow', { - realm: rec.data.realm, - listeners: { - destroy: () => me.reload(), - }, - }).show(); - }, - - initComponent: function () { - var me = this; - - let items = []; - for (const [authType, config] of Object.entries(PVE.Utils.authSchema)) { - if (!config.add) { - continue; - } - items.push({ - text: config.name, - iconCls: 'fa fa-fw ' + (config.iconCls || 'fa-address-book-o'), - handler: () => me.openEditWindow(authType), - }); - } - - Ext.apply(me, { - tbar: [ - { - text: gettext('Add'), - menu: { - items: items, - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - disabled: true, - handler: () => me.run_editor(), - }, - { - xtype: 'proxmoxStdRemoveButton', - baseurl: '/access/domains/', - enableFn: (rec) => PVE.Utils.authSchema[rec.data.type].add, - callback: () => me.reload(), - }, - '-', - { - xtype: 'proxmoxButton', - text: gettext('Sync'), - disabled: true, - enableFn: (rec) => Boolean(PVE.Utils.authSchema[rec.data.type].syncipanel), - handler: () => me.open_sync_window(), - }, - ], - listeners: { - itemdblclick: () => me.run_editor(), - }, - }); - - me.callParent(); - me.reload(); - }, -}); -Ext.define('PVE.dc.BackupDiskTree', { - extend: 'Ext.tree.Panel', - alias: 'widget.pveBackupDiskTree', - - folderSort: true, - rootVisible: false, - - store: { - sorters: 'id', - data: {}, - }, - - tools: [ - { - type: 'expand', - tooltip: gettext('Expand All'), - callback: (panel) => panel.expandAll(), - }, - { - type: 'collapse', - tooltip: gettext('Collapse All'), - callback: (panel) => panel.collapseAll(), - }, - ], - - columns: [ - { - xtype: 'treecolumn', - text: gettext('Guest Image'), - renderer: function (value, meta, record) { - if (record.data.type) { - // guest level - let ret = value; - if (record.data.name) { - ret += ' (' + record.data.name + ')'; - } - return ret; - } else { - // extJS needs unique IDs but we only want to show the volumes key from "vmid:key" - return value.split(':')[1] + ' - ' + record.data.name; - } - }, - dataIndex: 'id', - flex: 6, - }, - { - text: gettext('Type'), - dataIndex: 'type', - flex: 1, - }, - { - text: gettext('Backup Job'), - renderer: PVE.Utils.render_backup_status, - dataIndex: 'included', - flex: 3, - }, - ], - - reload: function () { - let me = this; - let sm = me.getSelectionModel(); - - Proxmox.Utils.API2Request({ - url: `/cluster/backup/${me.jobid}/included_volumes`, - waitMsgTarget: me, - method: 'GET', - failure: function (response, opts) { - Proxmox.Utils.setErrorMask(me, response.htmlStatus); - }, - success: function (response, opts) { - sm.deselectAll(); - me.setRootNode(response.result.data); - me.expandAll(); - }, - }); - }, - - initComponent: function () { - var me = this; - - if (!me.jobid) { - throw 'no job id specified'; - } - - var sm = Ext.create('Ext.selection.TreeModel', {}); - - Ext.apply(me, { - selModel: sm, - fields: [ - 'id', - 'type', - { - type: 'string', - name: 'iconCls', - calculate: function (data) { - var txt = 'fa x-fa-tree fa-'; - if (data.leaf && !data.type) { - return txt + 'hdd-o'; - } else if (data.type === 'qemu') { - return txt + 'desktop'; - } else if (data.type === 'lxc') { - return txt + 'cube'; - } else { - return txt + 'question-circle'; - } - }, - }, - ], - header: { - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('Search'), - labelWidth: 50, - emptyText: 'Name, VMID, Type', - width: 200, - padding: '0 5 0 0', - enableKeyEvents: true, - listeners: { - buffer: 500, - keyup: function (field) { - let searchValue = field.getValue().toLowerCase(); - me.store.clearFilter(true); - me.store.filterBy(function (record) { - let data = {}; - if (record.data.depth === 0) { - return true; - } else if (record.data.depth === 1) { - data = record.data; - } else if (record.data.depth === 2) { - data = record.parentNode.data; - } - - for (const property of ['name', 'id', 'type']) { - if (!data[property]) { - continue; - } - let v = data[property].toString(); - if (v !== undefined) { - v = v.toLowerCase(); - if (v.includes(searchValue)) { - return true; - } - } - } - return false; - }); - }, - }, - }, - ], - }, - }); - - me.callParent(); - - me.reload(); - }, -}); - -Ext.define('PVE.dc.BackupInfo', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveBackupInfo', - - viewModel: { - data: { - retentionType: 'none', - hideRecipients: true, - }, - formulas: { - hasRetention: (get) => get('retentionType') !== 'none', - retentionKeepAll: (get) => get('retentionType') === 'all', - }, - }, - - padding: '5 0 5 10', - - column1: [ - { - xtype: 'displayfield', - name: 'node', - fieldLabel: gettext('Node'), - renderer: (value) => value || `-- ${gettext('All')} --`, - }, - { - xtype: 'displayfield', - name: 'storage', - fieldLabel: gettext('Storage'), - }, - { - xtype: 'displayfield', - name: 'schedule', - fieldLabel: gettext('Schedule'), - }, - { - xtype: 'displayfield', - name: 'next-run', - fieldLabel: gettext('Next Run'), - renderer: PVE.Utils.render_next_event, - }, - { - xtype: 'displayfield', - name: 'selMode', - fieldLabel: gettext('Selection mode'), - }, - ], - column2: [ - { - xtype: 'displayfield', - name: 'notification-mode', - fieldLabel: gettext('Notification'), - renderer: function (value) { - value = value ?? 'auto'; - let record = this.up('pveBackupInfo')?.record; - let mailto = record?.mailto; - let mailnotification = record?.mailnotification ?? 'always'; - - if ((value === 'auto' && mailto === undefined) || value === 'notification-system') { - return gettext('Use global notification settings'); - } else if (mailnotification === 'always') { - return gettext('Always send email'); - } else { - return gettext('Send email on failure'); - } - }, - }, - { - xtype: 'displayfield', - name: 'mailto', - fieldLabel: gettext('Recipients'), - hidden: true, - bind: { - hidden: '{hideRecipients}', - }, - renderer: function (value) { - if (!value) { - return gettext('No recipients configured'); - } - - return value; - }, - }, - { - xtype: 'displayfield', - name: 'compress', - fieldLabel: gettext('Compression'), - }, - { - xtype: 'displayfield', - name: 'mode', - fieldLabel: gettext('Mode'), - renderer: function (value) { - const modeToDisplay = { - snapshot: gettext('Snapshot'), - stop: gettext('Stop'), - suspend: gettext('Suspend'), - }; - return modeToDisplay[value] ?? gettext('Unknown'); - }, - }, - { - xtype: 'displayfield', - name: 'enabled', - fieldLabel: gettext('Enabled'), - renderer: (v) => - PVE.Parser.parseBoolean(v.toString()) ? gettext('Yes') : gettext('No'), - }, - { - xtype: 'displayfield', - name: 'pool', - fieldLabel: gettext('Pool to backup'), - }, - ], - - columnB: [ - { - xtype: 'displayfield', - name: 'comment', - fieldLabel: gettext('Comment'), - renderer: Ext.String.htmlEncode, - }, - { - xtype: 'fieldset', - title: gettext('Retention Configuration'), - layout: 'hbox', - collapsible: true, - defaults: { - border: false, - layout: 'anchor', - flex: 1, - }, - bind: { - hidden: '{!hasRetention}', - }, - items: [ - { - padding: '0 10 0 0', - defaults: { - labelWidth: 110, - }, - items: [ - { - xtype: 'displayfield', - name: 'keep-all', - fieldLabel: gettext('Keep All'), - renderer: Proxmox.Utils.format_boolean, - bind: { - hidden: '{!retentionKeepAll}', - }, - }, - ].concat( - [ - ['keep-last', gettext('Keep Last')], - ['keep-hourly', gettext('Keep Hourly')], - ].map((name) => ({ - xtype: 'displayfield', - name: name[0], - fieldLabel: name[1], - bind: { - hidden: '{!hasRetention || retentionKeepAll}', - }, - })), - ), - }, - { - padding: '0 0 0 10', - defaults: { - labelWidth: 110, - }, - items: [ - ['keep-daily', gettext('Keep Daily')], - ['keep-weekly', gettext('Keep Weekly')], - ].map((name) => ({ - xtype: 'displayfield', - name: name[0], - fieldLabel: name[1], - bind: { - hidden: '{!hasRetention || retentionKeepAll}', - }, - })), - }, - { - padding: '0 0 0 10', - defaults: { - labelWidth: 110, - }, - items: [ - ['keep-monthly', gettext('Keep Monthly')], - ['keep-yearly', gettext('Keep Yearly')], - ].map((name) => ({ - xtype: 'displayfield', - name: name[0], - fieldLabel: name[1], - bind: { - hidden: '{!hasRetention || retentionKeepAll}', - }, - })), - }, - ], - }, - ], - - setValues: function (values) { - var me = this; - let vm = me.getViewModel(); - - Ext.iterate(values, function (fieldId, val) { - let field = me.query('[isFormField][name=' + fieldId + ']')[0]; - if (field) { - field.setValue(val); - } - }); - - if (values['prune-backups']) { - let keepValues; - if (values['prune-backups']) { - keepValues = values['prune-backups']; - } else { - keepValues = { 'keep-all': 1 }; - } - - vm.set('retentionType', keepValues['keep-all'] ? 'all' : 'other'); - - // set values of all keep-X fields - ['all', 'last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'].forEach((time) => { - let name = `keep-${time}`; - me.query(`[isFormField][name=${name}]`)[0]?.setValue(keepValues[name]); - }); - } else { - vm.set('retentionType', 'none'); - } - - let notificationMode = values['notification-mode'] ?? 'auto'; - let mailto = values.mailto; - - let hideRecipients = - (notificationMode === 'auto' && mailto === undefined) || - notificationMode === 'notification-system'; - vm.set('hideRecipients', hideRecipients); - - // selection Mode depends on the presence/absence of several keys - let selModeField = me.query('[isFormField][name=selMode]')[0]; - let selMode = 'none'; - if (values.vmid) { - selMode = gettext('Include selected VMs'); - } - if (values.all) { - selMode = gettext('All'); - } - if (values.exclude) { - selMode = gettext('Exclude selected VMs'); - } - if (values.pool) { - selMode = gettext('Pool based'); - } - selModeField.setValue(selMode); - - if (!values.pool) { - let poolField = me.query('[isFormField][name=pool]')[0]; - poolField.setVisible(0); - } - }, - - initComponent: function () { - var me = this; - - if (!me.record) { - throw 'no data provided'; - } - me.callParent(); - - me.setValues(me.record); - }, -}); - -Ext.define('PVE.dc.BackedGuests', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveBackedGuests', - - stateful: true, - stateId: 'grid-dc-backed-guests', - - textfilter: '', - - columns: [ - { - header: gettext('Type'), - dataIndex: 'type', - renderer: PVE.Utils.render_resource_type, - flex: 1, - sortable: true, - }, - { - header: 'VMID', - dataIndex: 'vmid', - flex: 1, - sortable: true, - }, - { - header: gettext('Name'), - dataIndex: 'name', - flex: 2, - sortable: true, - }, - ], - viewConfig: { - stripeRows: true, - trackOver: false, - }, - - initComponent: function () { - let me = this; - - me.store.clearFilter(true); - - Ext.apply(me, { - tbar: [ - '->', - gettext('Search') + ':', - ' ', - { - xtype: 'textfield', - width: 200, - emptyText: 'Name, VMID, Type', - enableKeyEvents: true, - listeners: { - buffer: 500, - keyup: function (field) { - let searchValue = field.getValue().toLowerCase(); - me.store.clearFilter(true); - me.store.filterBy(function (record) { - let data = record.data; - for (const property of ['name', 'vmid', 'type']) { - if (data[property] === null) { - continue; - } - let v = data[property].toString(); - if (v !== undefined) { - if (v.toLowerCase().includes(searchValue)) { - return true; - } - } - } - return false; - }); - }, - }, - }, - ], - }); - me.callParent(); - }, -}); -Ext.define('PVE.dc.BackupEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveDcBackupEdit'], - - mixins: ['Proxmox.Mixin.CBind'], - - defaultFocus: undefined, - - subject: gettext('Backup Job'), - width: 720, - bodyPadding: 0, - - url: '/api2/extjs/cluster/backup', - method: 'POST', - isCreate: true, - - cbindData: function () { - let me = this; - if (me.jobid) { - me.isCreate = false; - me.method = 'PUT'; - me.url += `/${me.jobid}`; - } - return {}; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - onGetValues: function (values) { - let me = this; - let isCreate = me.getView().isCreate; - if (!values.node) { - if (!isCreate) { - Proxmox.Utils.assemble_field_data(values, { delete: 'node' }); - } - delete values.node; - } - - let selMode = values.selMode; - delete values.selMode; - - if (selMode === 'all') { - values.all = 1; - values.exclude = ''; - delete values.vmid; - } else if (selMode === 'exclude') { - values.all = 1; - values.exclude = values.vmid; - delete values.vmid; - } else if (selMode === 'pool') { - delete values.vmid; - } - - if (selMode !== 'pool') { - delete values.pool; - } - return values; - }, - - nodeChange: function (f, value) { - let me = this; - me.lookup('storageSelector').setNodename(value); - let vmgrid = me.lookup('vmgrid'); - let store = vmgrid.getStore(); - - store.clearFilter(); - store.filterBy(function (rec) { - return !value || rec.get('node') === value; - }); - - let mode = me.lookup('modeSelector').getValue(); - if (mode === 'all') { - vmgrid.selModel.selectAll(true); - } - if (mode === 'pool') { - me.selectPoolMembers(); - } - }, - - storageChange: function (f, v) { - let me = this; - let rec = f.getStore().findRecord('storage', v, 0, false, true, true); - let compressionSelector = me.lookup('compressionSelector'); - - if (rec?.data?.type === 'pbs') { - compressionSelector.setValue('zstd'); - compressionSelector.setDisabled(true); - } else if (!compressionSelector.getEditable()) { - compressionSelector.setDisabled(false); - } - }, - - selectPoolMembers: function () { - let me = this; - let mode = me.lookup('modeSelector').getValue(); - - if (mode !== 'pool') { - return; - } - - let vmgrid = me.lookup('vmgrid'); - let poolid = me.lookup('poolSelector').getValue(); - - vmgrid.getSelectionModel().deselectAll(true); - if (!poolid) { - return; - } - vmgrid.getStore().filter([ - { - id: 'poolFilter', - property: 'pool', - value: poolid, - }, - ]); - vmgrid.selModel.selectAll(true); - }, - - modeChange: function (f, value, oldValue) { - let me = this; - let vmgrid = me.lookup('vmgrid'); - vmgrid.getStore().removeFilter('poolFilter'); - - if (oldValue === 'all' && value !== 'all') { - vmgrid.getSelectionModel().deselectAll(true); - } - - if (value === 'all') { - vmgrid.getSelectionModel().selectAll(true); - } - - if (value === 'pool') { - me.selectPoolMembers(); - } - }, - - compressionChange: function (f, value, oldValue) { - this.getView().lookup('backupAdvanced').updateCompression(value, f.isDisabled()); - }, - - compressionDisable: function (f) { - this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), true); - }, - - compressionEnable: function (f) { - this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), false); - }, - - prepareValues: function (data) { - let me = this; - let viewModel = me.getViewModel(); - - if (data.exclude) { - data.vmid = data.exclude; - data.selMode = 'exclude'; - } else if (data.all) { - data.vmid = ''; - data.selMode = 'all'; - } else if (data.pool) { - data.selMode = 'pool'; - data.selPool = data.pool; - } else { - data.selMode = 'include'; - } - viewModel.set('selMode', data.selMode); - - if (data['prune-backups']) { - Object.assign(data, data['prune-backups']); - delete data['prune-backups']; - } - - if (data['notes-template']) { - data['notes-template'] = PVE.Utils.unEscapeNotesTemplate(data['notes-template']); - } - - if (data.performance) { - Object.assign(data, data.performance); - delete data.performance; - } - - return data; - }, - - init: function (view) { - let me = this; - - if (view.isCreate) { - me.lookup('modeSelector').setValue('include'); - } else { - view.load({ - success: function (response, _options) { - let values = me.prepareValues(response.result.data); - view.setValues(values); - }, - }); - } - }, - }, - - viewModel: { - data: { - selMode: 'include', - }, - - formulas: { - poolMode: (get) => get('selMode') === 'pool', - disableVMSelection: (get) => - get('selMode') !== 'include' && get('selMode') !== 'exclude', - }, - }, - - items: [ - { - xtype: 'tabpanel', - region: 'center', - layout: 'fit', - bodyPadding: 10, - items: [ - { - title: gettext('General'), - xtype: 'inputpanel', - onlineHelp: 'chapter_vzdump', - column1: [ - { - xtype: 'pveNodeSelector', - name: 'node', - fieldLabel: gettext('Node'), - allowBlank: true, - editable: true, - autoSelect: false, - emptyText: '-- ' + gettext('All') + ' --', - listeners: { - change: 'nodeChange', - }, - }, - { - xtype: 'pveStorageSelector', - reference: 'storageSelector', - fieldLabel: gettext('Storage'), - clusterView: true, - storageContent: 'backup', - allowBlank: false, - name: 'storage', - listeners: { - change: 'storageChange', - }, - }, - { - xtype: 'pveCalendarEvent', - fieldLabel: gettext('Schedule'), - allowBlank: false, - name: 'schedule', - }, - { - xtype: 'proxmoxKVComboBox', - reference: 'modeSelector', - comboItems: [ - ['include', gettext('Include selected VMs')], - ['all', gettext('All')], - ['exclude', gettext('Exclude selected VMs')], - ['pool', gettext('Pool based')], - ], - fieldLabel: gettext('Selection mode'), - name: 'selMode', - value: '', - bind: { - value: '{selMode}', - }, - listeners: { - change: 'modeChange', - }, - }, - { - xtype: 'pvePoolSelector', - reference: 'poolSelector', - fieldLabel: gettext('Pool to backup'), - hidden: true, - allowBlank: false, - name: 'pool', - listeners: { - change: 'selectPoolMembers', - }, - bind: { - hidden: '{!poolMode}', - disabled: '{!poolMode}', - }, - }, - ], - column2: [ - { - xtype: 'pveBackupCompressionSelector', - reference: 'compressionSelector', - fieldLabel: gettext('Compression'), - name: 'compress', - cbind: { - deleteEmpty: '{!isCreate}', - }, - value: 'zstd', - listeners: { - change: 'compressionChange', - disable: 'compressionDisable', - enable: 'compressionEnable', - }, - }, - { - xtype: 'pveBackupModeSelector', - fieldLabel: gettext('Mode'), - value: 'snapshot', - name: 'mode', - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enable'), - name: 'enabled', - uncheckedValue: 0, - defaultValue: 1, - checked: true, - }, - ], - columnB: [ - { - xtype: 'proxmoxtextfield', - name: 'comment', - fieldLabel: gettext('Job Comment'), - cbind: { - deleteEmpty: '{!isCreate}', - }, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Description of the job'), - }, - }, - { - xtype: 'vmselector', - reference: 'vmgrid', - height: 300, - name: 'vmid', - disabled: true, - allowBlank: false, - columnSelection: ['vmid', 'node', 'status', 'name', 'type'], - bind: { - disabled: '{disableVMSelection}', - }, - }, - ], - onGetValues: function (values) { - return this.up('window').getController().onGetValues(values); - }, - }, - { - xtype: 'pveBackupNotificationOptionsPanel', - title: gettext('Notifications'), - cbind: { - isCreate: '{isCreate}', - }, - }, - { - xtype: 'pveBackupJobPrunePanel', - title: gettext('Retention'), - cbind: { - isCreate: '{isCreate}', - }, - keepAllDefaultForCreate: false, - showPBSHint: false, - fallbackHintHtml: gettext( - "Without any keep option, the storage's configuration or node's vzdump.conf is used as fallback", - ), - }, - { - xtype: 'inputpanel', - onlineHelp: 'chapter_vzdump', - title: gettext('Note Template'), - region: 'center', - layout: { - type: 'vbox', - align: 'stretch', - }, - onGetValues: function (values) { - if (values['notes-template']) { - values['notes-template'] = PVE.Utils.escapeNotesTemplate( - values['notes-template'], - ); - } - return values; - }, - items: [ - { - xtype: 'textarea', - name: 'notes-template', - fieldLabel: gettext('Backup Notes'), - height: 100, - maxLength: 512, - cbind: { - deleteEmpty: '{!isCreate}', - value: (get) => (get('isCreate') ? '{{guestname}}' : undefined), - }, - }, - { - xtype: 'box', - style: { - margin: '8px 0px', - 'line-height': '1.5em', - }, - html: - gettext('The notes are added to each backup created by this job.') + - '
    ' + - Ext.String.format( - gettext('Possible template variables are: {0}'), - PVE.Utils.notesTemplateVars - .map((v) => `{{${v}}}`) - .join(', '), - ), - }, - ], - }, - { - xtype: 'pveBackupAdvancedOptionsPanel', - onlineHelp: 'chapter_vzdump', - reference: 'backupAdvanced', - title: gettext('Advanced'), - cbind: { - isCreate: '{isCreate}', - }, - }, - ], - }, - ], -}); - -Ext.define( - 'PVE.dc.BackupView', - { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveDcBackupView'], - - onlineHelp: 'chapter_vzdump', - - allText: '-- ' + gettext('All') + ' --', - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-cluster-backup', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/backup', - }, - }); - - let not_backed_store = new Ext.data.Store({ - sorters: 'vmid', - proxy: { - type: 'proxmox', - url: 'api2/json/cluster/backup-info/not-backed-up', - }, - }); - - let noBackupJobInfoButton; - let reload = function () { - store.load(); - not_backed_store.load({ - callback: (records) => noBackupJobInfoButton.setVisible(records.length > 0), - }); - }; - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - Ext.create('PVE.dc.BackupEdit', { - autoShow: true, - jobid: rec.data.id, - listeners: { - destroy: () => reload(), - }, - }); - }; - - let run_detail = function () { - let record = sm.getSelection()[0]; - if (!record) { - return; - } - Ext.create('Ext.window.Window', { - modal: true, - width: 800, - height: Ext.getBody().getViewSize().height > 1000 ? 800 : 600, // factor out as common infra? - resizable: true, - layout: 'fit', - title: gettext('Backup Details'), - items: [ - { - xtype: 'panel', - region: 'center', - layout: { - type: 'vbox', - align: 'stretch', - }, - items: [ - { - xtype: 'pveBackupInfo', - flex: 0, - layout: 'fit', - record: record.data, - }, - { - xtype: 'pveBackupDiskTree', - title: gettext('Included disks'), - flex: 1, - jobid: record.data.id, - }, - ], - }, - ], - }).show(); - }; - - let run_backup_now = function (job) { - job = Ext.clone(job); - - let jobNode = job.node; - // Remove properties related to scheduling - delete job.enabled; - delete job.starttime; - delete job.dow; - delete job.id; - delete job.schedule; - delete job.type; - delete job.node; - delete job.comment; - delete job['next-run']; - delete job['repeat-missed']; - job.all = job.all === true ? 1 : 0; - - ['performance', 'prune-backups', 'fleecing'].forEach((key) => { - if (job[key]) { - job[key] = PVE.Parser.printPropertyString(job[key]); - } - }); - - let allNodes = PVE.data.ResourceStore.getNodes(); - let nodes = allNodes - .filter((node) => node.status === 'online') - .map((node) => node.node); - let errors = []; - - if (jobNode !== undefined) { - if (!nodes.includes(jobNode)) { - Ext.Msg.alert( - 'Error', - "Node '" + jobNode + "' from backup job isn't online!", - ); - return; - } - nodes = [jobNode]; - } else { - let unkownNodes = allNodes.filter((node) => node.status !== 'online'); - if (unkownNodes.length > 0) { - errors.push( - unkownNodes.map( - (node) => node.node + ': ' + gettext('Node is offline'), - ), - ); - } - } - let jobTotalCount = nodes.length, - jobsStarted = 0; - - Ext.Msg.show({ - title: gettext('Please wait...'), - closable: false, - progress: true, - progressText: '0/' + jobTotalCount, - }); - - let postRequest = function () { - jobsStarted++; - Ext.Msg.updateProgress( - jobsStarted / jobTotalCount, - jobsStarted + '/' + jobTotalCount, - ); - - if (jobsStarted === jobTotalCount) { - Ext.Msg.hide(); - if (errors.length > 0) { - Ext.Msg.alert( - 'Error', - 'Some errors have been encountered:
    ' + errors.join('
    '), - ); - } - } - }; - - nodes.forEach((node) => - Proxmox.Utils.API2Request({ - url: '/nodes/' + node + '/vzdump', - method: 'POST', - params: job, - failure: function (response, opts) { - errors.push(node + ': ' + response.htmlStatus); - postRequest(); - }, - success: postRequest, - }), - ); - }; - - var edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - var run_btn = new Proxmox.button.Button({ - text: gettext('Run now'), - disabled: true, - selModel: sm, - handler: function () { - var rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - Ext.Msg.show({ - title: gettext('Confirm'), - icon: Ext.Msg.QUESTION, - msg: gettext('Start the selected backup job now?'), - buttons: Ext.Msg.YESNO, - callback: function (btn) { - if (btn !== 'yes') { - return; - } - run_backup_now(rec.data); - }, - }); - }, - }); - - var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/cluster/backup', - callback: function () { - reload(); - }, - }); - - var detail_btn = new Proxmox.button.Button({ - text: gettext('Job Detail'), - disabled: true, - tooltip: gettext( - 'Show job details and which guests and volumes are affected by the backup job', - ), - selModel: sm, - handler: run_detail, - }); - - noBackupJobInfoButton = new Proxmox.button.Button({ - text: `${gettext('Show')}: ${gettext('Guests Without Backup Job')}`, - tooltip: gettext('Some guests are not covered by any backup job.'), - iconCls: 'fa fa-fw fa-exclamation-circle', - hidden: true, - handler: () => { - Ext.create('Ext.window.Window', { - autoShow: true, - modal: true, - width: 600, - height: 500, - resizable: true, - layout: 'fit', - title: gettext('Guests Without Backup Job'), - items: [ - { - xtype: 'panel', - region: 'center', - layout: { - type: 'vbox', - align: 'stretch', - }, - items: [ - { - xtype: 'pveBackedGuests', - flex: 1, - layout: 'fit', - store: not_backed_store, - }, - ], - }, - ], - }); - }, - }); - - Proxmox.Utils.monStoreErrors(me, store); - - Ext.apply(me, { - store: store, - selModel: sm, - stateful: true, - stateId: 'grid-dc-backup', - viewConfig: { - trackOver: false, - }, - dockedItems: [ - { - xtype: 'toolbar', - overflowHandler: 'scroller', - dock: 'top', - items: [ - { - text: gettext('Add'), - handler: function () { - var win = Ext.create('PVE.dc.BackupEdit', {}); - win.on('destroy', reload); - win.show(); - }, - }, - '-', - remove_btn, - edit_btn, - detail_btn, - '-', - run_btn, - '->', - noBackupJobInfoButton, - '-', - { - xtype: 'proxmoxButton', - selModel: null, - text: gettext('Schedule Simulator'), - handler: () => { - let record = sm.getSelection()[0]; - let schedule; - if (record) { - schedule = record.data.schedule; - } - Ext.create('PVE.window.ScheduleSimulator', { - autoShow: true, - schedule, - }); - }, - }, - ], - }, - ], - columns: [ - { - header: gettext('Enabled'), - width: 80, - dataIndex: 'enabled', - align: 'center', - renderer: Proxmox.Utils.renderEnabledIcon, - sortable: true, - }, - { - header: gettext('ID'), - dataIndex: 'id', - hidden: true, - }, - { - header: gettext('Node'), - width: 100, - sortable: true, - dataIndex: 'node', - renderer: function (value) { - if (value) { - return value; - } - return me.allText; - }, - }, - { - header: gettext('Schedule'), - width: 150, - dataIndex: 'schedule', - }, - { - text: gettext('Next Run'), - dataIndex: 'next-run', - width: 150, - renderer: PVE.Utils.render_next_event, - }, - { - header: gettext('Storage'), - width: 100, - sortable: true, - dataIndex: 'storage', - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.htmlEncode, - sorter: (a, b) => - (a.data.comment || '').localeCompare(b.data.comment || ''), - flex: 1, - }, - { - header: gettext('Retention'), - dataIndex: 'prune-backups', - renderer: (v) => - v - ? PVE.Parser.printPropertyString(v) - : gettext('Fallback from storage config'), - flex: 2, - }, - { - header: gettext('Selection'), - flex: 4, - sortable: false, - dataIndex: 'vmid', - renderer: PVE.Utils.render_backup_selection, - }, - ], - listeners: { - activate: reload, - itemdblclick: run_editor, - }, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-cluster-backup', { - extend: 'Ext.data.Model', - fields: [ - 'id', - 'compress', - 'dow', - 'exclude', - 'mailto', - 'mode', - 'node', - 'pool', - 'prune-backups', - 'starttime', - 'storage', - 'vmid', - { name: 'enabled', type: 'boolean' }, - { name: 'all', type: 'boolean' }, - ], - }); - }, -); -Ext.define('pve-cluster-nodes', { - extend: 'Ext.data.Model', - fields: [ - 'node', - { type: 'integer', name: 'nodeid' }, - 'ring0_addr', - 'ring1_addr', - { type: 'integer', name: 'quorum_votes' }, - ], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/config/nodes', - }, - idProperty: 'nodeid', -}); - -Ext.define('pve-cluster-info', { - extend: 'Ext.data.Model', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/config/join', - }, -}); - -Ext.define('PVE.ClusterAdministration', { - extend: 'Ext.panel.Panel', - xtype: 'pveClusterAdministration', - - title: gettext('Cluster Administration'), - onlineHelp: 'chapter_pvecm', - - border: false, - defaults: { border: false }, - - viewModel: { - parent: null, - data: { - totem: {}, - nodelist: [], - preferred_node: { - name: '', - fp: '', - addr: '', - }, - isInCluster: false, - nodecount: 0, - }, - }, - - items: [ - { - xtype: 'panel', - title: gettext('Cluster Information'), - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - view.store = Ext.create('Proxmox.data.UpdateStore', { - autoStart: true, - interval: 15 * 1000, - storeid: 'pve-cluster-info', - model: 'pve-cluster-info', - }); - view.store.on('load', this.onLoad, this); - view.on('destroy', view.store.stopUpdate); - }, - - onLoad: function (store, records, success, operation) { - let vm = this.getViewModel(); - - let data = records?.[0]?.data; - if (!success || !data || !data.nodelist?.length) { - let error = operation.getError(); - if (error) { - let msg = Proxmox.Utils.getResponseErrorMessage(error); - if (error.status !== 424 && !msg.match(/node is not in a cluster/i)) { - // an actual error, not just the "not in a cluster one", so show it! - Proxmox.Utils.setErrorMask(this.getView(), msg); - } - } - vm.set('totem', {}); - vm.set('isInCluster', false); - vm.set('nodelist', []); - vm.set('preferred_node', { - name: '', - addr: '', - fp: '', - }); - return; - } - vm.set('totem', data.totem); - vm.set('isInCluster', !!data.totem.cluster_name); - vm.set('nodelist', data.nodelist); - - let nodeinfo = data.nodelist.find((el) => el.name === data.preferred_node); - - let links = {}; - let ring_addr = []; - PVE.Utils.forEachCorosyncLink(nodeinfo, (num, link) => { - links[num] = link; - ring_addr.push(link); - }); - - vm.set('preferred_node', { - name: data.preferred_node, - addr: nodeinfo.pve_addr, - peerLinks: links, - ring_addr: ring_addr, - fp: nodeinfo.pve_fp, - }); - }, - - onCreate: function () { - let view = this.getView(); - view.store.stopUpdate(); - Ext.create('PVE.ClusterCreateWindow', { - autoShow: true, - listeners: { - destroy: function () { - view.store.startUpdate(); - }, - }, - }); - }, - - onClusterInfo: function () { - let vm = this.getViewModel(); - Ext.create('PVE.ClusterInfoWindow', { - autoShow: true, - joinInfo: { - ipAddress: vm.get('preferred_node.addr'), - fingerprint: vm.get('preferred_node.fp'), - peerLinks: vm.get('preferred_node.peerLinks'), - ring_addr: vm.get('preferred_node.ring_addr'), - totem: vm.get('totem'), - }, - }); - }, - - onJoin: function () { - let view = this.getView(); - view.store.stopUpdate(); - Ext.create('PVE.ClusterJoinNodeWindow', { - autoShow: true, - listeners: { - destroy: function () { - view.store.startUpdate(); - }, - }, - }); - }, - }, - tbar: [ - { - text: gettext('Create Cluster'), - reference: 'createButton', - handler: 'onCreate', - bind: { - disabled: '{isInCluster}', - }, - }, - { - text: gettext('Join Information'), - reference: 'addButton', - handler: 'onClusterInfo', - bind: { - disabled: '{!isInCluster}', - }, - }, - { - text: gettext('Join Cluster'), - reference: 'joinButton', - handler: 'onJoin', - bind: { - disabled: '{isInCluster}', - }, - }, - ], - layout: 'hbox', - bodyPadding: 5, - items: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Cluster Name'), - bind: { - value: '{totem.cluster_name}', - hidden: '{!isInCluster}', - }, - flex: 1, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Config Version'), - bind: { - value: '{totem.config_version}', - hidden: '{!isInCluster}', - }, - flex: 1, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Number of Nodes'), - labelWidth: 120, - bind: { - value: '{nodecount}', - hidden: '{!isInCluster}', - }, - flex: 1, - }, - { - xtype: 'displayfield', - value: gettext('Standalone node - no cluster defined'), - bind: { - hidden: '{isInCluster}', - }, - flex: 1, - }, - ], - }, - { - xtype: 'grid', - title: gettext('Cluster Nodes'), - autoScroll: true, - enableColumnHide: false, - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - view.rstore = Ext.create('Proxmox.data.UpdateStore', { - autoLoad: true, - xtype: 'update', - interval: 5 * 1000, - autoStart: true, - storeid: 'pve-cluster-nodes', - model: 'pve-cluster-nodes', - }); - view.setStore( - Ext.create('Proxmox.data.DiffStore', { - rstore: view.rstore, - sorters: { - property: 'nodeid', - direction: 'ASC', - }, - }), - ); - Proxmox.Utils.monStoreErrors(view, view.rstore); - view.rstore.on('load', this.onLoad, this); - view.on('destroy', view.rstore.stopUpdate); - }, - - onLoad: function (store, records, success) { - let view = this.getView(); - let vm = this.getViewModel(); - - if (!success || !records || !records.length) { - vm.set('nodecount', 0); - return; - } - vm.set('nodecount', records.length); - - // show/hide columns according to used links - let linkIndex = view.columns.length; - Ext.each(view.columns, (col, i) => { - if (col.linkNumber !== undefined) { - col.setHidden(true); - // save offset at which link columns start, so we can address them directly below - if (i < linkIndex) { - linkIndex = i; - } - } - }); - - PVE.Utils.forEachCorosyncLink(records[0].data, (linknum, val) => { - if (linknum > 7) { - return; - } - view.columns[linkIndex + linknum].setHidden(false); - }); - }, - }, - columns: { - items: [ - { - header: gettext('Nodename'), - hidden: false, - dataIndex: 'name', - }, - { - header: gettext('ID'), - minWidth: 100, - width: 100, - flex: 0, - hidden: false, - dataIndex: 'nodeid', - }, - { - header: gettext('Votes'), - minWidth: 100, - width: 100, - flex: 0, - hidden: false, - dataIndex: 'quorum_votes', - }, - { - header: Ext.String.format(gettext('Link {0}'), 0), - dataIndex: 'ring0_addr', - linkNumber: 0, - }, - { - header: Ext.String.format(gettext('Link {0}'), 1), - dataIndex: 'ring1_addr', - linkNumber: 1, - }, - { - header: Ext.String.format(gettext('Link {0}'), 2), - dataIndex: 'ring2_addr', - linkNumber: 2, - }, - { - header: Ext.String.format(gettext('Link {0}'), 3), - dataIndex: 'ring3_addr', - linkNumber: 3, - }, - { - header: Ext.String.format(gettext('Link {0}'), 4), - dataIndex: 'ring4_addr', - linkNumber: 4, - }, - { - header: Ext.String.format(gettext('Link {0}'), 5), - dataIndex: 'ring5_addr', - linkNumber: 5, - }, - { - header: Ext.String.format(gettext('Link {0}'), 6), - dataIndex: 'ring6_addr', - linkNumber: 6, - }, - { - header: Ext.String.format(gettext('Link {0}'), 7), - dataIndex: 'ring7_addr', - linkNumber: 7, - }, - ], - defaults: { - flex: 1, - hidden: true, - minWidth: 150, - }, - }, - }, - ], -}); -Ext.define('PVE.ClusterCreateWindow', { - extend: 'Proxmox.window.Edit', - xtype: 'pveClusterCreateWindow', - - title: gettext('Create Cluster'), - width: 600, - - method: 'POST', - url: '/cluster/config', - - isCreate: true, - subject: gettext('Cluster'), - showTaskViewer: true, - - onlineHelp: 'pvecm_create_cluster', - - items: { - xtype: 'inputpanel', - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('Cluster Name'), - allowBlank: false, - maxLength: 15, - name: 'clustername', - }, - { - xtype: 'fieldcontainer', - fieldLabel: gettext('Cluster Network'), - items: [ - { - xtype: 'pveCorosyncLinkEditor', - infoText: gettext( - 'Multiple links are used as failover, lower numbers have higher priority.', - ), - name: 'links', - }, - ], - }, - ], - }, -}); - -Ext.define('PVE.ClusterInfoWindow', { - extend: 'Ext.window.Window', - xtype: 'pveClusterInfoWindow', - mixins: ['Proxmox.Mixin.CBind'], - - width: 800, - modal: true, - resizable: false, - title: gettext('Cluster Join Information'), - - joinInfo: { - ipAddress: undefined, - fingerprint: undefined, - totem: {}, - }, - - items: [ - { - xtype: 'component', - border: false, - padding: '10 10 10 10', - html: gettext('Copy the Join Information here and use it on the node you want to add.'), - }, - { - xtype: 'container', - layout: 'form', - border: false, - padding: '0 10 10 10', - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('IP Address'), - cbind: { - value: '{joinInfo.ipAddress}', - }, - editable: false, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Fingerprint'), - cbind: { - value: '{joinInfo.fingerprint}', - }, - editable: false, - }, - { - xtype: 'textarea', - inputId: 'pveSerializedClusterInfo', - fieldLabel: gettext('Join Information'), - grow: true, - cbind: { - joinInfo: '{joinInfo}', - }, - editable: false, - listeners: { - afterrender: function (field) { - if (!field.joinInfo) { - return; - } - var jsons = Ext.JSON.encode(field.joinInfo); - var base64s = Ext.util.Base64.encode(jsons); - field.setValue(base64s); - }, - }, - }, - ], - }, - ], - dockedItems: [ - { - dock: 'bottom', - xtype: 'toolbar', - items: [ - { - xtype: 'button', - handler: function (b) { - var el = document.getElementById('pveSerializedClusterInfo'); - el.select(); - document.execCommand('copy'); - }, - text: gettext('Copy Information'), - iconCls: 'fa fa-clipboard', - }, - ], - }, - ], -}); - -Ext.define('PVE.ClusterJoinNodeWindow', { - extend: 'Proxmox.window.Edit', - xtype: 'pveClusterJoinNodeWindow', - - title: gettext('Cluster Join'), - width: 800, - - method: 'POST', - url: '/cluster/config/join', - - defaultFocus: 'textarea[name=serializedinfo]', - isCreate: true, - bind: { - submitText: '{submittxt}', - }, - showTaskViewer: true, - - onlineHelp: 'pvecm_join_node_to_cluster', - - viewModel: { - parent: null, - data: { - info: { - fp: '', - ip: '', - clusterName: '', - }, - hasAssistedInfo: false, - }, - formulas: { - submittxt: function (get) { - let cn = get('info.clusterName'); - if (cn) { - return Ext.String.format(gettext('Join {0}'), `'${cn}'`); - } - return gettext('Join'); - }, - showClusterFields: (get) => { - let manualMode = !get('assistedEntry.checked'); - return get('hasAssistedInfo') || manualMode; - }, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - '#': { - close: function () { - delete PVE.Utils.silenceAuthFailures; - }, - }, - 'proxmoxcheckbox[name=assistedEntry]': { - change: 'onInputTypeChange', - }, - 'textarea[name=serializedinfo]': { - change: 'recomputeSerializedInfo', - enable: 'resetField', - }, - textfield: { - disable: 'resetField', - }, - }, - resetField: function (field) { - field.reset(); - }, - onInputTypeChange: function (field, assistedInput) { - let linkEditor = this.lookup('linkEditor'); - - // this also clears all links - linkEditor.setAllowNumberEdit(!assistedInput); - - if (!assistedInput) { - linkEditor.setInfoText(); - linkEditor.setDefaultLinks(); - } - }, - recomputeSerializedInfo: function (field, value) { - let vm = this.getViewModel(); - - let assistedEntryBox = this.lookup('assistedEntry'); - - if (!assistedEntryBox.getValue()) { - // not in assisted entry mode, nothing to do - vm.set('hasAssistedInfo', false); - return; - } - - let linkEditor = this.lookup('linkEditor'); - - let jsons = Ext.util.Base64.decode(value); - let joinInfo = Ext.JSON.decode(jsons, true); - - let info = { - fp: '', - ip: '', - clusterName: '', - }; - - if (!(joinInfo && joinInfo.totem)) { - field.valid = false; - linkEditor.setLinks([]); - linkEditor.setInfoText(); - vm.set('hasAssistedInfo', false); - } else { - let interfaces = joinInfo.totem.interface; - let links = Object.values(interfaces).map((iface) => { - let linkNumber = iface.linknumber; - let peerLink; - if (joinInfo.peerLinks) { - peerLink = joinInfo.peerLinks[linkNumber]; - } - return { - number: linkNumber, - value: '', - text: peerLink - ? Ext.String.format(gettext("peer's link address: {0}"), peerLink) - : '', - allowBlank: false, - }; - }); - - linkEditor.setInfoText(); - if ( - links.length === 1 && - joinInfo.ring_addr !== undefined && - joinInfo.ring_addr[0] === joinInfo.ipAddress - ) { - links[0].allowBlank = true; - links[0].emptyText = gettext("IP resolved by node's hostname"); - } - - linkEditor.setLinks(links); - - info = { - ip: joinInfo.ipAddress, - fp: joinInfo.fingerprint, - clusterName: joinInfo.totem.cluster_name, - }; - field.valid = true; - vm.set('hasAssistedInfo', true); - } - vm.set('info', info); - }, - }, - - submit: function () { - // joining may produce temporarily auth failures, ignore as long the task runs - PVE.Utils.silenceAuthFailures = true; - this.callParent(); - }, - - taskDone: function (success) { - delete PVE.Utils.silenceAuthFailures; - if (success) { - // reload always (if user wasn't faster), but wait a bit for pveproxy - Ext.defer(function () { - window.location.reload(true); - }, 5000); - let txt = gettext( - 'Cluster join task finished, node certificate may have changed, reload GUI!', - ); - // ensure user cannot do harm - Ext.getBody().mask(txt, ['pve-static-mask']); - // TaskView may hide above mask, so tell him directly - Ext.Msg.show({ - title: gettext('Join Task Finished'), - icon: Ext.Msg.INFO, - msg: txt, - }); - } - }, - - items: [ - { - xtype: 'proxmoxcheckbox', - reference: 'assistedEntry', - name: 'assistedEntry', - itemId: 'assistedEntry', - submitValue: false, - value: true, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Select if join information should be extracted from pasted cluster information, deselect for manual entering', - ), - }, - boxLabel: gettext( - 'Assisted join: Paste encoded cluster join information and enter password.', - ), - }, - { - xtype: 'textarea', - name: 'serializedinfo', - submitValue: false, - allowBlank: false, - fieldLabel: gettext('Information'), - emptyText: gettext('Paste encoded Cluster Information here'), - validator: function (val) { - return ( - val === '' || - this.valid || - gettext('Does not seem like a valid encoded Cluster Information!') - ); - }, - bind: { - disabled: '{!assistedEntry.checked}', - hidden: '{!assistedEntry.checked}', - }, - value: '', - }, - { - xtype: 'panel', - width: 776, - layout: { - type: 'hbox', - align: 'center', - }, - bind: { - hidden: '{!showClusterFields}', - }, - items: [ - { - xtype: 'textfield', - flex: 1, - margin: '0 5px 0 0', - fieldLabel: gettext('Peer Address'), - allowBlank: false, - bind: { - value: '{info.ip}', - readOnly: '{assistedEntry.checked}', - }, - name: 'hostname', - }, - { - xtype: 'textfield', - flex: 1, - margin: '0 0 10px 5px', - inputType: 'password', - emptyText: gettext("Peer's root password"), - fieldLabel: gettext('Password'), - allowBlank: false, - name: 'password', - }, - ], - }, - { - xtype: 'textfield', - fieldLabel: gettext('Fingerprint'), - allowBlank: false, - bind: { - value: '{info.fp}', - readOnly: '{assistedEntry.checked}', - hidden: '{!showClusterFields}', - }, - name: 'fingerprint', - }, - { - xtype: 'fieldcontainer', - fieldLabel: gettext('Cluster Network'), - bind: { - hidden: '{!showClusterFields}', - }, - items: [ - { - xtype: 'pveCorosyncLinkEditor', - itemId: 'linkEditor', - reference: 'linkEditor', - allowNumberEdit: false, - }, - ], - }, - ], -}); -Ext.define('PVE.dc.CmdMenu', { - extend: 'Ext.menu.Menu', - xtype: 'datacenterCmdMenu', - - showSeparator: false, - - extraHandlerArgs: {}, - - items: [ - { - text: gettext('Bulk Start'), - itemId: 'bulkstart', - iconCls: 'fa fa-fw fa-play', - handler: function () { - let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Start'), - btnText: gettext('Start'), - action: 'start', - ...extraArgs, - }); - }, - }, - { - text: gettext('Bulk Shutdown'), - itemId: 'bulkstop', - iconCls: 'fa fa-fw fa-stop', - handler: function () { - let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Shutdown'), - btnText: gettext('Shutdown'), - action: 'shutdown', - ...extraArgs, - }); - }, - }, - { - text: gettext('Bulk Suspend'), - itemId: 'bulksuspend', - iconCls: 'fa fa-fw fa-download', - handler: function () { - let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Suspend'), - btnText: gettext('Suspend'), - action: 'suspend', - ...extraArgs, - }); - }, - }, - { - text: gettext('Bulk Migrate'), - itemId: 'bulkmigrate', - iconCls: 'fa fa-fw fa-send-o', - handler: function () { - let extraArgs = this.up('datacenterCmdMenu').extraHandlerArgs ?? {}; - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Migrate'), - btnText: gettext('Migrate'), - action: 'migrate', - ...extraArgs, - }); - }, - }, - ], - - initComponent: function () { - let me = this; - - if (!me.title) { - me.title = gettext('Datacenter'); - if (PVE.ClusterName?.length) { - me.title += ` (${PVE.ClusterName})`; - me.minWidth = 220; - } - } - - me.callParent(); - - let caps = Ext.state.Manager.get('GuiCap'); - - if (!caps.vms['VM.Migrate']) { - me.getComponent('bulkmigrate').setDisabled(true); - } - if (!caps.vms['VM.PowerMgmt']) { - me.getComponent('bulkstart').setDisabled(true); - me.getComponent('bulkstop').setDisabled(true); - me.getComponent('bulksuspend').setDisabled(true); - } - if (PVE.Utils.isStandaloneNode()) { - me.getComponent('bulkmigrate').setVisible(false); - } - }, -}); - -Ext.define('PVE.dc.TagCmdMenu', { - extend: 'PVE.dc.CmdMenu', - xtype: 'tagCmdMenu', - - minWidth: 220, - - initComponent: function () { - let me = this; - - if (!me.tag) { - throw 'no tag specified'; - } - - me.title = `${gettext('Tag')} '${me.tag}'`; - if (PVE.ClusterName?.length) { - me.title += ` (${me.nodename})`; - } - - me.extraHandlerArgs = { - prefilterIncludeTag: me.tag, - }; - - me.callParent(); - }, -}); -/* - * Datacenter config panel, located in the center of the ViewPort after the Datacenter view is selected - */ - -Ext.define('PVE.dc.Config', { - extend: 'PVE.panel.Config', - alias: 'widget.PVE.dc.Config', - - onlineHelp: 'pve_admin_guide', - - initComponent: function () { - var me = this; - - var caps = Ext.state.Manager.get('GuiCap'); - - me.items = []; - - let actionBtn = Ext.create('Ext.Button', { - text: gettext('Bulk Actions'), - iconCls: 'fa fa-fw fa-ellipsis-v', - disabled: !caps.vms['VM.PowerMgmt'] && !caps.vms['VM.Migrate'], - menu: new Ext.menu.Menu({ - items: [ - { - text: gettext('Bulk Start'), - iconCls: 'fa fa-fw fa-play', - disabled: !caps.vms['VM.PowerMgmt'], - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Start'), - btnText: gettext('Start'), - action: 'start', - }); - }, - }, - { - text: gettext('Bulk Shutdown'), - iconCls: 'fa fa-fw fa-stop', - disabled: !caps.vms['VM.PowerMgmt'], - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Shutdown'), - btnText: gettext('Shutdown'), - action: 'shutdown', - }); - }, - }, - { - text: gettext('Bulk Suspend'), - iconCls: 'fa fa-fw fa-download', - disabled: !caps.vms['VM.PowerMgmt'], - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Suspend'), - btnText: gettext('Suspend'), - action: 'suspend', - }); - }, - }, - { - text: gettext('Bulk Migrate'), - iconCls: 'fa fa-fw fa-send-o', - disabled: !caps.vms['VM.Migrate'], - hidden: PVE.Utils.isStandaloneNode(), - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - vmsAsArray: true, - title: gettext('Bulk Migrate'), - btnText: gettext('Migrate'), - action: 'migrate', - }); - }, - }, - ], - }), - }); - - Ext.apply(me, { - title: gettext('Datacenter'), - hstateid: 'dctab', - tbar: [actionBtn], - }); - - if (caps.dc['Sys.Audit']) { - me.items.push( - { - title: gettext('Summary'), - xtype: 'pveDcSummary', - iconCls: 'fa fa-book', - itemId: 'summary', - }, - { - xtype: 'pmxNotesView', - title: gettext('Notes'), - iconCls: 'fa fa-sticky-note-o', - itemId: 'notes', - }, - { - title: gettext('Cluster'), - xtype: 'pveClusterAdministration', - iconCls: 'fa fa-server', - itemId: 'cluster', - }, - { - title: 'Ceph', - itemId: 'ceph', - iconCls: 'fa fa-ceph', - xtype: 'pveNodeCephStatus', - }, - { - xtype: 'pveDcOptionView', - title: gettext('Options'), - iconCls: 'fa fa-gear', - itemId: 'options', - }, - ); - } - - if (caps.storage['Datastore.Allocate'] || caps.dc['Sys.Audit']) { - me.items.push({ - xtype: 'pveStorageView', - title: gettext('Storage'), - iconCls: 'fa fa-database', - itemId: 'storage', - }); - } - - if (caps.dc['Sys.Audit']) { - me.items.push( - { - xtype: 'pveDcBackupView', - iconCls: 'fa fa-floppy-o', - title: gettext('Backup'), - itemId: 'backup', - }, - { - xtype: 'pveReplicaView', - iconCls: 'fa fa-retweet', - title: gettext('Replication'), - itemId: 'replication', - }, - { - xtype: 'pveACLView', - title: gettext('Permissions'), - iconCls: 'fa fa-unlock', - itemId: 'permissions', - expandedOnInit: true, - }, - ); - } - - me.items.push({ - xtype: 'pveUserView', - groups: ['permissions'], - iconCls: 'fa fa-user', - title: gettext('Users'), - itemId: 'users', - }); - - me.items.push({ - xtype: 'pveTokenView', - groups: ['permissions'], - iconCls: 'fa fa-user-o', - title: gettext('API Tokens'), - itemId: 'apitokens', - }); - - me.items.push({ - xtype: 'pmxTfaView', - title: gettext('Two Factor'), - groups: ['permissions'], - iconCls: 'fa fa-key', - itemId: 'tfa', - yubicoEnabled: true, - issuerName: `Proxmox VE - ${PVE.ClusterName || Proxmox.NodeName}`, - }); - - if (caps.dc['Sys.Audit']) { - me.items.push( - { - xtype: 'pveGroupView', - title: gettext('Groups'), - iconCls: 'fa fa-users', - groups: ['permissions'], - itemId: 'groups', - }, - { - xtype: 'pvePoolView', - title: gettext('Pools'), - iconCls: 'fa fa-tags', - groups: ['permissions'], - itemId: 'pools', - }, - { - xtype: 'pveRoleView', - title: gettext('Roles'), - iconCls: 'fa fa-male', - groups: ['permissions'], - itemId: 'roles', - }, - { - title: gettext('Realms'), - xtype: 'panel', - layout: { - type: 'border', - }, - groups: ['permissions'], - iconCls: 'fa fa-address-book-o', - itemId: 'domains', - items: [ - { - xtype: 'pveAuthView', - region: 'center', - border: false, - }, - { - xtype: 'pveRealmSyncJobView', - title: gettext('Realm Sync Jobs'), - region: 'south', - collapsible: true, - animCollapse: false, - border: false, - height: '50%', - }, - ], - }, - { - xtype: 'pveHAStatus', - title: 'HA', - iconCls: 'fa fa-heartbeat', - itemId: 'ha', - }, - { - title: gettext('Affinity Rules'), - groups: ['ha'], - xtype: 'pveHARulesView', - iconCls: 'fa fa-gears', - itemId: 'ha-rules', - }, - { - title: gettext('Fencing'), - groups: ['ha'], - iconCls: 'fa fa-bolt', - xtype: 'pveFencingView', - itemId: 'ha-fencing', - }, - ); - // always show on initial load, will be hiddea later if the SDN API calls don't exist, - // else it won't be shown at first if the user initially loads with DC selected - if (PVE.SDNInfo || PVE.SDNInfo === undefined) { - me.items.push( - { - xtype: 'pveSDNStatus', - title: gettext('SDN'), - iconCls: 'fa fa-sdn x-fa-sdn-treelist', - hidden: true, - itemId: 'sdn', - expandedOnInit: true, - }, - { - xtype: 'pveSDNZoneView', - groups: ['sdn'], - title: gettext('Zones'), - hidden: true, - iconCls: 'fa fa-th', - itemId: 'sdnzone', - }, - { - xtype: 'pveSDNVnet', - groups: ['sdn'], - title: 'VNets', - hidden: true, - iconCls: 'fa fa-network-wired x-fa-sdn-treelist', - itemId: 'sdnvnet', - }, - { - xtype: 'pveSDNOptions', - groups: ['sdn'], - title: gettext('Options'), - hidden: true, - iconCls: 'fa fa-gear', - itemId: 'sdnoptions', - }, - { - xtype: 'pveDhcpTree', - groups: ['sdn'], - title: gettext('IPAM'), - hidden: true, - iconCls: 'fa fa-map-signs', - itemId: 'sdnmappings', - }, - { - xtype: 'pveSDNFirewall', - groups: ['sdn'], - title: gettext('VNet Firewall'), - hidden: true, - iconCls: 'fa fa-shield', - itemId: 'sdnfirewall', - }, - { - xtype: 'pveSDNFabricView', - groups: ['sdn'], - title: gettext('Fabrics'), - hidden: true, - iconCls: 'fa fa-road', - itemId: 'sdnfabrics', - }, - ); - } - - if (Proxmox.UserName === 'root@pam') { - me.items.push({ - xtype: 'pveACMEClusterView', - title: 'ACME', - iconCls: 'fa fa-certificate', - itemId: 'acme', - }); - } - - me.items.push( - { - xtype: 'pveFirewallRules', - title: gettext('Firewall'), - allow_iface: true, - base_url: '/cluster/firewall/rules', - list_refs_url: '/cluster/firewall/refs', - iconCls: 'fa fa-shield', - itemId: 'firewall', - firewall_type: 'dc', - }, - { - xtype: 'pveFirewallOptions', - title: gettext('Options'), - groups: ['firewall'], - iconCls: 'fa fa-gear', - base_url: '/cluster/firewall/options', - onlineHelp: 'pve_firewall_cluster_wide_setup', - fwtype: 'dc', - itemId: 'firewall-options', - }, - { - xtype: 'pveSecurityGroups', - title: gettext('Security Group'), - groups: ['firewall'], - iconCls: 'fa fa-group', - itemId: 'firewall-sg', - }, - { - xtype: 'pveFirewallAliases', - title: gettext('Alias'), - groups: ['firewall'], - iconCls: 'fa fa-external-link', - base_url: '/cluster/firewall/aliases', - itemId: 'firewall-aliases', - }, - { - xtype: 'pveIPSet', - title: 'IPSet', - groups: ['firewall'], - iconCls: 'fa fa-list-ol', - base_url: '/cluster/firewall/ipset', - list_refs_url: '/cluster/firewall/refs', - itemId: 'firewall-ipset', - }, - { - xtype: 'pveMetricServerView', - title: gettext('Metric Server'), - iconCls: 'fa fa-bar-chart', - itemId: 'metricservers', - onlineHelp: 'external_metric_server', - }, - ); - } - - if ( - caps.mapping['Mapping.Audit'] || - caps.mapping['Mapping.Use'] || - caps.mapping['Mapping.Modify'] - ) { - me.items.push( - { - xtype: 'container', - onlineHelp: 'resource_mapping', - title: gettext('Resource Mappings'), - itemId: 'resources', - iconCls: 'fa fa-folder-o', - layout: { - type: 'vbox', - align: 'stretch', - multi: true, - }, - scrollable: true, - defaults: { - border: false, - }, - items: [ - { - xtype: 'pveDcPCIMapView', - title: gettext('PCI Devices'), - flex: 1, - }, - { - xtype: 'splitter', - collapsible: false, - performCollapse: false, - }, - { - xtype: 'pveDcUSBMapView', - title: gettext('USB Devices'), - flex: 1, - }, - ], - }, - { - xtype: 'pveDcDirMapView', - itemId: 'directories', - title: gettext('Directory Mappings'), - iconCls: 'fa fa-folder', - }, - ); - } - - if ( - caps.mapping['Mapping.Audit'] || - caps.mapping['Mapping.Use'] || - caps.mapping['Mapping.Modify'] - ) { - me.items.push({ - xtype: 'pmxNotificationConfigView', - title: gettext('Notifications'), - itemId: 'notification-targets', - iconCls: 'fa fa-bell-o', - baseUrl: '/cluster/notifications', - }); - } - - if (caps.dc['Sys.Audit']) { - me.items.push({ - xtype: 'pveDcSupport', - title: gettext('Support'), - itemId: 'support', - iconCls: 'fa fa-comments-o', - }); - } - - me.callParent(); - }, -}); -Ext.define('PVE.form.CorosyncLinkEditorController', { - extend: 'Ext.app.ViewController', - alias: 'controller.pveCorosyncLinkEditorController', - - addLinkIfEmpty: function () { - let view = this.getView(); - if (view.items || view.items.length === 0) { - this.addLink(); - } - }, - - addEmptyLink: function () { - this.addLink(); // discard parameters to allow being called from 'handler' - }, - - addLink: function (link) { - let me = this; - let view = me.getView(); - let vm = view.getViewModel(); - - let linkCount = vm.get('linkCount'); - if (linkCount >= vm.get('maxLinkCount')) { - return; - } - - link = link || {}; - - if (link.number === undefined) { - link.number = me.getNextFreeNumber(); - } - if (link.value === undefined) { - link.value = me.getNextFreeNetwork(); - } - - let linkSelector = Ext.create('PVE.form.CorosyncLinkSelector', { - maxLinkNumber: vm.get('maxLinkCount') - 1, - allowNumberEdit: vm.get('allowNumberEdit'), - allowBlankNetwork: link.allowBlank, - initNumber: link.number, - initNetwork: link.value, - text: link.text, - emptyText: link.emptyText, - - // needs to be set here, because we need to update the viewmodel - removeBtnHandler: function () { - let curLinkCount = vm.get('linkCount'); - - if (curLinkCount <= 1) { - return; - } - - vm.set('linkCount', curLinkCount - 1); - - // 'this' is the linkSelector here - view.remove(this); - - me.updateDeleteButtonState(); - }, - }); - - view.add(linkSelector); - - linkCount++; - vm.set('linkCount', linkCount); - - me.updateDeleteButtonState(); - }, - - // ExtJS trips on binding this for some reason, so do it manually - updateDeleteButtonState: function () { - let view = this.getView(); - let vm = view.getViewModel(); - - let disabled = vm.get('linkCount') <= 1; - - let deleteButtons = view.query('button[cls=removeLinkBtn]'); - Ext.Array.each(deleteButtons, (btn) => { - btn.setDisabled(disabled); - }); - }, - - getNextFreeNetwork: function () { - let view = this.getView(); - let vm = view.getViewModel(); - - let networksInUse = view.query('proxmoxNetworkSelector').map((selector) => selector.value); - - for (const network of vm.get('networks')) { - if (!networksInUse.includes(network)) { - return network; - } - } - return undefined; // default to empty field, user has to set up link manually - }, - - getNextFreeNumber: function () { - let view = this.getView(); - let vm = view.getViewModel(); - - let numbersInUse = view.query('numberfield').map((field) => field.value); - - for (let i = 0; i < vm.get('maxLinkCount'); i++) { - if (!numbersInUse.includes(i)) { - return i; - } - } - // all numbers in use, this should never happen since add button is disabled automatically - return 0; - }, -}); - -Ext.define('PVE.form.CorosyncLinkSelector', { - extend: 'Ext.panel.Panel', - xtype: 'pveCorosyncLinkSelector', - - mixins: ['Proxmox.Mixin.CBind'], - cbindData: [], - - // config - maxLinkNumber: 7, - allowNumberEdit: true, - allowBlankNetwork: false, - removeBtnHandler: undefined, - emptyText: '', - - // values - initNumber: 0, - initNetwork: '', - text: '', - - layout: 'hbox', - bodyPadding: 5, - border: 0, - - items: [ - { - xtype: 'displayfield', - fieldLabel: 'Link', - cbind: { - hidden: '{allowNumberEdit}', - value: '{initNumber}', - }, - width: 45, - labelWidth: 30, - allowBlank: false, - }, - { - xtype: 'numberfield', - fieldLabel: 'Link', - cbind: { - maxValue: '{maxLinkNumber}', - hidden: '{!allowNumberEdit}', - value: '{initNumber}', - }, - width: 80, - labelWidth: 30, - minValue: 0, - submitValue: false, // see getSubmitValue of network selector - allowBlank: false, - }, - { - xtype: 'proxmoxNetworkSelector', - cbind: { - allowBlank: '{allowBlankNetwork}', - value: '{initNetwork}', - emptyText: '{emptyText}', - }, - autoSelect: false, - valueField: 'address', - displayField: 'address', - width: 220, - margin: '0 5px 0 5px', - getSubmitValue: function () { - let me = this; - // link number is encoded into key, so we need to set field name before value retrieval - let linkNumber = me.prev('numberfield').getValue(); // always the correct one - me.name = 'link' + linkNumber; - return me.getValue(); - }, - }, - { - xtype: 'button', - iconCls: 'fa fa-trash-o', - cls: 'removeLinkBtn', - cbind: { - hidden: '{!allowNumberEdit}', - }, - handler: function () { - let me = this; - let parent = me.up('pveCorosyncLinkSelector'); - if (parent.removeBtnHandler !== undefined) { - parent.removeBtnHandler(); - } - }, - }, - { - xtype: 'label', - margin: '-1px 0 0 5px', - - // for muted effect - cls: 'x-form-item-label-default', - - cbind: { - text: '{text}', - }, - }, - ], - - initComponent: function () { - let me = this; - - me.callParent(); - - let numSelect = me.down('numberfield'); - let netSelect = me.down('proxmoxNetworkSelector'); - - numSelect.validator = me.createNoDuplicatesValidator( - 'numberfield', - gettext('Duplicate link number not allowed.'), - ); - - netSelect.validator = me.createNoDuplicatesValidator( - 'proxmoxNetworkSelector', - gettext('Duplicate link address not allowed.'), - ); - }, - - createNoDuplicatesValidator: function (queryString, errorMsg) { - // linkSelector generator - let view = this; - /** @this is the field itself, as the validator this is called from scopes it that way */ - return function (val) { - let me = this; - let form = view.up('form'); - let linkEditor = view.up('pveCorosyncLinkEditor'); - - if (!form.validating) { - // avoid recursion/double validation by setting temporary states - me.validating = true; - form.validating = true; - - // validate all other fields as well, to always mark both - // parties involved in a 'duplicate' error - form.isValid(); - - form.validating = false; - me.validating = false; - } else if (me.validating) { - // we'll be validated by the original call in the other if-branch, avoid double work - return true; - } - - if (val === undefined || (val instanceof String && val.length === 0)) { - return true; // let this be caught by allowBlank, if at all - } - - let allFields = linkEditor.query(queryString); - for (const field of allFields) { - if (field !== me && String(field.getValue()) === String(val)) { - return errorMsg; - } - } - return true; - }; - }, -}); - -Ext.define('PVE.form.CorosyncLinkEditor', { - extend: 'Ext.panel.Panel', - xtype: 'pveCorosyncLinkEditor', - - controller: 'pveCorosyncLinkEditorController', - - // only initial config, use setter otherwise - allowNumberEdit: true, - - viewModel: { - data: { - linkCount: 0, - maxLinkCount: 8, - networks: null, - allowNumberEdit: true, - infoText: '', - }, - formulas: { - addDisabled: function (get) { - return !get('allowNumberEdit') || get('linkCount') >= get('maxLinkCount'); - }, - dockHidden: function (get) { - return !(get('allowNumberEdit') || get('infoText')); - }, - }, - }, - - dockedItems: [ - { - xtype: 'toolbar', - dock: 'bottom', - defaultButtonUI: 'default', - border: false, - padding: '6 0 6 0', - bind: { - hidden: '{dockHidden}', - }, - items: [ - { - xtype: 'button', - text: gettext('Add'), - bind: { - disabled: '{addDisabled}', - hidden: '{!allowNumberEdit}', - }, - handler: 'addEmptyLink', - }, - { - xtype: 'label', - bind: { - text: '{infoText}', - }, - }, - ], - }, - ], - - setInfoText: function (text) { - let me = this; - let vm = me.getViewModel(); - - vm.set('infoText', text || ''); - }, - - setLinks: function (links) { - let me = this; - let controller = me.getController(); - let vm = me.getViewModel(); - - me.removeAll(); - vm.set('linkCount', 0); - - Ext.Array.each(links, (link) => controller.addLink(link)); - }, - - setDefaultLinks: function () { - let me = this; - let controller = me.getController(); - let vm = me.getViewModel(); - - me.removeAll(); - vm.set('linkCount', 0); - controller.addLink(); - }, - - // clears all links - setAllowNumberEdit: function (allow) { - let me = this; - let vm = me.getViewModel(); - vm.set('allowNumberEdit', allow); - me.removeAll(); - vm.set('linkCount', 0); - }, - - items: [ - { - // No links is never a valid scenario, but can occur during a slow load - xtype: 'hiddenfield', - submitValue: false, - isValid: function () { - let me = this; - let vm = me.up('pveCorosyncLinkEditor').getViewModel(); - return vm.get('linkCount') > 0; - }, - }, - ], - - initComponent: function () { - let me = this; - let vm = me.getViewModel(); - let controller = me.getController(); - - vm.set('allowNumberEdit', me.allowNumberEdit); - vm.set('infoText', me.infoText || ''); - - me.callParent(); - - // Request local node networks to pre-populate first link. - Proxmox.Utils.API2Request({ - url: '/nodes/localhost/network', - method: 'GET', - waitMsgTarget: me, - success: (response) => { - let data = response.result.data; - if (data.length > 0) { - data.sort((a, b) => a.iface.localeCompare(b.iface)); - let addresses = []; - for (let net of data) { - if (net.address) { - addresses.push(net.address); - } - if (net.address6) { - addresses.push(net.address6); - } - } - - vm.set('networks', addresses); - } - - // Always have at least one link, but account for delay in API, - // someone might have called 'setLinks' in the meantime - - // except if 'allowNumberEdit' is false, in which case we're - // probably waiting for the user to input the join info - if (vm.get('allowNumberEdit')) { - controller.addLinkIfEmpty(); - } - }, - failure: () => { - if (vm.get('allowNumberEdit')) { - controller.addLinkIfEmpty(); - } - }, - }); - }, -}); -Ext.define('PVE.dc.GroupEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveDcGroupEdit'], - - initComponent: function () { - var me = this; - - me.isCreate = !me.groupid; - - var url; - var method; - - if (me.isCreate) { - url = '/api2/extjs/access/groups'; - method = 'POST'; - } else { - url = '/api2/extjs/access/groups/' + me.groupid; - method = 'PUT'; - } - - Ext.applyIf(me, { - subject: gettext('Group'), - url: url, - method: method, - items: [ - { - xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield', - fieldLabel: gettext('Name'), - name: 'groupid', - value: me.groupid, - allowBlank: false, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Comment'), - name: 'comment', - allowBlank: true, - }, - ], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load(); - } - }, -}); -Ext.define('PVE.dc.GroupView', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveGroupView'], - - onlineHelp: 'pveum_groups', - - stateful: true, - stateId: 'grid-groups', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-groups', - sorters: { - property: 'groupid', - direction: 'ASC', - }, - }); - - var reload = function () { - store.load(); - }; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - callback: function () { - reload(); - }, - baseurl: '/access/groups/', - }); - - var run_editor = function () { - var rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - var win = Ext.create('PVE.dc.GroupEdit', { - groupid: rec.data.groupid, - }); - win.on('destroy', reload); - win.show(); - }; - - var edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - var tbar = [ - { - text: gettext('Create'), - handler: function () { - var win = Ext.create('PVE.dc.GroupEdit', {}); - win.on('destroy', reload); - win.show(); - }, - }, - edit_btn, - remove_btn, - ]; - - Proxmox.Utils.monStoreErrors(me, store); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: tbar, - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: gettext('Name'), - width: 200, - sortable: true, - dataIndex: 'groupid', - }, - { - header: gettext('Comment'), - sortable: false, - renderer: Ext.String.htmlEncode, - dataIndex: 'comment', - flex: 1, - }, - { - header: gettext('Users'), - sortable: false, - dataIndex: 'users', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - listeners: { - activate: reload, - itemdblclick: run_editor, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.dc.Guests', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveDcGuests', - - title: gettext('Guests'), - height: 250, - layout: { - type: 'table', - columns: 2, - tableAttrs: { - style: { - width: '100%', - }, - }, - }, - bodyPadding: '0 20 20 20', - - defaults: { - xtype: 'box', - padding: '0 50 0 50', - style: { - 'text-align': 'center', - 'line-height': '1.5em', - 'font-size': '14px', - }, - }, - items: [ - { - itemId: 'qemu', - data: { - running: 0, - paused: 0, - stopped: 0, - template: 0, - }, - cls: 'centered-flex-column', - tpl: [ - '

    ' + gettext('Virtual Machines') + '

    ', - '
    ', - '
    ', - ' ', - gettext('Running'), - '
    ', - '
    {running}
    ', - '
    ', - '', - '
    ', - '
    ', - ' ', - gettext('Paused'), - '
    ', - '
    {paused}
    ', - '
    ', - '
    ', - '
    ', - '
    ', - ' ', - gettext('Stopped'), - '
    ', - '
    {stopped}
    ', - '
    ', - '', - '
    ', - '
    ', - ' ', - gettext('Templates'), - '
    ', - '
    {template}
    ', - '
    ', - '
    ', - ], - }, - { - itemId: 'lxc', - data: { - running: 0, - paused: 0, - stopped: 0, - template: 0, - }, - cls: 'centered-flex-column', - tpl: [ - '

    ' + gettext('LXC Container') + '

    ', - '
    ', - '
    ', - ' ', - gettext('Running'), - '
    ', - '
    {running}
    ', - '
    ', - '', - '
    ', - '
    ', - ' ', - gettext('Paused'), - '
    ', - '
    {paused}
    ', - '
    ', - '
    ', - '
    ', - '
    ', - ' ', - gettext('Stopped'), - '
    ', - '
    {stopped}
    ', - '
    ', - '', - '
    ', - '
    ', - ' ', - gettext('Templates'), - '
    ', - '
    {template}
    ', - '
    ', - '
    ', - ], - }, - { - itemId: 'error', - colspan: 2, - data: { - num: 0, - }, - columnWidth: 1, - padding: '10 250 0 250', - tpl: [ - '', - '
    ', - ' ', - gettext('Error'), - '
    ', - '
    {num}
    ', - '
    ', - ], - }, - ], - - updateValues: function (qemu, lxc, error) { - let me = this; - - let lazyUpdate = (query, newData) => { - let el = me.getComponent(query); - let currentData = el.data; - - let keys = Object.keys(newData); - if (keys.length === Object.keys(currentData).length) { - if (keys.every((k) => newData[k] === currentData[k])) { - return; // all stayed the same here, return early to avoid bogus regeneration - } - } - el.update(newData); - }; - lazyUpdate('qemu', qemu); - lazyUpdate('lxc', lxc); - lazyUpdate('error', { num: error }); - }, -}); -Ext.define('PVE.dc.Health', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveDcHealth', - - title: gettext('Health'), - - bodyPadding: 10, - height: 250, - layout: { - type: 'hbox', - align: 'stretch', - }, - - defaults: { - flex: 1, - xtype: 'box', - style: { - 'text-align': 'center', - }, - }, - - nodeList: [], - nodeIndex: 0, - - updateStatus: function (store, records, success) { - let me = this; - if (!success) { - return; - } - - let cluster = { - iconCls: PVE.Utils.get_health_icon('good', true), - text: gettext('Standalone node - no cluster defined'), - }; - let nodes = { - online: 0, - offline: 0, - }; - let numNodes = 1; // by default we have one node - for (const { data } of records) { - if (data.type === 'node') { - nodes[data.online === 1 ? 'online' : 'offline']++; - } else if (data.type === 'cluster') { - cluster.text = `${gettext('Cluster')}: ${data.name}, ${gettext('Quorate')}: `; - cluster.text += Proxmox.Utils.format_boolean(data.quorate); - if (data.quorate !== 1) { - cluster.iconCls = PVE.Utils.get_health_icon('critical', true); - } - numNodes = data.nodes; - } - } - - if (numNodes !== nodes.online + nodes.offline) { - nodes.offline = numNodes - nodes.online; - } - - me.getComponent('clusterstatus').updateHealth(cluster); - me.getComponent('nodestatus').update(nodes); - }, - - updateCeph: function (store, records, success) { - let me = this; - let cephstatus = me.getComponent('ceph'); - if (!success || records.length < 1) { - if (cephstatus.isVisible()) { - return; // if ceph status is already visible don't stop to update - } - // try all nodes until we either get a successful api call, or we tried all nodes - if (++me.nodeIndex >= me.nodeList.length) { - me.cephstore.stopUpdate(); - } else { - store - .getProxy() - .setUrl(`/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`); - } - return; - } - - let state = PVE.Utils.render_ceph_health(records[0].data.health || {}); - cephstatus.updateHealth(state); - cephstatus.setVisible(true); - }, - - listeners: { - destroy: function () { - let me = this; - me.cephstore.stopUpdate(); - }, - }, - - items: [ - { - itemId: 'clusterstatus', - xtype: 'pveHealthWidget', - title: gettext('Status'), - }, - { - itemId: 'nodestatus', - data: { - online: 0, - offline: 0, - }, - tpl: [ - '

    ' + gettext('Nodes') + '


    ', - '
    ', - '
    ', - ' ', - gettext('Online'), - '
    ', - '
    {online}
    ', - '

    ', - '
    ', - ' ', - gettext('Offline'), - '
    ', - '
    {offline}
    ', - '
    ', - ], - }, - { - itemId: 'ceph', - width: 250, - columnWidth: undefined, - userCls: 'pointer', - title: 'Ceph', - xtype: 'pveHealthWidget', - hidden: true, - listeners: { - element: 'el', - click: function () { - Ext.state.Manager.getProvider().set('dctab', { value: 'ceph' }, true); - }, - }, - }, - ], - - initComponent: function () { - let me = this; - - me.nodeList = PVE.data.ResourceStore.getNodes(); - me.nodeIndex = 0; - me.cephstore = Ext.create('Proxmox.data.UpdateStore', { - interval: 3000, - storeid: 'pve-cluster-ceph', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`, - }, - }); - me.callParent(); - me.mon(me.cephstore, 'load', me.updateCeph, me); - me.cephstore.startUpdate(); - }, -}); -/* This class defines the "Cluster log" tab of the bottom status panel - * A log entry is a timestamp associated with an action on a cluster - */ - -Ext.define('PVE.dc.Log', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveClusterLog'], - - initComponent: function () { - let me = this; - - let logstore = Ext.create('Proxmox.data.UpdateStore', { - storeid: 'pve-cluster-log', - model: 'proxmox-cluster-log', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/log', - }, - }); - let store = Ext.create('Proxmox.data.DiffStore', { - rstore: logstore, - appendAtStart: true, - }); - - Ext.apply(me, { - store: store, - stateful: false, - - viewConfig: { - trackOver: false, - stripeRows: true, - getRowClass: function (record, index) { - let pri = record.get('pri'); - if (pri && pri <= 3) { - return 'proxmox-invalid-row'; - } - return undefined; - }, - }, - sortableColumns: false, - columns: [ - { - header: gettext('Time'), - dataIndex: 'time', - width: 150, - renderer: function (value) { - return Ext.Date.format(value, 'M d H:i:s'); - }, - }, - { - header: gettext('Node'), - dataIndex: 'node', - width: 150, - }, - { - header: gettext('Service'), - dataIndex: 'tag', - width: 100, - }, - { - header: 'PID', - dataIndex: 'pid', - width: 100, - }, - { - header: gettext('User name'), - dataIndex: 'user', - renderer: Ext.String.htmlEncode, - width: 150, - }, - { - header: gettext('Severity'), - dataIndex: 'pri', - renderer: PVE.Utils.render_serverity, - width: 100, - }, - { - header: gettext('Message'), - dataIndex: 'msg', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - listeners: { - activate: () => logstore.startUpdate(), - deactivate: () => logstore.stopUpdate(), - destroy: () => logstore.stopUpdate(), - }, - }); - - me.callParent(); - }, -}); -Ext.define( - 'PVE.dc.NodeView', - { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveDcNodeView', - - title: gettext('Nodes'), - disableSelection: true, - scrollable: true, - - columns: [ - { - header: gettext('Name'), - flex: 1, - sortable: true, - dataIndex: 'name', - }, - { - header: 'ID', - width: 40, - sortable: true, - dataIndex: 'nodeid', - }, - { - header: gettext('Online'), - width: 60, - sortable: true, - dataIndex: 'online', - renderer: function (value) { - var cls = value ? 'good' : 'critical'; - return ''; - }, - }, - { - header: gettext('Support'), - width: 100, - sortable: true, - dataIndex: 'level', - renderer: PVE.Utils.render_support_level, - }, - { - header: gettext('Server Address'), - width: 115, - sortable: true, - dataIndex: 'ip', - }, - { - header: gettext('CPU usage'), - sortable: true, - width: 110, - dataIndex: 'cpuusage', - tdCls: 'x-progressbar-default-cell', - xtype: 'widgetcolumn', - widget: { - xtype: 'pveProgressBar', - }, - }, - { - header: gettext('Memory usage'), - width: 110, - sortable: true, - tdCls: 'x-progressbar-default-cell', - dataIndex: 'memoryusage', - xtype: 'widgetcolumn', - widget: { - xtype: 'pveProgressBar', - warningThreshold: 0.9, - criticalThreshold: 0.975, - }, - }, - { - header: gettext('Uptime'), - sortable: true, - dataIndex: 'uptime', - align: 'right', - renderer: Proxmox.Utils.render_uptime, - }, - ], - - stateful: true, - stateId: 'grid-cluster-nodes', - tools: [ - { - type: 'up', - handler: function () { - let view = this.up('grid'); - view.setHeight(Math.max(view.getHeight() - 50, 250)); - }, - }, - { - type: 'down', - handler: function () { - let view = this.up('grid'); - view.setHeight(view.getHeight() + 50); - }, - }, - ], - }, - function () { - Ext.define('pve-dc-nodes', { - extend: 'Ext.data.Model', - fields: ['id', 'type', 'name', 'nodeid', 'ip', 'level', 'local', 'online'], - idProperty: 'id', - }); - }, -); - -Ext.define('PVE.widget.ProgressBar', { - extend: 'Ext.Progress', - alias: 'widget.pveProgressBar', - - animate: true, - textTpl: ['{percent}%'], - - warningThreshold: 0.8, - criticalThreshold: 0.9, - - setValue: function (value) { - let me = this; - - me.callParent([value]); - - me.removeCls(['warning', 'critical']); - - if (value >= me.criticalThreshold) { - me.addCls('critical'); - } else if (value >= me.warningThreshold) { - me.addCls('warning'); - } - }, -}); -Ext.define('PVE.dc.OptionView', { - extend: 'Proxmox.grid.ObjectGrid', - alias: ['widget.pveDcOptionView'], - - onlineHelp: 'datacenter_configuration_file', - - monStoreErrors: true, - userCls: 'proxmox-tags-full', - - add_inputpanel_row: function (name, text, opts) { - var me = this; - - opts = opts || {}; - me.rows = me.rows || {}; - - let canEdit = !Object.hasOwn(opts, 'caps') || opts.caps; - me.rows[name] = { - required: true, - defaultValue: opts.defaultValue, - header: text, - renderer: opts.renderer, - editor: canEdit - ? { - xtype: 'proxmoxWindowEdit', - width: opts.width || 350, - subject: text, - onlineHelp: opts.onlineHelp, - fieldDefaults: { - labelWidth: opts.labelWidth || 100, - }, - setValues: function (values) { - var edit_value = values[name]; - - if (opts.parseBeforeSet) { - edit_value = PVE.Parser.parsePropertyString(edit_value); - } - - Ext.Array.each(this.query('inputpanel'), function (panel) { - panel.setValues(edit_value); - }); - }, - url: opts.url, - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - if (values === undefined || Object.keys(values).length === 0) { - return { delete: name }; - } - var ret_val = {}; - ret_val[name] = PVE.Parser.printPropertyString(values); - return ret_val; - }, - items: opts.items, - }, - ], - } - : undefined, - }; - }, - - render_bwlimits: function (value) { - if (!value) { - return gettext('None'); - } - - let parsed = PVE.Parser.parsePropertyString(value); - return Object.entries(parsed) - .map(([k, v]) => k + ': ' + Proxmox.Utils.format_size(v * 1024) + '/s') - .join(','); - }, - - initComponent: function () { - var me = this; - - me.add_combobox_row('keyboard', gettext('Keyboard Layout'), { - renderer: PVE.Utils.render_kvm_language, - comboItems: Object.entries(PVE.Utils.kvm_keymaps), - defaultValue: '__default__', - deleteEmpty: true, - }); - me.add_text_row('http_proxy', gettext('HTTP proxy'), { - renderer: Ext.htmlEncode, - defaultValue: Proxmox.Utils.noneText, - vtype: 'HttpProxy', - deleteEmpty: true, - }); - me.add_combobox_row('console', gettext('Console Viewer'), { - renderer: PVE.Utils.render_console_viewer, - comboItems: Object.entries(PVE.Utils.console_map), - defaultValue: '__default__', - deleteEmpty: true, - }); - me.add_text_row('email_from', gettext('Email from address'), { - deleteEmpty: true, - vtype: 'proxmoxMail', - defaultValue: 'root@$hostname', - }); - me.add_text_row('mac_prefix', gettext('MAC address prefix'), { - deleteEmpty: true, - vtype: 'MacPrefix', - defaultValue: 'BC:24:11', - }); - me.add_inputpanel_row('migration', gettext('Migration Settings'), { - renderer: PVE.Utils.render_as_property_string, - labelWidth: 120, - url: '/api2/extjs/cluster/options', - defaultKey: 'type', - items: [ - { - xtype: 'displayfield', - name: 'type', - fieldLabel: gettext('Type'), - value: 'secure', - submitValue: true, - }, - { - xtype: 'proxmoxNetworkSelector', - name: 'network', - fieldLabel: gettext('Network'), - value: null, - emptyText: Proxmox.Utils.defaultText, - autoSelect: false, - skipEmptyText: true, - editable: true, - notFoundIsValid: true, - vtype: 'IP64CIDRAddress', - type: 'include_sdn', - }, - ], - }); - me.add_inputpanel_row('replication', gettext('Replication Settings'), { - renderer: PVE.Utils.render_as_property_string, - labelWidth: 120, - url: '/api2/extjs/cluster/options', - defaultKey: 'type', - items: [ - { - xtype: 'displayfield', - name: 'type', - fieldLabel: gettext('Type'), - value: 'secure', - submitValue: true, - }, - { - xtype: 'proxmoxNetworkSelector', - name: 'network', - fieldLabel: gettext('Network'), - value: null, - emptyText: Proxmox.Utils.defaultText, - autoSelect: false, - skipEmptyText: true, - editable: true, - notFoundIsValid: true, - vtype: 'IP64CIDRAddress', - type: 'include_sdn', - }, - ], - }); - me.add_inputpanel_row('ha', gettext('HA Settings'), { - renderer: PVE.Utils.render_dc_ha_opts, - labelWidth: 120, - url: '/api2/extjs/cluster/options', - onlineHelp: 'ha_manager_shutdown_policy', - items: [ - { - xtype: 'proxmoxKVComboBox', - name: 'shutdown_policy', - fieldLabel: gettext('Shutdown Policy'), - deleteEmpty: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (conditional)'], - ['freeze', 'freeze'], - ['failover', 'failover'], - ['migrate', 'migrate'], - ['conditional', 'conditional'], - ], - defaultValue: '__default__', - }, - ], - }); - me.add_inputpanel_row('crs', gettext('Cluster Resource Scheduling'), { - renderer: PVE.Utils.render_as_property_string, - width: 450, - labelWidth: 120, - url: '/api2/extjs/cluster/options', - onlineHelp: 'ha_manager_crs', - items: [ - { - xtype: 'proxmoxKVComboBox', - name: 'ha', - fieldLabel: gettext('HA Scheduling'), - deleteEmpty: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (basic)'], - ['basic', 'Basic (Resource Count)'], - ['static', 'Static Load'], - ], - defaultValue: '__default__', - }, - { - xtype: 'proxmoxcheckbox', - name: 'ha-rebalance-on-start', - fieldLabel: gettext('Rebalance on Start'), - boxLabel: gettext( - 'Use CRS to select the least loaded node when starting an HA service', - ), - value: 0, - }, - ], - }); - me.add_inputpanel_row('u2f', gettext('U2F Settings'), { - renderer: (v) => - !v ? Proxmox.Utils.NoneText : Ext.htmlEncode(PVE.Parser.printPropertyString(v)), - width: 450, - url: '/api2/extjs/cluster/options', - onlineHelp: 'pveum_configure_u2f', - items: [ - { - xtype: 'textfield', - name: 'appid', - fieldLabel: gettext('U2F AppID URL'), - emptyText: gettext('Defaults to origin'), - value: '', - deleteEmpty: true, - skipEmptyText: true, - submitEmptyText: false, - }, - { - xtype: 'textfield', - name: 'origin', - fieldLabel: gettext('U2F Origin'), - emptyText: gettext('Defaults to requesting host URI'), - value: '', - deleteEmpty: true, - skipEmptyText: true, - submitEmptyText: false, - }, - { - xtype: 'box', - height: 25, - html: - `${gettext('Note:')} ` + - gettext('U2F is deprecated, use WebAuthn'), - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext('NOTE: Changing an AppID breaks existing U2F registrations!'), - }, - ], - }); - me.add_inputpanel_row('webauthn', gettext('WebAuthn Settings'), { - renderer: (v) => - !v ? Proxmox.Utils.NoneText : Ext.htmlEncode(PVE.Parser.printPropertyString(v)), - width: 450, - url: '/api2/extjs/cluster/options', - onlineHelp: 'pveum_configure_webauthn', - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('Name'), - name: 'rp', // NOTE: relying party consists of name and id, this is the name - allowBlank: false, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Origin'), - emptyText: Ext.String.format( - gettext('Domain Lockdown (e.g., {0})'), - document.location.origin, - ), - name: 'origin', - allowBlank: true, - }, - { - xtype: 'textfield', - fieldLabel: 'ID', - name: 'id', - allowBlank: false, - listeners: { - dirtychange: (f, isDirty) => - f - .up('panel') - .down('box[id=idChangeWarning]') - .setHidden(!f.originalValue || !isDirty), - }, - }, - { - xtype: 'container', - layout: 'hbox', - items: [ - { - xtype: 'box', - flex: 1, - }, - { - xtype: 'button', - text: gettext('Auto-fill'), - iconCls: 'fa fa-fw fa-pencil-square-o', - handler: function (button, ev) { - let panel = this.up('panel'); - let fqdn = document.location.hostname; - - panel.down('field[name=rp]').setValue(fqdn); - - let idField = panel.down('field[name=id]'); - let currentID = idField.getValue(); - if (!currentID || currentID.length === 0) { - idField.setValue(fqdn); - } - }, - }, - ], - }, - { - xtype: 'box', - height: 25, - html: - `${gettext('Note:')} ` + - gettext('WebAuthn requires using a trusted certificate.'), - }, - { - xtype: 'box', - id: 'idChangeWarning', - hidden: true, - padding: '5 0 0 0', - html: - ' ' + - gettext('Changing the ID breaks existing WebAuthn TFA entries.'), - }, - ], - }); - me.add_inputpanel_row('bwlimit', gettext('Bandwidth Limits'), { - renderer: me.render_bwlimits, - width: 450, - url: '/api2/extjs/cluster/options', - parseBeforeSet: true, - labelWidth: 120, - items: [ - { - xtype: 'pveBandwidthField', - name: 'default', - fieldLabel: gettext('Default'), - emptyText: gettext('none'), - backendUnit: 'KiB', - }, - { - xtype: 'pveBandwidthField', - name: 'restore', - fieldLabel: gettext('Backup Restore'), - emptyText: gettext('default'), - backendUnit: 'KiB', - }, - { - xtype: 'pveBandwidthField', - name: 'migration', - fieldLabel: gettext('Migration'), - emptyText: gettext('default'), - backendUnit: 'KiB', - }, - { - xtype: 'pveBandwidthField', - name: 'clone', - fieldLabel: gettext('Clone'), - emptyText: gettext('default'), - backendUnit: 'KiB', - }, - { - xtype: 'pveBandwidthField', - name: 'move', - fieldLabel: gettext('Disk Move'), - emptyText: gettext('default'), - backendUnit: 'KiB', - }, - ], - }); - me.add_integer_row('max_workers', gettext('Maximal Workers/bulk-action'), { - deleteEmpty: true, - defaultValue: 4, - minValue: 1, - maxValue: 64, // arbitrary but generous limit as limits are good - }); - me.add_inputpanel_row('next-id', gettext('Next Free VMID Range'), { - renderer: PVE.Utils.render_as_property_string, - url: '/api2/extjs/cluster/options', - items: [ - { - xtype: 'proxmoxintegerfield', - name: 'lower', - fieldLabel: gettext('Lower'), - emptyText: '100', - minValue: 100, - maxValue: 1000 * 1000 * 1000 - 1, - submitValue: true, - }, - { - xtype: 'proxmoxintegerfield', - name: 'upper', - fieldLabel: gettext('Upper'), - emptyText: '1.000.000', - minValue: 100, - maxValue: 1000 * 1000 * 1000 - 1, - submitValue: true, - }, - ], - }); - me.rows['tag-style'] = { - required: true, - renderer: (value) => { - if (value === undefined) { - return gettext('No Overrides'); - } - let colors = PVE.UIOptions.parseTagOverrides(value?.['color-map']); - let shape = value.shape; - let shapeText = PVE.UIOptions.tagTreeStyles[shape ?? '__default__']; - let txt = Ext.String.format(gettext('Tree Shape: {0}'), shapeText); - let orderText = PVE.UIOptions.tagOrderOptions[value.ordering ?? '__default__']; - txt += `, ${Ext.String.format(gettext('Ordering: {0}'), orderText)}`; - if (value['case-sensitive']) { - txt += `, ${gettext('Case-Sensitive')}`; - } - if (Object.keys(colors).length > 0) { - txt += `, ${gettext('Color Overrides')}: `; - for (const tag of Object.keys(colors)) { - txt += Proxmox.Utils.getTagElement(tag, colors); - } - } - return txt; - }, - header: gettext('Tag Style Override'), - editor: { - xtype: 'proxmoxWindowEdit', - width: 800, - subject: gettext('Tag Color Override'), - onlineHelp: 'gui_tags', - fieldDefaults: { - labelWidth: 100, - }, - url: '/api2/extjs/cluster/options', - items: [ - { - xtype: 'inputpanel', - setValues: function (values) { - if (values === undefined) { - return undefined; - } - values = values?.['tag-style'] ?? {}; - values.shape = values.shape || '__default__'; - values.colors = values['color-map']; - return Proxmox.panel.InputPanel.prototype.setValues.call(this, values); - }, - onGetValues: function (values) { - let style = {}; - if (values.colors) { - style['color-map'] = values.colors; - } - if (values.shape && values.shape !== '__default__') { - style.shape = values.shape; - } - if (values.ordering) { - style.ordering = values.ordering; - } - if (values['case-sensitive']) { - style['case-sensitive'] = 1; - } - let value = PVE.Parser.printPropertyString(style); - if (value === '') { - return { - delete: 'tag-style', - }; - } - return { - 'tag-style': value, - }; - }, - items: [ - { - name: 'shape', - xtype: 'proxmoxComboGrid', - fieldLabel: gettext('Tree Shape'), - valueField: 'value', - displayField: 'display', - allowBlank: false, - listConfig: { - columns: [ - { - header: gettext('Option'), - dataIndex: 'display', - flex: 1, - }, - { - header: gettext('Preview'), - dataIndex: 'value', - renderer: function (value) { - let cls = value ?? '__default__'; - if (value === '__default__') { - cls = 'circle'; - } - let tags = PVE.Utils.renderTags('preview'); - return `
    ${tags}
    `; - }, - flex: 1, - }, - ], - }, - store: { - data: Object.entries(PVE.UIOptions.tagTreeStyles).map((v) => ({ - value: v[0], - display: v[1], - })), - }, - deleteDefault: true, - defaultValue: '__default__', - deleteEmpty: true, - }, - { - name: 'ordering', - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('Ordering'), - comboItems: Object.entries(PVE.UIOptions.tagOrderOptions), - defaultValue: '__default__', - value: '__default__', - deleteEmpty: true, - }, - { - name: 'case-sensitive', - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Case-Sensitive'), - boxLabel: gettext('Applies to new edits'), - value: 0, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Color Overrides'), - }, - { - name: 'colors', - xtype: 'pveTagColorGrid', - deleteEmpty: true, - height: 300, - }, - ], - }, - ], - }, - }; - - me.rows['user-tag-access'] = { - required: true, - renderer: (value) => { - if (value === undefined) { - return Ext.String.format(gettext('Mode: {0}'), 'free'); - } - let mode = value?.['user-allow'] ?? 'free'; - let list = value?.['user-allow-list']?.join(',') ?? ''; - let modeTxt = Ext.String.format(gettext('Mode: {0}'), mode); - let overrides = PVE.UIOptions.tagOverrides; - let tags = PVE.Utils.renderTags(list, overrides); - let listTxt = tags !== '' ? `, ${gettext('Pre-defined:')} ${tags}` : ''; - return `${modeTxt}${listTxt}`; - }, - header: gettext('User Tag Access'), - editor: { - xtype: 'pveUserTagAccessEdit', - }, - }; - - me.rows['registered-tags'] = { - required: true, - renderer: (value) => { - if (value === undefined) { - return gettext('No Registered Tags'); - } - let overrides = PVE.UIOptions.tagOverrides; - return PVE.Utils.renderTags(value.join(','), overrides); - }, - header: gettext('Registered Tags'), - editor: { - xtype: 'pveRegisteredTagEdit', - }, - }; - - me.add_textareafield_row('consent-text', gettext('Consent Text'), { - deleteEmpty: true, - fieldOpts: { - maxLength: 64 * 1024, - }, - onlineHelp: 'gui_consent_banner', - }); - - me.selModel = Ext.create('Ext.selection.RowModel', {}); - - Ext.apply(me, { - tbar: [ - { - text: gettext('Edit'), - xtype: 'proxmoxButton', - disabled: true, - handler: function () { - me.run_editor(); - }, - selModel: me.selModel, - }, - ], - url: '/api2/json/cluster/options', - editorConfig: { - url: '/api2/extjs/cluster/options', - }, - interval: 5000, - cwidth1: 200, - listeners: { - itemdblclick: me.run_editor, - }, - }); - - me.callParent(); - - // set the new value for the default console - me.mon(me.rstore, 'load', function (store, records, success) { - if (!success) { - return; - } - - var rec = store.getById('console'); - PVE.UIOptions.options.console = rec.data.value; - if (rec.data.value === '__default__') { - delete PVE.UIOptions.options.console; - } - - PVE.UIOptions.options['tag-style'] = store.getById('tag-style')?.data?.value; - PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']); - PVE.UIOptions.fireUIConfigChanged(); - }); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - me.on('deactivate', me.rstore.stopUpdate); - }, -}); -Ext.define('pve-permissions', { - extend: 'Ext.data.TreeModel', - fields: [ - 'text', - 'type', - { - type: 'boolean', - name: 'propagate', - }, - ], -}); - -Ext.define('PVE.dc.PermissionGridPanel', { - extend: 'Ext.tree.Panel', - alias: 'widget.pveUserPermissionGrid', - - onlineHelp: 'chapter_user_management', - - scrollable: true, - layout: 'fit', - rootVisible: false, - animate: false, - sortableColumns: false, - - columns: [ - { - xtype: 'treecolumn', - header: gettext('Path') + '/' + gettext('Permission'), - dataIndex: 'text', - flex: 6, - }, - { - header: gettext('Propagate'), - dataIndex: 'propagate', - flex: 1, - renderer: function (value) { - if (Ext.isDefined(value)) { - return Proxmox.Utils.format_boolean(value); - } - return ''; - }, - }, - ], - - initComponent: function () { - let me = this; - - Proxmox.Utils.API2Request({ - url: '/access/permissions?userid=' + me.userid, - method: 'GET', - failure: function (response, opts) { - Proxmox.Utils.setErrorMask(me, response.htmlStatus); - me.load_task.delay(me.load_delay); - }, - success: function (response, opts) { - Proxmox.Utils.setErrorMask(me, false); - let result = Ext.decode(response.responseText); - let data = result.data || {}; - - let root = { - name: '__root', - expanded: true, - children: [], - }; - let idhash = { - '/': { - children: [], - text: '/', - type: 'path', - }, - }; - Ext.Object.each(data, function (path, perms) { - let path_item = { - text: path, - type: 'path', - children: [], - }; - Ext.Object.each(perms, function (perm, propagate) { - let perm_item = { - text: perm, - type: 'perm', - propagate: propagate === 1, - iconCls: 'fa fa-fw fa-unlock', - leaf: true, - }; - path_item.children.push(perm_item); - path_item.expandable = true; - }); - idhash[path] = path_item; - }); - - Ext.Object.each(idhash, function (path, item) { - let parent_item = idhash['/']; - if (path === '/') { - parent_item = root; - item.expanded = true; - } else { - let split_path = path.split('/'); - while (split_path.pop()) { - let parent_path = split_path.join('/'); - if (idhash[parent_path]) { - parent_item = idhash[parent_path]; - break; - } - } - } - parent_item.children.push(item); - }); - - me.setRootNode(root); - }, - }); - - me.callParent(); - - me.store.sorters.add( - new Ext.util.Sorter({ - sorterFn: function (rec1, rec2) { - let v1 = rec1.data.text, - v2 = rec2.data.text; - if (rec1.data.type !== rec2.data.type) { - v2 = rec1.data.type; - v1 = rec2.data.type; - } - if (v1 > v2) { - return 1; - } else if (v1 < v2) { - return -1; - } - return 0; - }, - }), - ); - }, -}); - -Ext.define('PVE.dc.PermissionView', { - extend: 'Ext.window.Window', - alias: 'widget.userShowPermissionWindow', - mixins: ['Proxmox.Mixin.CBind'], - - scrollable: true, - width: 800, - height: 600, - layout: 'fit', - cbind: { - title: (get) => - Ext.String.htmlEncode(get('userid')) + ` - ${gettext('Granted Permissions')}`, - }, - items: [ - { - xtype: 'pveUserPermissionGrid', - cbind: { - userid: '{userid}', - }, - }, - ], -}); -Ext.define('PVE.dc.PoolEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveDcPoolEdit'], - mixins: ['Proxmox.Mixin.CBind'], - - subject: gettext('Pool'), - - cbindData: { - poolid: '', - isCreate: (cfg) => !cfg.poolid, - }, - - cbind: { - url: (get) => `/api2/extjs/pools/${!get('isCreate') ? '?poolid=' + get('poolid') : ''}`, - method: (get) => (get('isCreate') ? 'POST' : 'PUT'), - }, - - items: [ - { - xtype: 'pmxDisplayEditField', - fieldLabel: gettext('Name'), - cbind: { - editable: '{isCreate}', - value: '{poolid}', - }, - name: 'poolid', - allowBlank: false, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Comment'), - name: 'comment', - allowBlank: true, - }, - ], - - initComponent: function () { - let me = this; - me.callParent(); - if (me.poolid) { - me.load({ - success: function (response) { - let data = response.result.data; - if (Ext.isArray(data)) { - me.setValues(data[0]); - } else { - me.setValues(data); - } - }, - }); - } - }, -}); -Ext.define('PVE.dc.PoolView', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pvePoolView'], - - onlineHelp: 'pveum_pools', - - stateful: true, - stateId: 'grid-pools', - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-pools', - sorters: { - property: 'poolid', - direction: 'ASC', - }, - }); - - var reload = function () { - store.load(); - }; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/pools/', - callback: function () { - reload(); - }, - getUrl: function (rec) { - return '/pools/?poolid=' + rec.getId(); - }, - }); - - var run_editor = function () { - var rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - var win = Ext.create('PVE.dc.PoolEdit', { - poolid: rec.data.poolid, - }); - win.on('destroy', reload); - win.show(); - }; - - var edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - var tbar = [ - { - text: gettext('Create'), - handler: function () { - var win = Ext.create('PVE.dc.PoolEdit', {}); - win.on('destroy', reload); - win.show(); - }, - }, - edit_btn, - remove_btn, - ]; - - Proxmox.Utils.monStoreErrors(me, store); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: tbar, - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: gettext('Name'), - width: 200, - sortable: true, - dataIndex: 'poolid', - }, - { - header: gettext('Comment'), - sortable: false, - renderer: Ext.String.htmlEncode, - dataIndex: 'comment', - flex: 1, - }, - ], - listeners: { - activate: reload, - itemdblclick: run_editor, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.dc.RoleEdit', { - extend: 'Proxmox.window.Edit', - xtype: 'pveDcRoleEdit', - - width: 400, - - initComponent: function () { - var me = this; - - me.isCreate = !me.roleid; - - var url; - var method; - - if (me.isCreate) { - url = '/api2/extjs/access/roles'; - method = 'POST'; - } else { - url = '/api2/extjs/access/roles/' + me.roleid; - method = 'PUT'; - } - - Ext.applyIf(me, { - subject: gettext('Role'), - url: url, - method: method, - items: [ - { - xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield', - name: 'roleid', - value: me.roleid, - allowBlank: false, - fieldLabel: gettext('Name'), - }, - { - xtype: 'pvePrivilegesSelector', - name: 'privs', - value: me.privs, - allowBlank: false, - fieldLabel: gettext('Privileges'), - }, - ], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response) { - var data = response.result.data; - var keys = Ext.Object.getKeys(data); - - me.setValues({ - privs: keys, - roleid: me.roleid, - }); - }, - }); - } - }, -}); -Ext.define('PVE.dc.RoleView', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveRoleView'], - - onlineHelp: 'pveum_roles', - - stateful: true, - stateId: 'grid-roles', - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pmx-roles', - sorters: { - property: 'roleid', - direction: 'ASC', - }, - }); - Proxmox.Utils.monStoreErrors(me, store); - - let sm = Ext.create('Ext.selection.RowModel', {}); - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - if (rec.data.special) { - return; - } - Ext.create('PVE.dc.RoleEdit', { - roleid: rec.data.roleid, - privs: rec.data.privs, - listeners: { - destroy: () => store.load(), - }, - autoShow: true, - }); - }; - - Ext.apply(me, { - store: store, - selModel: sm, - - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: gettext('Built-In'), - width: 65, - sortable: true, - dataIndex: 'special', - renderer: Proxmox.Utils.format_boolean, - }, - { - header: gettext('Name'), - width: 150, - sortable: true, - dataIndex: 'roleid', - }, - { - itemid: 'privs', - header: gettext('Privileges'), - sortable: false, - renderer: (value, metaData) => { - if (!value) { - return '-'; - } - metaData.style = 'white-space:normal;'; // allow word wrap - return value.replace(/,/g, ' '); - }, - variableRowHeight: true, - dataIndex: 'privs', - flex: 1, - }, - ], - listeners: { - activate: function () { - store.load(); - }, - itemdblclick: run_editor, - }, - tbar: [ - { - text: gettext('Create'), - handler: function () { - Ext.create('PVE.dc.RoleEdit', { - listeners: { - destroy: () => store.load(), - }, - autoShow: true, - }); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - enableFn: (rec) => !rec.data.special, - }, - { - xtype: 'proxmoxStdRemoveButton', - selModel: sm, - callback: () => store.load(), - baseurl: '/access/roles/', - enableFn: (rec) => !rec.data.special, - }, - ], - }); - - me.callParent(); - }, -}); -Ext.define('pve-security-groups', { - extend: 'Ext.data.Model', - - fields: ['group', 'comment', 'digest'], - idProperty: 'group', -}); - -Ext.define('PVE.SecurityGroupEdit', { - extend: 'Proxmox.window.Edit', - - base_url: '/cluster/firewall/groups', - - allow_iface: false, - - initComponent: function () { - var me = this; - - me.isCreate = me.group_name === undefined; - - var subject; - - me.url = '/api2/extjs' + me.base_url; - me.method = 'POST'; - - var items = [ - { - xtype: 'textfield', - name: 'group', - value: me.group_name || '', - fieldLabel: gettext('Name'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'comment', - value: me.group_comment || '', - fieldLabel: gettext('Comment'), - }, - ]; - - if (me.isCreate) { - subject = gettext('Security Group'); - } else { - subject = gettext('Security Group') + " '" + me.group_name + "'"; - items.push({ - xtype: 'hiddenfield', - name: 'rename', - value: me.group_name, - }); - } - - var ipanel = Ext.create('Proxmox.panel.InputPanel', { - // InputPanel does not have a 'create' property, does it need a 'isCreate' - isCreate: me.isCreate, - items: items, - }); - - Ext.apply(me, { - subject: subject, - items: [ipanel], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.SecurityGroupList', { - extend: 'Ext.grid.Panel', - alias: 'widget.pveSecurityGroupList', - - stateful: true, - stateId: 'grid-securitygroups', - - rulePanel: undefined, - - addBtn: undefined, - removeBtn: undefined, - editBtn: undefined, - - base_url: '/cluster/firewall/groups', - - initComponent: function () { - let me = this; - if (!me.base_url) { - throw 'no base_url specified'; - } - - let store = new Ext.data.Store({ - model: 'pve-security-groups', - proxy: { - type: 'proxmox', - url: '/api2/json' + me.base_url, - }, - sorters: { - property: 'group', - direction: 'ASC', - }, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let caps = Ext.state.Manager.get('GuiCap'); - let canEdit = !!caps.dc['Sys.Modify']; - - let reload = function () { - let oldrec = sm.getSelection()[0]; - store.load((records, operation, success) => { - if (oldrec) { - let rec = store.findRecord('group', oldrec.data.group, 0, false, true, true); - if (rec) { - sm.select(rec); - } - } - }); - }; - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec || !canEdit) { - return; - } - Ext.create('PVE.SecurityGroupEdit', { - digest: rec.data.digest, - group_name: rec.data.group, - group_comment: rec.data.comment, - listeners: { - destroy: () => reload(), - }, - autoShow: true, - }); - }; - - me.editBtn = new Proxmox.button.Button({ - text: gettext('Edit'), - enableFn: (rec) => canEdit, - disabled: true, - selModel: sm, - handler: run_editor, - }); - me.addBtn = new Proxmox.button.Button({ - text: gettext('Create'), - disabled: !canEdit, - handler: function () { - sm.deselectAll(); - var win = Ext.create('PVE.SecurityGroupEdit', {}); - win.show(); - win.on('destroy', reload); - }, - }); - - me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: me.base_url + '/', - enableFn: (rec) => canEdit && rec && me.base_url, - callback: () => reload(), - }); - - Ext.apply(me, { - store: store, - tbar: ['' + gettext('Group') + ':', me.addBtn, me.removeBtn, me.editBtn], - selModel: sm, - columns: [ - { - header: gettext('Group'), - dataIndex: 'group', - width: '100', - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 1, - }, - ], - listeners: { - itemdblclick: run_editor, - select: function (_sm, rec) { - if (!me.rulePanel) { - me.rulePanel = me.up('panel').down('pveFirewallRules'); - } - me.rulePanel.setBaseUrl(`/cluster/firewall/groups/${rec.data.group}`); - }, - deselect: function () { - if (!me.rulePanel) { - me.rulePanel = me.up('panel').down('pveFirewallRules'); - } - me.rulePanel.setBaseUrl(undefined); - }, - show: reload, - }, - }); - - me.callParent(); - - store.load(); - }, -}); - -Ext.define('PVE.SecurityGroups', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSecurityGroups', - - title: 'Security Groups', - onlineHelp: 'pve_firewall_security_groups', - - layout: 'border', - - items: [ - { - xtype: 'pveFirewallRules', - region: 'center', - allow_groups: false, - list_refs_url: '/cluster/firewall/refs', - tbar_prefix: '' + gettext('Rules') + ':', - border: false, - firewall_type: 'group', - }, - { - xtype: 'pveSecurityGroupList', - region: 'west', - width: '25%', - border: false, - split: true, - }, - ], - listeners: { - show: function () { - let sglist = this.down('pveSecurityGroupList'); - sglist.fireEvent('show', sglist); - }, - }, -}); -Ext.define( - 'PVE.dc.StorageView', - { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveStorageView'], - - onlineHelp: 'chapter_storage', - - stateful: true, - stateId: 'grid-dc-storage', - - createStorageEditWindow: function (type, sid) { - let schema = PVE.Utils.storageSchema[type]; - if (!schema || !schema.ipanel) { - throw 'no editor registered for storage type: ' + type; - } - - Ext.create('PVE.storage.BaseEdit', { - paneltype: 'PVE.storage.' + schema.ipanel, - type: type, - storageId: sid, - canDoBackups: schema.backups, - autoShow: true, - listeners: { - destroy: this.reloadStore, - }, - }); - }, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-storage', - proxy: { - type: 'proxmox', - url: '/api2/json/storage', - }, - sorters: { - property: 'storage', - direction: 'ASC', - }, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - let { type, storage } = rec.data; - me.createStorageEditWindow(type, storage); - }; - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/storage/', - callback: () => store.load(), - }); - - // else we cannot dynamically generate the add menu handlers - let addHandleGenerator = function (type) { - return function () { - me.createStorageEditWindow(type); - }; - }; - let addMenuItems = []; - for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) { - if (storage.hideAdd) { - continue; - } - addMenuItems.push({ - text: PVE.Utils.format_storage_type(type), - iconCls: 'fa fa-fw fa-' + storage.faIcon, - handler: addHandleGenerator(type), - }); - } - - Ext.apply(me, { - store: store, - reloadStore: () => store.load(), - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: addMenuItems, - }), - }, - remove_btn, - edit_btn, - ], - columns: [ - { - header: 'ID', - flex: 2, - sortable: true, - dataIndex: 'storage', - }, - { - header: gettext('Type'), - flex: 1, - sortable: true, - dataIndex: 'type', - renderer: PVE.Utils.format_storage_type, - }, - { - header: gettext('Content'), - flex: 3, - sortable: true, - dataIndex: 'content', - renderer: PVE.Utils.format_content_types, - }, - { - header: gettext('Path') + '/' + gettext('Target'), - flex: 2, - sortable: true, - dataIndex: 'path', - renderer: function (value, metaData, record) { - if (record.data.target) { - return record.data.target; - } - return value; - }, - }, - { - header: gettext('Shared'), - flex: 1, - sortable: true, - dataIndex: 'shared', - renderer: Proxmox.Utils.format_boolean, - }, - { - header: gettext('Enabled'), - flex: 1, - sortable: true, - dataIndex: 'disable', - renderer: Proxmox.Utils.format_neg_boolean, - }, - { - header: gettext('Bandwidth Limit'), - flex: 2, - sortable: true, - dataIndex: 'bwlimit', - }, - ], - listeners: { - activate: () => store.load(), - itemdblclick: run_editor, - }, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-storage', { - extend: 'Ext.data.Model', - fields: [ - 'path', - 'type', - 'content', - 'server', - 'portal', - 'target', - 'export', - 'storage', - { name: 'shared', type: 'boolean' }, - { name: 'disable', type: 'boolean' }, - ], - idProperty: 'storage', - }); - }, -); -Ext.define('PVE.dc.Summary', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveDcSummary', - - scrollable: true, - - bodyPadding: 5, - - layout: 'column', - - defaults: { - padding: 5, - columnWidth: 1, - }, - - items: [ - { - itemId: 'dcHealth', - xtype: 'pveDcHealth', - }, - { - itemId: 'dcGuests', - xtype: 'pveDcGuests', - }, - { - title: gettext('Resources'), - xtype: 'panel', - minHeight: 250, - bodyPadding: 5, - layout: 'hbox', - defaults: { - xtype: 'proxmoxGauge', - flex: 1, - }, - items: [ - { - title: gettext('CPU'), - itemId: 'cpu', - }, - { - title: gettext('Memory'), - itemId: 'memory', - warningThreshold: 0.9, - criticalThreshold: 0.975, - }, - { - title: gettext('Storage'), - itemId: 'storage', - }, - ], - }, - { - itemId: 'nodeview', - xtype: 'pveDcNodeView', - height: 250, - }, - { - title: gettext('Subscriptions'), - height: 220, - items: [ - { - xtype: 'pveHealthWidget', - itemId: 'subscriptions', - userCls: 'pointer', - listeners: { - element: 'el', - click: function () { - if (this.component.userCls === 'pointer') { - window.open( - 'https://www.proxmox.com/en/proxmox-virtual-environment/pricing', - '_blank', - ); - } - }, - }, - }, - ], - }, - ], - - listeners: { - resize: function (panel) { - Proxmox.Utils.updateColumns(panel); - }, - }, - - initComponent: function () { - var me = this; - - var rstore = Ext.create('Proxmox.data.UpdateStore', { - interval: 3000, - storeid: 'pve-cluster-status', - model: 'pve-dc-nodes', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/status', - }, - }); - - var gridstore = Ext.create('Proxmox.data.DiffStore', { - rstore: rstore, - filters: { - property: 'type', - value: 'node', - }, - sorters: { - property: 'id', - direction: 'ASC', - }, - }); - - me.callParent(); - - me.getComponent('nodeview').setStore(gridstore); - - var gueststatus = me.getComponent('dcGuests'); - - var cpustat = me.down('#cpu'); - var memorystat = me.down('#memory'); - var storagestat = me.down('#storage'); - var sp = Ext.state.Manager.getProvider(); - - me.mon(PVE.data.ResourceStore, 'load', function (curstore, results) { - me.suspendLayout = true; - - let cpu = 0, - maxcpu = 0; - let memory = 0, - maxmem = 0; - - let used = 0, - total = 0; - let countedStorage = {}, - usableStorages = {}; - let storages = sp.get('dash-storages') || ''; - storages - .split(',') - .filter((v) => v !== '') - .forEach((storage) => { - usableStorages[storage] = true; - }); - - let qemu = { - running: 0, - paused: 0, - stopped: 0, - template: 0, - }; - let lxc = { - running: 0, - paused: 0, - stopped: 0, - template: 0, - }; - let error = 0; - - for (const { data } of results) { - switch (data.type) { - case 'node': - cpu += data.cpu * data.maxcpu; - maxcpu += data.maxcpu || 0; - memory += data.mem || 0; - maxmem += data.maxmem || 0; - - if (gridstore.getById(data.id)) { - let griditem = gridstore.getById(data.id); - griditem.set('cpuusage', data.cpu); - let max = data.maxmem || 1; - let val = data.mem || 0; - griditem.set('memoryusage', val / max); - griditem.set('uptime', data.uptime); - griditem.commit(); // else the store marks the field as dirty - } - break; - case 'storage': { - let sid = !data.shared || data.storage === 'local' ? data.id : data.storage; - if (!Ext.Object.isEmpty(usableStorages)) { - if (usableStorages[data.id] !== true) { - break; - } - sid = data.id; - } else if (countedStorage[sid]) { - break; - } - - if (data.status === 'unknown') { - break; - } - - used += data.disk; - total += data.maxdisk; - countedStorage[sid] = true; - break; - } - case 'qemu': - qemu[data.template ? 'template' : data.status]++; - if (data.hastate === 'error') { - error++; - } - break; - case 'lxc': - lxc[data.template ? 'template' : data.status]++; - if (data.hastate === 'error') { - error++; - } - break; - default: - break; - } - } - - let text = Ext.String.format(gettext('of {0} CPU(s)'), maxcpu); - cpustat.updateValue(cpu / maxcpu, text); - - text = Ext.String.format( - gettext('{0} of {1}'), - Proxmox.Utils.render_size(memory), - Proxmox.Utils.render_size(maxmem), - ); - memorystat.updateValue(memory / maxmem, text); - - text = Ext.String.format( - gettext('{0} of {1}'), - Proxmox.Utils.render_size(used), - Proxmox.Utils.render_size(total), - ); - storagestat.updateValue(used / total, text); - - gueststatus.updateValues(qemu, lxc, error); - - me.suspendLayout = false; - me.updateLayout(true); - }); - - let dcHealth = me.getComponent('dcHealth'); - me.mon(rstore, 'load', dcHealth.updateStatus, dcHealth); - - let subs = me.down('#subscriptions'); - me.mon(rstore, 'load', function (store, records, success) { - var level; - var mixed = false; - for (let i = 0; i < records.length; i++) { - let node = records[i]; - if (node.get('type') !== 'node' || node.get('status') === 'offline') { - continue; - } - - let curlevel = node.get('level'); - if (curlevel === '') { - // no subscription beats all, set it and break the loop - level = ''; - break; - } - - if (level === undefined) { - // save level - level = curlevel; - } else if (level !== curlevel) { - // detect different levels - mixed = true; - } - } - - let data = { - title: Proxmox.Utils.unknownText, - text: Proxmox.Utils.unknownText, - iconCls: PVE.Utils.get_health_icon(undefined, true), - }; - if (level === '') { - data = { - title: gettext('No Subscription'), - iconCls: PVE.Utils.get_health_icon('critical', true), - text: gettext('You have at least one node without subscription.'), - }; - subs.setUserCls('pointer'); - } else if (mixed) { - data = { - title: gettext('Mixed Subscriptions'), - iconCls: PVE.Utils.get_health_icon('warning', true), - text: gettext('Warning: Your subscription levels are not the same.'), - }; - subs.setUserCls('pointer'); - } else if (level) { - data = { - title: PVE.Utils.render_support_level(level), - iconCls: PVE.Utils.get_health_icon('good', true), - text: gettext('Your subscription status is valid.'), - }; - subs.setUserCls(''); - } - - subs.setData(data); - }); - - me.on('destroy', function () { - rstore.stopUpdate(); - }); - - me.mon(sp, 'statechange', function (provider, key, value) { - if (key !== 'summarycolumns') { - return; - } - Proxmox.Utils.updateColumns(me); - }); - - rstore.startUpdate(); - }, -}); -Ext.define('PVE.dc.Support', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveDcSupport', - pveGuidePath: '/pve-docs/index.html', - onlineHelp: 'getting_help', - - invalidHtml: '

    No valid subscription

    ' + PVE.Utils.noSubKeyHtml, - - communityHtml: - 'Please use the public community forum for any questions.', - - activeHtml: - 'Please use our support portal for any questions. You can also use the public community forum to get additional information.', - - bugzillaHtml: - '

    Bug Tracking

    Our bug tracking system is available here.', - - docuHtml: function () { - var me = this; - var guideUrl = window.location.origin + me.pveGuidePath; - var text = Ext.String.format( - '

    Documentation

    ' + - 'The official Proxmox VE Administration Guide' + - ' is included with this installation and can be browsed at ' + - '{0}', - guideUrl, - ); - return text; - }, - - updateActive: function (data) { - var me = this; - - var html = '

    ' + data.productname + '

    ' + me.activeHtml; - html += '

    ' + me.docuHtml(); - html += '

    ' + me.bugzillaHtml; - - me.update(html); - }, - - updateCommunity: function (data) { - var me = this; - - var html = '

    ' + data.productname + '

    ' + me.communityHtml; - html += '

    ' + me.docuHtml(); - html += '

    ' + me.bugzillaHtml; - - me.update(html); - }, - - updateInactive: function (data) { - var me = this; - me.update(me.invalidHtml); - }, - - initComponent: function () { - let me = this; - - let reload = function () { - Proxmox.Utils.API2Request({ - url: '/nodes/localhost/subscription', - method: 'GET', - waitMsgTarget: me, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - me.update( - `${gettext('Unable to load subscription status')}: ${response.htmlStatus}`, - ); - }, - success: function (response, opts) { - let data = response.result.data; - if (data?.status.toLowerCase() === 'active') { - if (data.level === 'c') { - me.updateCommunity(data); - } else { - me.updateActive(data); - } - } else { - me.updateInactive(data); - } - }, - }); - }; - - Ext.apply(me, { - autoScroll: true, - bodyStyle: 'padding:10px', - listeners: { - activate: reload, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.dc.SyncWindow', { - extend: 'Ext.window.Window', - - title: gettext('Realm Sync'), - - width: 600, - bodyPadding: 10, - modal: true, - resizable: false, - - controller: { - xclass: 'Ext.app.ViewController', - - control: { - form: { - validitychange: function (field, valid) { - let me = this; - me.lookup('preview_btn').setDisabled(!valid); - me.lookup('sync_btn').setDisabled(!valid); - }, - }, - button: { - click: function (btn) { - if (btn.reference === 'help_btn') { - return; - } - this.sync_realm(btn.reference === 'preview_btn'); - }, - }, - }, - - sync_realm: function (is_preview) { - let me = this; - let view = me.getView(); - let ipanel = me.lookup('ipanel'); - let params = ipanel.getValues(); - - let vanished_opts = []; - ['acl', 'entry', 'properties'].forEach((prop) => { - if (params[`remove-vanished-${prop}`]) { - vanished_opts.push(prop); - } - delete params[`remove-vanished-${prop}`]; - }); - if (vanished_opts.length > 0) { - params['remove-vanished'] = vanished_opts.join(';'); - } else { - params['remove-vanished'] = 'none'; - } - - params['dry-run'] = is_preview ? 1 : 0; - Proxmox.Utils.API2Request({ - url: `/access/domains/${view.realm}/sync`, - waitMsgTarget: view, - method: 'POST', - params, - failure: function (response) { - view.show(); - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response) { - view.hide(); - Ext.create('Proxmox.window.TaskViewer', { - upid: response.result.data, - listeners: { - destroy: function () { - if (is_preview) { - view.show(); - } else { - view.close(); - } - }, - }, - }).show(); - }, - }); - }, - }, - - items: [ - { - xtype: 'form', - reference: 'form', - border: false, - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - items: [ - { - xtype: 'inputpanel', - reference: 'ipanel', - column1: [ - { - xtype: 'proxmoxKVComboBox', - name: 'scope', - fieldLabel: gettext('Scope'), - value: '', - emptyText: gettext('No default available'), - deleteEmpty: false, - allowBlank: false, - comboItems: [ - ['users', gettext('Users')], - ['groups', gettext('Groups')], - ['both', gettext('Users and Groups')], - ], - }, - ], - - column2: [ - { - xtype: 'proxmoxKVComboBox', - value: '1', - deleteEmpty: false, - allowBlank: false, - comboItems: [ - ['1', Proxmox.Utils.yesText], - ['0', Proxmox.Utils.noText], - ], - name: 'enable-new', - fieldLabel: gettext('Enable new'), - }, - ], - - columnB: [ - { - xtype: 'fieldset', - title: gettext('Remove Vanished Options'), - items: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('ACL'), - name: 'remove-vanished-acl', - boxLabel: gettext('Remove ACLs of vanished users and groups.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Entry'), - name: 'remove-vanished-entry', - boxLabel: gettext('Remove vanished user and group entries.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Properties'), - name: 'remove-vanished-properties', - boxLabel: gettext( - 'Remove vanished properties from synced users.', - ), - }, - ], - }, - { - xtype: 'displayfield', - reference: 'defaulthint', - value: gettext('Default sync options can be set by editing the realm.'), - userCls: 'pmx-hint', - hidden: true, - }, - ], - }, - ], - }, - ], - - buttons: [ - { - xtype: 'proxmoxHelpButton', - reference: 'help_btn', - onlineHelp: 'pveum_ldap_sync', - hidden: false, - }, - '->', - { - text: gettext('Preview'), - reference: 'preview_btn', - }, - { - text: gettext('Sync'), - reference: 'sync_btn', - }, - ], - - initComponent: function () { - let me = this; - - if (!me.realm) { - throw 'no realm defined'; - } - - me.callParent(); - - Proxmox.Utils.API2Request({ - url: `/access/domains/${me.realm}`, - waitMsgTarget: me, - method: 'GET', - failure: function (response) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - me.close(); - }, - success: function (response) { - let default_options = response.result.data['sync-defaults-options']; - if (default_options) { - let options = PVE.Parser.parsePropertyString(default_options); - if (options['remove-vanished']) { - let opts = options['remove-vanished'].split(';'); - for (const opt of opts) { - options[`remove-vanished-${opt}`] = 1; - } - } - let ipanel = me.lookup('ipanel'); - ipanel.setValues(options); - } else { - me.lookup('defaulthint').setVisible(true); - } - - // check validity for button state - me.lookup('form').isValid(); - }, - }); - }, -}); -/* This class defines the "Tasks" tab of the bottom status panel - * Tasks are jobs with a start, end and log output - */ - -Ext.define('PVE.dc.Tasks', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveClusterTasks'], - - initComponent: function () { - let me = this; - - let taskstore = Ext.create('Proxmox.data.UpdateStore', { - storeId: 'pve-cluster-tasks', - model: 'proxmox-tasks', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/tasks', - }, - }); - let store = Ext.create('Proxmox.data.DiffStore', { - rstore: taskstore, - sortAfterUpdate: true, - appendAtStart: true, - sorters: [ - { - property: 'pid', - direction: 'DESC', - }, - { - property: 'starttime', - direction: 'DESC', - }, - ], - }); - - let run_task_viewer = function () { - var sm = me.getSelectionModel(); - var rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: rec.data.upid, - endtime: rec.data.endtime, - }); - }; - - Ext.apply(me, { - store: store, - stateful: false, - viewConfig: { - trackOver: false, - stripeRows: true, // does not work with getRowClass() - getRowClass: function (record, index) { - let taskState = record.get('status'); - if (taskState) { - let parsed = Proxmox.Utils.parse_task_status(taskState); - if (parsed === 'warning') { - return 'proxmox-warning-row'; - } else if (parsed !== 'ok') { - return 'proxmox-invalid-row'; - } - } - return ''; - }, - }, - sortableColumns: false, - columns: [ - { - header: gettext('Start Time'), - dataIndex: 'starttime', - width: 150, - renderer: function (value) { - return Ext.Date.format(value, 'M d H:i:s'); - }, - }, - { - header: gettext('End Time'), - dataIndex: 'endtime', - width: 150, - align: 'inherit', - renderer: function (value, metaData, record) { - metaData.tdStyle = 'text-align: left;'; - if (record.data.pid) { - if ( - record.data.type === 'vncproxy' || - record.data.type === 'vncshell' || - record.data.type === 'spiceproxy' - ) { - metaData.tdStyle = 'text-align: center;'; - return ''; - } else { - metaData.tdCls = 'x-grid-row-loading'; - } - return ''; - } - - return Ext.Date.format(value, 'M d H:i:s'); - }, - }, - { - header: gettext('Node'), - dataIndex: 'node', - width: 100, - }, - { - header: gettext('User name'), - dataIndex: 'user', - renderer: Ext.String.htmlEncode, - width: 150, - }, - { - header: gettext('Description'), - dataIndex: 'upid', - flex: 1, - renderer: Proxmox.Utils.render_upid, - }, - { - header: gettext('Status'), - dataIndex: 'status', - width: 200, - renderer: function (value, metaData, record) { - if (record.data.pid) { - if (record.data.type !== 'vncproxy') { - metaData.tdCls = 'x-grid-row-loading'; - } - return ''; - } - return Proxmox.Utils.format_task_status(value); - }, - }, - { - xtype: 'actioncolumn', - width: 30, - align: 'center', - tooltip: gettext('Actions'), - items: [ - { - iconCls: 'fa fa-chevron-right', - tooltip: gettext('View Task'), - handler: function (_grid, _rowIndex, _colIndex, _item, _e, rec) { - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: rec.data.upid, - endtime: rec.data.endtime, - }); - }, - }, - ], - }, - ], - listeners: { - itemdblclick: run_task_viewer, - show: () => taskstore.startUpdate(), - destroy: () => taskstore.stopUpdate(), - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.dc.TokenEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveDcTokenEdit'], - mixins: ['Proxmox.Mixin.CBind'], - - subject: gettext('Token'), - onlineHelp: 'pveum_tokens', - - isAdd: true, - isCreate: false, - - method: 'POST', - url: '/api2/extjs/access/users/', - - defaultFocus: 'field[disabled=false][hidden=false][name=tokenid]', - - items: { - xtype: 'inputpanel', - onGetValues: function (values) { - let me = this; - let win = me.up('pveDcTokenEdit'); - win.url = '/api2/extjs/access/users/'; - let uid = encodeURIComponent(values.userid); - let tid = encodeURIComponent(values.tokenid); - delete values.userid; - delete values.tokenid; - - win.url += `${uid}/token/${tid}`; - return values; - }, - column1: [ - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{isCreate}', - }, - submitValue: true, - editConfig: { - xtype: 'pmxUserSelector', - allowBlank: false, - }, - name: 'userid', - value: Proxmox.UserName, - renderer: Ext.String.htmlEncode, - fieldLabel: gettext('User'), - }, - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{isCreate}', - }, - name: 'tokenid', - fieldLabel: gettext('Token ID'), - submitValue: true, - minLength: 2, - allowBlank: false, - }, - ], - column2: [ - { - xtype: 'proxmoxcheckbox', - name: 'privsep', - checked: true, - uncheckedValue: 0, - fieldLabel: gettext('Privilege Separation'), - }, - { - xtype: 'pmxExpireDate', - name: 'expire', - }, - ], - columnB: [ - { - xtype: 'textfield', - name: 'comment', - fieldLabel: gettext('Comment'), - }, - ], - }, - - initComponent: function () { - let me = this; - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - me.setValues(response.result.data); - }, - }); - } - }, - apiCallDone: function (success, response, options) { - let res = response.result.data; - if (!success || !res.value) { - return; - } - - Ext.create('PVE.dc.TokenShow', { - autoShow: true, - tokenid: res['full-tokenid'], - secret: res.value, - }); - }, -}); - -Ext.define('PVE.dc.TokenShow', { - extend: 'Ext.window.Window', - alias: ['widget.pveTokenShow'], - mixins: ['Proxmox.Mixin.CBind'], - - width: 600, - modal: true, - resizable: false, - title: gettext('Token Secret'), - - items: [ - { - xtype: 'container', - layout: 'form', - bodyPadding: 10, - border: false, - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - padding: '0 10 10 10', - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('Token ID'), - cbind: { - value: '{tokenid}', - }, - editable: false, - }, - { - xtype: 'textfield', - fieldLabel: gettext('Secret'), - inputId: 'token-secret-value', - cbind: { - value: '{secret}', - }, - editable: false, - }, - ], - }, - { - xtype: 'component', - border: false, - padding: '10 10 10 10', - userCls: 'pmx-hint', - html: gettext('Please record the API token secret - it will only be displayed now'), - }, - ], - buttons: [ - { - handler: function (b) { - document.getElementById('token-secret-value').select(); - document.execCommand('copy'); - }, - text: gettext('Copy Secret Value'), - iconCls: 'fa fa-clipboard', - }, - ], -}); -Ext.define('PVE.dc.TokenView', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveTokenView'], - - onlineHelp: 'chapter_user_management', - - stateful: true, - stateId: 'grid-tokens', - - initComponent: function () { - let me = this; - - let caps = Ext.state.Manager.get('GuiCap'); - - let store = new Ext.data.Store({ - id: 'tokens', - model: 'pve-tokens', - sorters: 'id', - }); - - let reload = function () { - Proxmox.Utils.API2Request({ - url: '/access/users/?full=1', - method: 'GET', - failure: function (response, opts) { - Proxmox.Utils.setErrorMask(me, response.htmlStatus); - me.load_task.delay(me.load_delay); - }, - success: function (response, opts) { - Proxmox.Utils.setErrorMask(me, false); - let result = Ext.decode(response.responseText); - let data = result.data || []; - let records = []; - Ext.Array.each(data, function (user) { - let tokens = user.tokens || []; - Ext.Array.each(tokens, function (token) { - let r = {}; - r.id = user.userid + '!' + token.tokenid; - r.userid = user.userid; - r.tokenid = token.tokenid; - r.comment = token.comment; - r.expire = token.expire; - r.privsep = token.privsep === 1; - records.push(r); - }); - }); - store.loadData(records); - }, - }); - }; - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let urlFromRecord = (rec) => { - let uid = encodeURIComponent(rec.data.userid); - let tid = encodeURIComponent(rec.data.tokenid); - return `/access/users/${uid}/token/${tid}`; - }; - - let hasTokenCRUDPermissions = function (userid) { - return userid === Proxmox.UserName || !!caps.access['User.Modify']; - }; - - let run_editor = function (rec) { - if (!hasTokenCRUDPermissions(rec.data.userid)) { - return; - } - - let win = Ext.create('PVE.dc.TokenEdit', { - method: 'PUT', - url: urlFromRecord(rec), - }); - win.setValues(rec.data); - win.on('destroy', reload); - win.show(); - }; - - let tbar = [ - { - text: gettext('Add'), - handler: function (btn, e) { - let data = {}; - let win = Ext.create('PVE.dc.TokenEdit', { - isCreate: true, - }); - win.setValues(data); - win.on('destroy', reload); - win.show(); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - disabled: true, - enableFn: (rec) => hasTokenCRUDPermissions(rec.data.userid), - selModel: sm, - handler: (btn, e, rec) => run_editor(rec), - }, - { - xtype: 'proxmoxStdRemoveButton', - selModel: sm, - enableFn: (rec) => hasTokenCRUDPermissions(rec.data.userid), - callback: reload, - getUrl: urlFromRecord, - }, - '-', - { - xtype: 'proxmoxButton', - text: gettext('Show Permissions'), - disabled: true, - selModel: sm, - handler: function (btn, event, rec) { - Ext.create('PVE.dc.PermissionView', { - autoShow: true, - userid: rec.data.id, - }); - }, - }, - ]; - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: tbar, - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: gettext('User name'), - dataIndex: 'userid', - renderer: (uid) => { - let realmIndex = uid.lastIndexOf('@'); - let user = Ext.String.htmlEncode(uid.substr(0, realmIndex)); - let realm = Ext.String.htmlEncode(uid.substr(realmIndex)); - return `${user} ${realm}`; - }, - flex: 2, - }, - { - header: gettext('Token Name'), - dataIndex: 'tokenid', - hideable: false, - flex: 1, - }, - { - header: gettext('Expire'), - dataIndex: 'expire', - hideable: false, - renderer: Proxmox.Utils.format_expire, - flex: 1, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.String.htmlEncode, - flex: 3, - }, - { - header: gettext('Privilege Separation'), - dataIndex: 'privsep', - hideable: false, - renderer: Proxmox.Utils.format_boolean, - flex: 1, - }, - ], - listeners: { - activate: reload, - itemdblclick: (view, rec) => run_editor(rec), - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.dc.UserEdit', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveDcUserEdit'], - - isAdd: true, - - initComponent: function () { - let me = this; - - me.isCreate = !me.userid; - - let url = '/api2/extjs/access/users'; - let method = 'POST'; - if (!me.isCreate) { - url += '/' + encodeURIComponent(me.userid); - method = 'PUT'; - } - - let verifypw, pwfield; - let validate_pw = function () { - if (verifypw.getValue() !== pwfield.getValue()) { - return gettext('Passwords do not match'); - } - return true; - }; - verifypw = Ext.createWidget('textfield', { - inputType: 'password', - fieldLabel: gettext('Confirm password'), - name: 'verifypassword', - submitValue: false, - disabled: true, - hidden: true, - validator: validate_pw, - }); - - pwfield = Ext.createWidget('textfield', { - inputType: 'password', - fieldLabel: gettext('Password'), - minLength: 8, - name: 'password', - disabled: true, - hidden: true, - validator: validate_pw, - }); - - let column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'userid', - fieldLabel: gettext('User name'), - value: me.userid, - renderer: Ext.String.htmlEncode, - allowBlank: false, - submitValue: !!me.isCreate, - }, - pwfield, - verifypw, - { - xtype: 'pveGroupSelector', - name: 'groups', - multiSelect: true, - allowBlank: true, - fieldLabel: gettext('Group'), - }, - { - xtype: 'pmxExpireDate', - name: 'expire', - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enabled'), - name: 'enable', - uncheckedValue: 0, - defaultValue: 1, - checked: true, - }, - ]; - - let column2 = [ - { - xtype: 'textfield', - name: 'firstname', - fieldLabel: gettext('First Name'), - }, - { - xtype: 'textfield', - name: 'lastname', - fieldLabel: gettext('Last Name'), - }, - { - xtype: 'textfield', - name: 'email', - fieldLabel: gettext('E-Mail'), - vtype: 'proxmoxMail', - }, - ]; - - if (me.isCreate) { - column1.splice(1, 0, { - xtype: 'pmxRealmComboBox', - name: 'realm', - fieldLabel: gettext('Realm'), - allowBlank: false, - matchFieldWidth: false, - listConfig: { width: 300 }, - listeners: { - change: function (combo, realm) { - me.realm = realm; - pwfield.setVisible(realm === 'pve'); - pwfield.setDisabled(realm !== 'pve'); - verifypw.setVisible(realm === 'pve'); - verifypw.setDisabled(realm !== 'pve'); - }, - }, - submitValue: false, - }); - } - - var ipanel = Ext.create('Proxmox.panel.InputPanel', { - column1: column1, - column2: column2, - columnB: [ - { - xtype: 'textfield', - name: 'comment', - fieldLabel: gettext('Comment'), - }, - ], - advancedItems: [ - { - xtype: 'textfield', - name: 'keys', - fieldLabel: gettext('Key IDs'), - }, - ], - onGetValues: function (values) { - if (me.realm) { - values.userid = values.userid + '@' + me.realm; - } - if (!values.password) { - delete values.password; - } - return values; - }, - }); - - Ext.applyIf(me, { - subject: gettext('User'), - url: url, - method: method, - fieldDefaults: { - labelWidth: 110, // some translation are quite long (e.g., Spanish) - }, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var data = response.result.data; - me.setValues(data); - if (data.keys) { - if ( - data.keys === 'x' || - data.keys === 'x!oath' || - data.keys === 'x!u2f' || - data.keys === 'x!yubico' - ) { - me.down('[name="keys"]').setDisabled(1); - } - } - }, - }); - } - }, -}); -Ext.define('PVE.dc.UserView', { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveUserView'], - - onlineHelp: 'pveum_users', - - stateful: true, - stateId: 'grid-users', - - initComponent: function () { - var me = this; - - var caps = Ext.state.Manager.get('GuiCap'); - - var store = new Ext.data.Store({ - id: 'users', - model: 'pmx-users', - sorters: { - property: 'userid', - direction: 'ASC', - }, - }); - let reload = () => store.load(); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/access/users/', - dangerous: true, - enableFn: (rec) => caps.access['User.Modify'] && rec.data.userid !== 'root@pam', - callback: () => reload(), - }); - let run_editor = function () { - var rec = sm.getSelection()[0]; - if (!rec || !caps.access['User.Modify']) { - return; - } - Ext.create('PVE.dc.UserEdit', { - userid: rec.data.userid, - autoShow: true, - listeners: { - destroy: () => reload(), - }, - }); - }; - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - enableFn: function (rec) { - return !!caps.access['User.Modify']; - }, - selModel: sm, - handler: run_editor, - }); - let pwchange_btn = new Proxmox.button.Button({ - text: gettext('Password'), - disabled: true, - selModel: sm, - enableFn: function (record) { - let type = record.data['realm-type']; - if (type) { - if (PVE.Utils.authSchema[type]) { - return !!PVE.Utils.authSchema[type].pwchange; - } - } - return false; - }, - handler: function (btn, event, rec) { - let hintHtml; - if (rec.data['realm-type'] === 'pam') { - hintHtml = gettext( - 'For the PAM realm, this applies only to the connected node.', - ); - } - - Ext.create('Proxmox.window.PasswordEdit', { - userid: rec.data.userid, - confirmCurrentPassword: Proxmox.UserName !== 'root@pam', - autoShow: true, - hintHtml, - minLength: 8, - listeners: { - destroy: () => reload(), - }, - }); - }, - }); - - var perm_btn = new Proxmox.button.Button({ - text: gettext('Permissions'), - disabled: true, - selModel: sm, - handler: function (btn, event, rec) { - Ext.create('PVE.dc.PermissionView', { - userid: rec.data.userid, - autoShow: true, - listeners: { - destroy: () => reload(), - }, - }); - }, - }); - - let unlock_btn = new Proxmox.button.Button({ - text: gettext('Unlock TFA'), - disabled: true, - selModel: sm, - enableFn: (rec) => - !!( - caps.access['User.Modify'] && - (rec.data['totp-locked'] || rec.data['tfa-locked-until']) - ), - handler: function (btn, event, rec) { - Ext.Msg.confirm( - Ext.String.format( - gettext('Unlock TFA authentication for {0}'), - rec.data.userid, - ), - gettext( - "Locked 2nd factors can happen if the user's password was leaked. Are you sure you want to unlock the user?", - ), - function (btn_response) { - if (btn_response === 'yes') { - Proxmox.Utils.API2Request({ - url: `/access/users/${rec.data.userid}/unlock-tfa`, - waitMsgTarget: me, - method: 'PUT', - failure: function (response, options) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, options) { - reload(); - }, - }); - } - }, - ); - }, - }); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: [ - { - text: gettext('Add'), - disabled: !caps.access['User.Modify'], - handler: function () { - Ext.create('PVE.dc.UserEdit', { - autoShow: true, - listeners: { - destroy: () => reload(), - }, - }); - }, - }, - '-', - edit_btn, - remove_btn, - '-', - pwchange_btn, - '-', - perm_btn, - '-', - unlock_btn, - ], - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: gettext('User name'), - width: 200, - sortable: true, - renderer: Proxmox.Utils.render_username, - dataIndex: 'userid', - }, - { - header: gettext('Realm'), - width: 100, - sortable: true, - renderer: Proxmox.Utils.render_realm, - dataIndex: 'userid', - }, - { - header: gettext('Enabled'), - width: 80, - sortable: true, - renderer: Proxmox.Utils.format_boolean, - dataIndex: 'enable', - }, - { - header: gettext('Expire'), - width: 80, - sortable: true, - renderer: Proxmox.Utils.format_expire, - dataIndex: 'expire', - }, - { - header: gettext('Name'), - width: 150, - sortable: true, - renderer: PVE.Utils.render_full_name, - dataIndex: 'firstname', - }, - { - header: 'TFA', - width: 120, - sortable: true, - renderer: function (v, metaData, record) { - let tfa_type = PVE.Parser.parseTfaType(v); - if (tfa_type === undefined) { - return Proxmox.Utils.noText; - } - - if (tfa_type !== 1) { - return tfa_type; - } - - let locked_until = record.data['tfa-locked-until']; - if (locked_until !== undefined) { - let now = new Date().getTime() / 1000; - if (locked_until > now) { - return gettext('Locked'); - } - } - - if (record.data['totp-locked']) { - return gettext('TOTP Locked'); - } - - return Proxmox.Utils.yesText; - }, - dataIndex: 'keys', - }, - { - header: gettext('Groups'), - dataIndex: 'groups', - renderer: Ext.htmlEncode, - flex: 2, - }, - { - header: gettext('Comment'), - sortable: false, - renderer: Ext.String.htmlEncode, - dataIndex: 'comment', - flex: 3, - }, - ], - listeners: { - activate: reload, - itemdblclick: run_editor, - }, - }); - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, store); - }, -}); -Ext.define('PVE.dc.MetricServerView', { - extend: 'Ext.grid.Panel', - alias: ['widget.pveMetricServerView'], - - stateful: true, - stateId: 'grid-metricserver', - - controller: { - xclass: 'Ext.app.ViewController', - - render_type: function (value) { - switch (value) { - case 'influxdb': - return 'InfluxDB'; - case 'graphite': - return 'Graphite'; - case 'opentelemetry': - return 'OpenTelemetry'; - default: - return Proxmox.Utils.unknownText; - } - }, - - editWindow: function (xtype, id) { - let me = this; - Ext.create(`PVE.dc.${xtype}Edit`, { - serverid: id, - autoShow: true, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - - addServer: function (button) { - this.editWindow(button.text); - }, - - editServer: function () { - let me = this; - let view = me.getView(); - let selection = view.getSelection(); - if (!selection || selection.length < 1) { - return; - } - - let cfg = selection[0].data; - - let xtype = me.render_type(cfg.type); - me.editWindow(xtype, cfg.id); - }, - - reload: function () { - this.getView().getStore().load(); - }, - }, - - store: { - autoLoad: true, - id: 'metricservers', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/metrics/server', - }, - }, - - columns: [ - { - text: gettext('Name'), - flex: 2, - dataIndex: 'id', - }, - { - text: gettext('Type'), - flex: 1, - dataIndex: 'type', - renderer: 'render_type', - }, - { - text: gettext('Enabled'), - dataIndex: 'disable', - width: 100, - renderer: Proxmox.Utils.format_neg_boolean, - }, - { - text: gettext('Server'), - width: 200, - dataIndex: 'server', - }, - { - text: gettext('Port'), - width: 100, - dataIndex: 'port', - }, - ], - - tbar: [ - { - text: gettext('Add'), - menu: [ - { - text: 'Graphite', - iconCls: 'fa fa-fw fa-bar-chart', - handler: 'addServer', - }, - { - text: 'InfluxDB', - iconCls: 'fa fa-fw fa-bar-chart', - handler: 'addServer', - }, - { - text: 'OpenTelemetry', - iconCls: 'fa fa-fw fa-bar-chart', - handler: 'addServer', - }, - ], - }, - { - text: gettext('Edit'), - xtype: 'proxmoxButton', - handler: 'editServer', - disabled: true, - }, - { - xtype: 'proxmoxStdRemoveButton', - baseurl: `/api2/extjs/cluster/metrics/server`, - callback: 'reload', - }, - ], - - listeners: { - itemdblclick: 'editServer', - }, - - initComponent: function () { - var me = this; - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.getStore()); - }, -}); - -Ext.define('PVE.dc.MetricServerBaseEdit', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: function () { - let me = this; - me.isCreate = !me.serverid; - me.serverid = me.serverid || ''; - me.url = `/api2/extjs/cluster/metrics/server/${me.serverid}`; - me.method = me.isCreate ? 'POST' : 'PUT'; - if (!me.isCreate) { - me.subject = `${me.subject}: ${me.serverid}`; - } - return {}; - }, - - submitUrl: function (url, values) { - return this.isCreate ? `${url}/${values.id}` : url; - }, - - initComponent: function () { - let me = this; - - me.callParent(); - - if (me.serverid) { - me.load({ - success: function (response, options) { - let values = response.result.data; - values.enable = !values.disable; - - // Handle OpenTelemetry advanced fields conversion - if (values.type === 'opentelemetry') { - if (values['otel-headers']) { - values.headers_advanced = Ext.util.Base64.decode( - values['otel-headers'], - ); - } - if (values['otel-resource-attributes']) { - values.resource_attributes_advanced = Ext.util.Base64.decode( - values['otel-resource-attributes'], - ); - } - } - - me.down('inputpanel').setValues(values); - }, - }); - } - }, -}); - -Ext.define('PVE.dc.InfluxDBEdit', { - extend: 'PVE.dc.MetricServerBaseEdit', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'metric_server_influxdb', - - subject: 'InfluxDB', - - cbindData: function () { - let me = this; - me.callParent(); - me.tokenEmptyText = me.isCreate ? '' : gettext('unchanged'); - return {}; - }, - - items: [ - { - xtype: 'inputpanel', - cbind: { - isCreate: '{isCreate}', - }, - onGetValues: function (values) { - let me = this; - values.disable = values.enable ? 0 : 1; - delete values.enable; - PVE.Utils.delete_if_default(values, 'verify-certificate', '1', me.isCreate); - return values; - }, - - column1: [ - { - xtype: 'hidden', - name: 'type', - value: 'influxdb', - cbind: { - submitValue: '{isCreate}', - }, - }, - { - xtype: 'pmxDisplayEditField', - name: 'id', - fieldLabel: gettext('Name'), - allowBlank: false, - cbind: { - editable: '{isCreate}', - value: '{serverid}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'server', - fieldLabel: gettext('Server'), - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'port', - fieldLabel: gettext('Port'), - value: 8089, - minValue: 1, - maximum: 65536, - allowBlank: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'influxdbproto', - fieldLabel: gettext('Protocol'), - value: '__default__', - cbind: { - deleteEmpty: '{!isCreate}', - }, - comboItems: [ - ['__default__', 'UDP'], - ['http', 'HTTP'], - ['https', 'HTTPS'], - ], - listeners: { - change: function (field, value) { - let me = this; - let view = me.up('inputpanel'); - let isUdp = value !== 'http' && value !== 'https'; - view.down('field[name=organization]').setDisabled(isUdp); - view.down('field[name=bucket]').setDisabled(isUdp); - view.down('field[name=token]').setDisabled(isUdp); - view.down('field[name=api-path-prefix]').setDisabled(isUdp); - view.down('field[name=mtu]').setDisabled(!isUdp); - view.down('field[name=timeout]').setDisabled(isUdp); - view.down('field[name=max-body-size]').setDisabled(isUdp); - view.down('field[name=verify-certificate]').setDisabled( - value !== 'https', - ); - }, - }, - }, - ], - - column2: [ - { - xtype: 'checkbox', - name: 'enable', - fieldLabel: gettext('Enabled'), - inputValue: 1, - uncheckedValue: 0, - checked: true, - }, - { - xtype: 'proxmoxtextfield', - name: 'organization', - fieldLabel: gettext('Organization'), - emptyText: 'proxmox', - disabled: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'bucket', - fieldLabel: gettext('Bucket'), - emptyText: 'proxmox', - disabled: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'token', - fieldLabel: gettext('Token'), - disabled: true, - allowBlank: true, - deleteEmpty: false, - submitEmpty: false, - cbind: { - disabled: '{!isCreate}', - emptyText: '{tokenEmptyText}', - }, - }, - ], - - advancedColumn1: [ - { - xtype: 'proxmoxtextfield', - name: 'api-path-prefix', - fieldLabel: gettext('API Path Prefix'), - allowBlank: true, - disabled: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'timeout', - fieldLabel: gettext('Timeout (s)'), - disabled: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - minValue: 1, - emptyText: 1, - }, - { - xtype: 'proxmoxcheckbox', - name: 'verify-certificate', - fieldLabel: gettext('Verify Certificate'), - value: 1, - uncheckedValue: 0, - disabled: true, - }, - ], - - advancedColumn2: [ - { - xtype: 'proxmoxintegerfield', - name: 'max-body-size', - fieldLabel: gettext('Batch Size (bytes)'), - minValue: 1, - emptyText: '25000000', - submitEmpty: false, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - fieldLabel: 'MTU', - minValue: 1, - emptyText: '1500', - submitEmpty: false, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - }, - ], -}); - -Ext.define('PVE.dc.GraphiteEdit', { - extend: 'PVE.dc.MetricServerBaseEdit', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'metric_server_graphite', - - subject: 'Graphite', - - items: [ - { - xtype: 'inputpanel', - - onGetValues: function (values) { - values.disable = values.enable ? 0 : 1; - delete values.enable; - return values; - }, - - column1: [ - { - xtype: 'hidden', - name: 'type', - value: 'graphite', - cbind: { - submitValue: '{isCreate}', - }, - }, - { - xtype: 'pmxDisplayEditField', - name: 'id', - fieldLabel: gettext('Name'), - allowBlank: false, - cbind: { - editable: '{isCreate}', - value: '{serverid}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'server', - fieldLabel: gettext('Server'), - allowBlank: false, - }, - ], - - column2: [ - { - xtype: 'checkbox', - name: 'enable', - fieldLabel: gettext('Enabled'), - inputValue: 1, - uncheckedValue: 0, - checked: true, - }, - { - xtype: 'proxmoxintegerfield', - name: 'port', - fieldLabel: gettext('Port'), - value: 2003, - minimum: 1, - maximum: 65536, - allowBlank: false, - }, - { - fieldLabel: gettext('Path'), - xtype: 'proxmoxtextfield', - emptyText: 'proxmox', - name: 'path', - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - - advancedColumn1: [ - { - xtype: 'proxmoxKVComboBox', - name: 'proto', - fieldLabel: gettext('Protocol'), - value: '__default__', - cbind: { - deleteEmpty: '{!isCreate}', - }, - comboItems: [ - ['__default__', 'UDP'], - ['tcp', 'TCP'], - ], - listeners: { - change: function (field, value) { - let me = this; - me.up('inputpanel') - .down('field[name=timeout]') - .setDisabled(value !== 'tcp'); - me.up('inputpanel') - .down('field[name=mtu]') - .setDisabled(value === 'tcp'); - }, - }, - }, - ], - - advancedColumn2: [ - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - fieldLabel: 'MTU', - minimum: 1, - emptyText: '1500', - submitEmpty: false, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'timeout', - fieldLabel: gettext('TCP Timeout'), - disabled: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - minValue: 1, - emptyText: 1, - }, - ], - }, - ], -}); - -Ext.define('PVE.dc.OpenTelemetryEdit', { - extend: 'PVE.dc.MetricServerBaseEdit', - xtype: 'pveOpenTelemetryEdit', - - subject: gettext('OpenTelemetry Server'), - - items: [ - { - xtype: 'inputpanel', - cbind: { - isCreate: '{isCreate}', - }, - onGetValues: function (values) { - values.disable = values.enable ? 0 : 1; - delete values.enable; - - // Rename advanced fields to their final names and encode as base64 (same as webhook) - if (values.headers_advanced && values.headers_advanced.trim()) { - values['otel-headers'] = Ext.util.Base64.encode(values.headers_advanced); - } else { - values['otel-headers'] = ''; - } - delete values.headers_advanced; - - if ( - values.resource_attributes_advanced && - values.resource_attributes_advanced.trim() - ) { - values['otel-resource-attributes'] = Ext.util.Base64.encode( - values.resource_attributes_advanced, - ); - } else { - values['otel-resource-attributes'] = ''; - } - delete values.resource_attributes_advanced; - - return values; - }, - - column1: [ - { - xtype: 'hidden', - name: 'type', - value: 'opentelemetry', - cbind: { - submitValue: '{isCreate}', - }, - }, - { - xtype: 'pmxDisplayEditField', - name: 'id', - fieldLabel: gettext('Name'), - allowBlank: false, - cbind: { - editable: '{isCreate}', - value: '{serverid}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'server', - fieldLabel: gettext('Server'), - allowBlank: false, - // TRANSLATORS: otel-collector is an OpenTelemetry endpoint. - emptyText: gettext('otel-collector.example.com'), - }, - { - xtype: 'proxmoxintegerfield', - name: 'port', - fieldLabel: gettext('Port'), - value: 4318, - minValue: 1, - maxValue: 65535, - allowBlank: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'otel-protocol', - fieldLabel: gettext('Protocol'), - value: 'https', - comboItems: [ - ['http', 'HTTP'], - ['https', 'HTTPS'], - ], - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'otel-path', - fieldLabel: gettext('Path'), - value: '/v1/metrics', - allowBlank: false, - }, - ], - - column2: [ - { - xtype: 'checkbox', - name: 'enable', - fieldLabel: gettext('Enabled'), - inputValue: 1, - uncheckedValue: 0, - checked: true, - }, - { - xtype: 'proxmoxintegerfield', - name: 'otel-timeout', - fieldLabel: gettext('Timeout (s)'), - value: 5, - minValue: 1, - maxValue: 300, - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'otel-verify-ssl', - fieldLabel: gettext('Verify SSL'), - inputValue: 1, - uncheckedValue: 0, - defaultValue: 1, - cbind: { - value: function (get) { - return get('isCreate') ? 1 : undefined; - }, - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'otel-max-body-size', - fieldLabel: gettext('Max Body Size (bytes)'), - value: 10000000, - minValue: 1024, - allowBlank: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'otel-compression', - fieldLabel: gettext('Compression'), - value: 'gzip', - comboItems: [ - ['none', gettext('None')], - ['gzip', 'Gzip'], - ], - allowBlank: false, - }, - ], - - columnB: [ - { - xtype: 'fieldset', - title: gettext('Advanced JSON Configuration'), - collapsible: true, - collapsed: true, - items: [ - { - xtype: 'textarea', - name: 'headers_advanced', - fieldLabel: gettext('HTTP Headers (JSON)'), - labelAlign: 'top', - // TRANSLATORS: These are sample json payloads, only translate the values - emptyText: gettext( - '{\n "Authorization": "Bearer token",\n "X-Custom-Header": "value"\n}', - ), - rows: 4, - validator: function (value) { - if (!value || value.trim() === '') { - return true; - } - try { - JSON.parse(value); - return true; - } catch (_e) { - return gettext('Invalid JSON format'); - } - }, - }, - { - xtype: 'textarea', - name: 'resource_attributes_advanced', - fieldLabel: gettext('Resource Attributes (JSON)'), - labelAlign: 'top', - // TRANSLATORS: These are sample json payloads, only translate the values - emptyText: gettext( - '{\n "environment": "production",\n "datacenter": "dc1",\n "region": "us-east-1"\n}', - ), - rows: 4, - validator: function (value) { - if (!value || value.trim() === '') { - return true; - } - try { - JSON.parse(value); - return true; - } catch (_e) { - return gettext('Invalid JSON format'); - } - }, - }, - ], - }, - ], - }, - ], - - initComponent: function () { - var me = this; - var initialLoad = true; - - me.callParent(); - - // Auto-adjust port when protocol changes (only for user interaction) - me.on('afterrender', function () { - var protocolField = me.down('[name=otel-protocol]'); - var portField = me.down('[name=port]'); - - if (protocolField && portField) { - // Set flag to false after initial load - me.on('loadrecord', function () { - setTimeout(function () { - initialLoad = false; - }, 100); - }); - - protocolField.on('change', function (field, newValue) { - // Only auto-adjust port if this is user interaction, not initial load - if (!initialLoad) { - if (newValue === 'https') { - portField.setValue(4318); - } else { - portField.setValue(4317); - } - } - }); - } - }); - }, -}); -Ext.define('PVE.dc.UserTagAccessEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveUserTagAccessEdit', - - subject: gettext('User Tag Access'), - onlineHelp: 'gui_tags', - - url: '/api2/extjs/cluster/options', - - hintText: gettext('NOTE: The following tags are also defined as registered tags.'), - - controller: { - xclass: 'Ext.app.ViewController', - - tagChange: function (field, value) { - let me = this; - let view = me.getView(); - let also_registered = []; - value = Ext.isArray(value) ? value : value.split(';'); - value.forEach((tag) => { - if (view.registered_tags.indexOf(tag) !== -1) { - also_registered.push(tag); - } - }); - let hint_field = me.lookup('hintField'); - hint_field.setVisible(also_registered.length > 0); - if (also_registered.length > 0) { - hint_field.setValue(`${view.hintText} ${also_registered.join(', ')}`); - } - }, - }, - - items: [ - { - xtype: 'inputpanel', - setValues: function (values) { - this.up('pveUserTagAccessEdit').registered_tags = values?.['registered-tags'] ?? []; - let data = values?.['user-tag-access'] ?? {}; - return Proxmox.panel.InputPanel.prototype.setValues.call(this, data); - }, - onGetValues: function (values) { - if (values === undefined || Object.keys(values).length === 0) { - return { delete: 'user-tag-access' }; - } - return { - 'user-tag-access': PVE.Parser.printPropertyString(values), - }; - }, - items: [ - { - name: 'user-allow', - fieldLabel: gettext('Mode'), - xtype: 'proxmoxKVComboBox', - deleteEmpty: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (free)'], - ['free', 'free'], - ['existing', 'existing'], - ['list', 'list'], - ['none', 'none'], - ], - defaultValue: '__default__', - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Predefined Tags'), - }, - { - name: 'user-allow-list', - xtype: 'pveListField', - emptyText: gettext('No Tags defined'), - fieldTitle: gettext('Tag'), - maskRe: PVE.Utils.tagCharRegex, - gridConfig: { - height: 200, - scrollable: true, - }, - listeners: { - change: 'tagChange', - }, - }, - { - hidden: true, - xtype: 'displayfield', - reference: 'hintField', - userCls: 'pmx-hint', - }, - ], - }, - ], -}); -Ext.define('PVE.dc.RegisteredTagsEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveRegisteredTagEdit', - - subject: gettext('Registered Tags'), - onlineHelp: 'gui_tags', - - url: '/api2/extjs/cluster/options', - - hintText: gettext('NOTE: The following tags are also defined in the user allow list.'), - - controller: { - xclass: 'Ext.app.ViewController', - - tagChange: function (field, value) { - let me = this; - let view = me.getView(); - let also_allowed = []; - value = Ext.isArray(value) ? value : value.split(';'); - value.forEach((tag) => { - if (view.allowed_tags.indexOf(tag) !== -1) { - also_allowed.push(tag); - } - }); - let hint_field = me.lookup('hintField'); - hint_field.setVisible(also_allowed.length > 0); - if (also_allowed.length > 0) { - hint_field.setValue(`${view.hintText} ${also_allowed.join(', ')}`); - } - }, - }, - - items: [ - { - xtype: 'inputpanel', - setValues: function (values) { - let allowed_tags = values?.['user-tag-access']?.['user-allow-list'] ?? []; - this.up('pveRegisteredTagEdit').allowed_tags = allowed_tags; - let tags = values?.['registered-tags']; - return Proxmox.panel.InputPanel.prototype.setValues.call(this, { tags }); - }, - onGetValues: function (values) { - if (!values.tags) { - return { - delete: 'registered-tags', - }; - } else { - return { - 'registered-tags': values.tags, - }; - } - }, - items: [ - { - name: 'tags', - xtype: 'pveListField', - maskRe: PVE.Utils.tagCharRegex, - gridConfig: { - height: 200, - scrollable: true, - emptyText: gettext('No Tags defined'), - }, - listeners: { - change: 'tagChange', - }, - }, - { - hidden: true, - xtype: 'displayfield', - reference: 'hintField', - userCls: 'pmx-hint', - }, - ], - }, - ], -}); -Ext.define('PVE.dc.RealmSyncJobView', { - extend: 'Ext.grid.Panel', - alias: 'widget.pveRealmSyncJobView', - - stateful: true, - stateId: 'grid-realmsyncjobs', - - emptyText: Ext.String.format(gettext('No {0} configured'), gettext('Realm Sync Job')), - - controller: { - xclass: 'Ext.app.ViewController', - - addRealmSyncJob: function (button) { - let me = this; - Ext.create(`PVE.dc.RealmSyncJobEdit`, { - autoShow: true, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - - editRealmSyncJob: function () { - let me = this; - let view = me.getView(); - let selection = view.getSelection(); - if (!selection || selection.length < 1) { - return; - } - - Ext.create(`PVE.dc.RealmSyncJobEdit`, { - jobid: selection[0].data.id, - autoShow: true, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - - runNow: function () { - let me = this; - let view = me.getView(); - let selection = view.getSelection(); - if (!selection || selection.length < 1) { - return; - } - - let params = selection[0].data; - let realm = params.realm; - - let propertiesToDelete = [ - 'comment', - 'realm', - 'id', - 'type', - 'schedule', - 'last-run', - 'next-run', - 'enabled', - ]; - for (const prop of propertiesToDelete) { - delete params[prop]; - } - - Proxmox.Utils.API2Request({ - url: `/access/domains/${realm}/sync`, - params, - waitMsgTarget: view, - method: 'POST', - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function (response, options) { - Ext.create('Proxmox.window.TaskProgress', { - autoShow: true, - upid: response.result.data, - taskDone: () => { - me.reload(); - }, - }); - }, - }); - }, - - reload: function () { - this.getView().getStore().load(); - }, - }, - - store: { - autoLoad: true, - id: 'realm-syncs', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/jobs/realm-sync', - }, - }, - - viewConfig: { - getRowClass: (record, _index) => (record.get('enabled') ? '' : 'proxmox-disabled-row'), - }, - - columns: [ - { - header: gettext('Enabled'), - width: 80, - dataIndex: 'enabled', - sortable: true, - align: 'center', - stopSelection: false, - renderer: Proxmox.Utils.renderEnabledIcon, - }, - { - text: gettext('Name'), - flex: 1, - dataIndex: 'id', - hidden: true, - }, - { - text: gettext('Realm'), - width: 200, - dataIndex: 'realm', - }, - { - header: gettext('Schedule'), - width: 150, - dataIndex: 'schedule', - }, - { - text: gettext('Next Run'), - dataIndex: 'next-run', - width: 150, - renderer: PVE.Utils.render_next_event, - }, - { - header: gettext('Comment'), - dataIndex: 'comment', - renderer: Ext.htmlEncode, - sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''), - flex: 1, - }, - ], - - tbar: [ - { - text: gettext('Add'), - handler: 'addRealmSyncJob', - }, - { - text: gettext('Edit'), - xtype: 'proxmoxButton', - handler: 'editRealmSyncJob', - disabled: true, - }, - { - xtype: 'proxmoxStdRemoveButton', - baseurl: `/api2/extjs/cluster/jobs/realm-sync`, - callback: 'reload', - }, - { - xtype: 'proxmoxButton', - handler: 'runNow', - disabled: true, - text: gettext('Run Now'), - }, - ], - - listeners: { - itemdblclick: 'editRealmSyncJob', - }, - - initComponent: function () { - var me = this; - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.getStore()); - }, -}); - -Ext.define('PVE.dc.RealmSyncJobEdit', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - subject: gettext('Realm Sync Job'), - onlineHelp: 'pveum_ldap_sync', - - // don't focus the schedule field on edit - defaultFocus: 'field[name=id]', - - cbindData: function () { - let me = this; - me.isCreate = !me.jobid; - me.jobid = me.jobid || ''; - let url = '/api2/extjs/cluster/jobs/realm-sync'; - me.url = me.jobid ? `${url}/${me.jobid}` : url; - me.method = me.isCreate ? 'POST' : 'PUT'; - if (!me.isCreate) { - me.subject = `${me.subject}: ${me.jobid}`; - } - return {}; - }, - - submitUrl: function (url, values) { - return this.isCreate ? `${url}/${values.id}` : url; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - updateDefaults: function (_field, newValue) { - let me = this; - - ['scope', 'enable-new', 'schedule'].forEach((reference) => { - me.lookup(reference)?.setDisabled(false); - }); - - // only update on create - if (!me.getView().isCreate) { - return; - } - Proxmox.Utils.API2Request({ - url: `/access/domains/${newValue}`, - success: function (response) { - // first reset the fields to their default - ['acl', 'entry', 'properties'].forEach((opt) => { - me.lookup(`remove-vanished-${opt}`)?.setValue(false); - }); - me.lookup('enable-new')?.setValue('1'); - me.lookup('scope')?.setValue(undefined); - - let options = response?.result?.data?.['sync-defaults-options']; - if (options) { - let parsed = PVE.Parser.parsePropertyString(options); - if (parsed['remove-vanished']) { - let opts = parsed['remove-vanished'].split(';'); - for (const opt of opts) { - me.lookup(`remove-vanished-${opt}`)?.setValue(true); - } - delete parsed['remove-vanished']; - } - for (const [name, value] of Object.entries(parsed)) { - me.lookup(name)?.setValue(value); - } - } - }, - }); - }, - }, - - items: [ - { - xtype: 'inputpanel', - - cbind: { - isCreate: '{isCreate}', - }, - - onGetValues: function (values) { - let me = this; - - let vanished_opts = []; - ['acl', 'entry', 'properties'].forEach((prop) => { - if (values[`remove-vanished-${prop}`]) { - vanished_opts.push(prop); - } - delete values[`remove-vanished-${prop}`]; - }); - - if (!values.id && me.isCreate) { - values.id = - 'realmsync-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); - } - - if (vanished_opts.length > 0) { - values['remove-vanished'] = vanished_opts.join(';'); - } else { - values['remove-vanished'] = 'none'; - } - - PVE.Utils.delete_if_default(values, 'node', ''); - - if (me.isCreate) { - delete values.delete; // on create we cannot delete values - } - - return values; - }, - - column1: [ - { - xtype: 'pmxDisplayEditField', - editConfig: { - xtype: 'pmxRealmComboBox', - storeFilter: (rec) => rec.data.type === 'ldap' || rec.data.type === 'ad', - }, - listConfig: { - emptyText: `
    ${gettext('No LDAP/AD Realm found')}
    `, - }, - cbind: { - editable: '{isCreate}', - }, - listeners: { - change: 'updateDefaults', - }, - fieldLabel: gettext('Realm'), - name: 'realm', - reference: 'realm', - }, - { - xtype: 'pveCalendarEvent', - fieldLabel: gettext('Schedule'), - disabled: true, - allowBlank: false, - name: 'schedule', - reference: 'schedule', - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enable Job'), - name: 'enabled', - reference: 'enabled', - uncheckedValue: 0, - defaultValue: 1, - checked: true, - }, - ], - - column2: [ - { - xtype: 'proxmoxKVComboBox', - name: 'scope', - reference: 'scope', - disabled: true, - fieldLabel: gettext('Scope'), - value: '', - emptyText: gettext('No default available'), - deleteEmpty: false, - allowBlank: false, - comboItems: [ - ['users', gettext('Users')], - ['groups', gettext('Groups')], - ['both', gettext('Users and Groups')], - ], - }, - { - xtype: 'proxmoxKVComboBox', - value: '1', - deleteEmpty: false, - disabled: true, - allowBlank: false, - comboItems: [ - ['1', Proxmox.Utils.yesText], - ['0', Proxmox.Utils.noText], - ], - name: 'enable-new', - reference: 'enable-new', - fieldLabel: gettext('Enable New'), - }, - ], - - columnB: [ - { - xtype: 'fieldset', - title: gettext('Remove Vanished Options'), - items: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('ACL'), - name: 'remove-vanished-acl', - reference: 'remove-vanished-acl', - boxLabel: gettext('Remove ACLs of vanished users and groups.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Entry'), - name: 'remove-vanished-entry', - reference: 'remove-vanished-entry', - boxLabel: gettext('Remove vanished user and group entries.'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Properties'), - name: 'remove-vanished-properties', - reference: 'remove-vanished-properties', - boxLabel: gettext('Remove vanished properties from synced users.'), - }, - ], - }, - { - xtype: 'proxmoxtextfield', - name: 'comment', - fieldLabel: gettext('Job Comment'), - cbind: { - deleteEmpty: '{!isCreate}', - }, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Description of the job'), - }, - }, - { - xtype: 'displayfield', - reference: 'defaulthint', - value: gettext('Default sync options can be set by editing the realm.'), - userCls: 'pmx-hint', - hidden: true, - }, - ], - }, - ], - - initComponent: function () { - let me = this; - me.callParent(); - if (me.jobid) { - me.load({ - success: function (response, options) { - let values = response.result.data; - - if (values['remove-vanished']) { - let opts = values['remove-vanished'].split(';'); - for (const opt of opts) { - values[`remove-vanished-${opt}`] = 1; - } - } - me.down('inputpanel').setValues(values); - }, - }); - } - }, -}); -Ext.define('pve-resource-pci-tree', { - extend: 'Ext.data.Model', - idProperty: 'internalId', - fields: ['type', 'text', 'path', 'id', 'subsystem-id', 'iommugroup', 'description', 'digest'], -}); - -Ext.define('PVE.dc.PCIMapView', { - extend: 'PVE.tree.ResourceMapTree', - alias: 'widget.pveDcPCIMapView', - - editWindowClass: 'PVE.window.PCIMapEditWindow', - baseUrl: '/cluster/mapping/pci', - mapIconCls: 'pve-itype-icon-pci', - getStatusCheckUrl: (node) => `/nodes/${node}/hardware/pci?pci-class-blacklist=`, - entryIdProperty: 'path', - - checkValidity: function (data, node) { - let me = this; - let ids = {}; - data.forEach((entry) => { - ids[entry.id] = entry; - }); - // extract the mdev property from the global entry and insert to the individiual entries, - // so we can reuse the normal checking logic - let mdev; - me.getRootNode()?.cascade(function (rec) { - if (rec.data.type === 'entry') { - mdev = rec.data.mdev ?? 0; - } - if (rec.data.node !== node || rec.data.type !== 'map') { - return; - } - rec.data.mdev = mdev; - - let id = rec.data.path; - if (!id.match(/\.\d$/)) { - id += '.0'; - } - let device = ids[id]; - if (!device) { - rec.set('valid', 0); - rec.set('errmsg', Ext.String.format(gettext('Cannot find PCI id {0}'), id)); - rec.commit(); - return; - } - - let deviceId = `${device.vendor}:${device.device}`.replace(/0x/g, ''); - let subId = `${device.subsystem_vendor}:${device.subsystem_device}`.replace(/0x/g, ''); - - let toCheck = { - id: deviceId, - 'subsystem-id': subId, - mdev: device.mdev ?? 0, - iommugroup: device.iommugroup !== -1 ? device.iommugroup : undefined, - }; - - let valid = 1; - let errors = []; - let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')"); - for (const [key, validValue] of Object.entries(toCheck)) { - if (`${rec.data[key]}` !== `${validValue}`) { - errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue)); - valid = 0; - } - } - - rec.set('valid', valid); - rec.set('errmsg', errors.join('
    ')); - rec.commit(); - }); - }, - - store: { - sorters: 'text', - model: 'pve-resource-pci-tree', - data: {}, - }, - - columns: [ - { - xtype: 'treecolumn', - text: gettext('ID/Node/Path'), - dataIndex: 'text', - width: 200, - }, - { - text: gettext('Vendor/Device'), - dataIndex: 'id', - }, - { - text: gettext('Subsystem Vendor/Device'), - dataIndex: 'subsystem-id', - }, - { - text: gettext('IOMMU-Group'), - dataIndex: 'iommugroup', - }, - { - header: gettext('Status'), - dataIndex: 'valid', - flex: 1, - renderer: 'renderStatus', - }, - { - header: gettext('Comment'), - dataIndex: 'description', - renderer: function (value, _meta, record) { - return Ext.String.htmlEncode(value ?? record.data.comment); - }, - flex: 1, - }, - ], -}); -Ext.define('pve-resource-usb-tree', { - extend: 'Ext.data.Model', - idProperty: 'internalId', - fields: ['type', 'text', 'path', 'id', 'description', 'digest'], -}); - -Ext.define('PVE.dc.USBMapView', { - extend: 'PVE.tree.ResourceMapTree', - alias: 'widget.pveDcUSBMapView', - - editWindowClass: 'PVE.window.USBMapEditWindow', - baseUrl: '/cluster/mapping/usb', - mapIconCls: 'fa fa-usb', - getStatusCheckUrl: (node) => `/nodes/${node}/hardware/usb`, - entryIdProperty: 'id', - - checkValidity: function (data, node) { - let me = this; - let ids = {}; - let paths = {}; - data.forEach((entry) => { - ids[`${entry.vendid}:${entry.prodid}`] = entry; - paths[`${entry.busnum}-${entry.usbpath}`] = entry; - }); - me.getRootNode()?.cascade(function (rec) { - if (rec.data.node !== node || rec.data.type !== 'map') { - return; - } - - let device; - if (rec.data.path) { - device = paths[rec.data.path]; - } - device ??= ids[rec.data.id]; - - if (!device) { - rec.set('valid', 0); - rec.set( - 'errmsg', - Ext.String.format(gettext('Cannot find USB device {0}'), rec.data.id), - ); - rec.commit(); - return; - } - - let deviceId = `${device.vendid}:${device.prodid}`.replace(/0x/g, ''); - - let toCheck = { - id: deviceId, - }; - - let valid = 1; - let errors = []; - let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')"); - for (const [key, validValue] of Object.entries(toCheck)) { - if (rec.data[key] !== validValue) { - errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue)); - valid = 0; - } - } - - rec.set('valid', valid); - rec.set('errmsg', errors.join('
    ')); - rec.commit(); - }); - }, - - store: { - sorters: 'text', - model: 'pve-resource-usb-tree', - data: {}, - }, - - columns: [ - { - xtype: 'treecolumn', - text: gettext('ID/Node/Vendor&Device'), - dataIndex: 'text', - width: 200, - }, - { - text: gettext('Path'), - dataIndex: 'path', - }, - { - header: gettext('Status'), - dataIndex: 'valid', - flex: 1, - renderer: 'renderStatus', - }, - { - header: gettext('Comment'), - dataIndex: 'description', - renderer: function (value, _meta, record) { - return Ext.String.htmlEncode(value ?? record.data.comment); - }, - flex: 1, - }, - ], -}); -Ext.define('pve-resource-dir-tree', { - extend: 'Ext.data.Model', - idProperty: 'internalId', - fields: ['type', 'text', 'path', 'id', 'description', 'digest'], -}); - -Ext.define('PVE.dc.DirMapView', { - extend: 'PVE.tree.ResourceMapTree', - alias: 'widget.pveDcDirMapView', - - editWindowClass: 'PVE.window.DirMapEditWindow', - baseUrl: '/cluster/mapping/dir', - mapIconCls: 'fa fa-folder', - entryIdProperty: 'path', - - store: { - sorters: 'text', - model: 'pve-resource-dir-tree', - data: {}, - }, - - columns: [ - { - xtype: 'treecolumn', - text: gettext('ID/Node'), - dataIndex: 'text', - width: 200, - }, - { - header: gettext('Comment'), - dataIndex: 'description', - renderer: function (value, _meta, record) { - return Ext.String.htmlEncode(value ?? record.data.comment); - }, - flex: 1, - }, - ], -}); -Ext.define('PVE.lxc.CmdMenu', { - extend: 'Ext.menu.Menu', - - showSeparator: false, - initComponent: function () { - let me = this; - - let info = me.pveSelNode.data; - if (!info.node) { - throw 'no node name specified'; - } - if (!info.vmid) { - throw 'no CT ID specified'; - } - - let vm_command = function (cmd, params) { - Proxmox.Utils.API2Request({ - params: params, - url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`, - method: 'POST', - failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }; - let confirmedVMCommand = (cmd, params) => { - let msg = PVE.Utils.formatGuestTaskConfirmation(`vz${cmd}`, info.vmid, info.name); - Ext.Msg.confirm(gettext('Confirm'), msg, (btn) => { - if (btn === 'yes') { - vm_command(cmd, params); - } - }); - }; - - let caps = Ext.state.Manager.get('GuiCap'); - let standalone = PVE.Utils.isStandaloneNode(); - - let running = false, - stopped = true, - suspended = false; - switch (info.status) { - case 'running': - running = true; - stopped = false; - break; - case 'paused': - stopped = false; - suspended = true; - break; - default: - break; - } - - me.title = 'CT ' + info.vmid; - - me.items = [ - { - text: gettext('Start'), - iconCls: 'fa fa-fw fa-play', - disabled: running, - handler: () => vm_command('start'), - }, - { - text: gettext('Shutdown'), - iconCls: 'fa fa-fw fa-power-off', - disabled: stopped || suspended, - handler: () => confirmedVMCommand('shutdown'), - }, - { - text: gettext('Stop'), - iconCls: 'fa fa-fw fa-stop', - disabled: stopped, - tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'), - handler: () => { - Ext.create('PVE.GuestStop', { - nodename: info.node, - vm: info, - autoShow: true, - }); - }, - }, - { - text: gettext('Reboot'), - iconCls: 'fa fa-fw fa-refresh', - disabled: stopped, - tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'), - handler: () => confirmedVMCommand('reboot'), - }, - { - xtype: 'menuseparator', - hidden: - (standalone || !caps.vms['VM.Migrate']) && - !caps.vms['VM.Allocate'] && - !caps.vms['VM.Clone'], - }, - { - text: gettext('Clone'), - iconCls: 'fa fa-fw fa-clone', - hidden: !caps.vms['VM.Clone'], - handler: () => - PVE.window.Clone.wrap(info.node, info.vmid, info.name, me.isTemplate, 'lxc'), - }, - { - text: gettext('Migrate'), - iconCls: 'fa fa-fw fa-send-o', - hidden: standalone || !caps.vms['VM.Migrate'], - handler: function () { - Ext.create('PVE.window.Migrate', { - vmtype: 'lxc', - nodename: info.node, - vmid: info.vmid, - vmname: info.name, - autoShow: true, - }); - }, - }, - { - text: gettext('Convert to template'), - iconCls: 'fa fa-fw fa-file-o', - handler: function () { - let msg = PVE.Utils.formatGuestTaskConfirmation( - 'vztemplate', - info.vmid, - info.name, - ); - Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) { - if (btn === 'yes') { - Proxmox.Utils.API2Request({ - url: `/nodes/${info.node}/lxc/${info.vmid}/template`, - method: 'POST', - failure: (response, opts) => - Ext.Msg.alert('Error', response.htmlStatus), - }); - } - }); - }, - }, - { xtype: 'menuseparator' }, - { - text: gettext('Take Snapshot'), - iconCls: 'fa fa-fw fa-history', - itemId: 'takeSnapshotBtn', - disabled: true, - handler: () => { - Ext.create('PVE.window.Snapshot', { - nodename: info.node, - vmid: info.vmid, - vmname: info.name, - viewonly: false, - type: info.type, - isCreate: true, - submitText: gettext('Take Snapshot'), - autoShow: true, - running: running, - }); - }, - }, - { - text: gettext('Backup now'), - iconCls: 'fa fa-fw fa-floppy-o', - disabled: !caps.vms['VM.Backup'], - handler: () => { - Ext.create('PVE.window.Backup', { - nodename: info.node, - vmid: info.vmid, - vmtype: info.type, - vmname: info.name, - autoShow: true, - }); - }, - }, - { xtype: 'menuseparator' }, - { - text: gettext('Console'), - iconCls: 'fa fa-fw fa-terminal', - handler: () => - PVE.Utils.openDefaultConsoleWindow( - true, - 'lxc', - info.vmid, - info.node, - info.vmname, - ), - }, - ]; - - me.callParent(); - - if (caps.vms['VM.Snapshot']) { - Proxmox.Utils.API2Request({ - url: `/nodes/${info.node}/${info.type}/${info.vmid}/feature`, - params: { feature: 'snapshot' }, - method: 'GET', - success: (response) => { - let hasFeature = response.result.data.hasFeature; - let btn = me.down('#takeSnapshotBtn'); - if (btn) { - btn.setDisabled(!hasFeature); - } - }, - }); - } - }, -}); -Ext.define('PVE.lxc.Config', { - extend: 'PVE.panel.Config', - alias: 'widget.pveLXCConfig', - - onlineHelp: 'chapter_pct', - - userCls: 'proxmox-tags-full', - - initComponent: function () { - var me = this; - var vm = me.pveSelNode.data; - - var nodename = vm.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = vm.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var template = !!vm.template; - - var running = !!vm.uptime; - - var caps = Ext.state.Manager.get('GuiCap'); - - var base_url = '/nodes/' + nodename + '/lxc/' + vmid; - - me.statusStore = Ext.create('Proxmox.data.ObjectStore', { - url: '/api2/json' + base_url + '/status/current', - interval: 1000, - }); - - var vm_command = function (cmd, params) { - Proxmox.Utils.API2Request({ - params: params, - url: base_url + '/status/' + cmd, - waitMsgTarget: me, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - }); - }; - - var startBtn = Ext.create('Ext.Button', { - text: gettext('Start'), - disabled: !caps.vms['VM.PowerMgmt'] || running, - hidden: template, - handler: function () { - vm_command('start'); - }, - iconCls: 'fa fa-play', - }); - - var shutdownBtn = Ext.create('PVE.button.Split', { - text: gettext('Shutdown'), - disabled: !caps.vms['VM.PowerMgmt'] || !running, - hidden: template, - confirmMsg: PVE.Utils.formatGuestTaskConfirmation('vzshutdown', vmid, vm.name), - handler: function () { - vm_command('shutdown'); - }, - menu: { - items: [ - { - text: gettext('Reboot'), - disabled: !caps.vms['VM.PowerMgmt'], - confirmMsg: PVE.Utils.formatGuestTaskConfirmation( - 'vzreboot', - vmid, - vm.name, - ), - tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'), - handler: function () { - vm_command('reboot'); - }, - iconCls: 'fa fa-refresh', - }, - { - text: gettext('Stop'), - disabled: !caps.vms['VM.PowerMgmt'], - tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'), - handler: function () { - Ext.create('PVE.GuestStop', { - nodename: nodename, - vm: vm, - autoShow: true, - }); - }, - iconCls: 'fa fa-stop', - }, - ], - }, - iconCls: 'fa fa-power-off', - }); - - var migrateBtn = Ext.create('Ext.Button', { - text: gettext('Migrate'), - disabled: !caps.vms['VM.Migrate'], - hidden: PVE.Utils.isStandaloneNode(), - handler: function () { - var win = Ext.create('PVE.window.Migrate', { - vmtype: 'lxc', - nodename: nodename, - vmid: vmid, - vmname: vm.name, - }); - win.show(); - }, - iconCls: 'fa fa-send-o', - }); - - var moreBtn = Ext.create('Proxmox.button.Button', { - text: gettext('More'), - menu: { - items: [ - { - text: gettext('Clone'), - iconCls: 'fa fa-fw fa-clone', - hidden: !caps.vms['VM.Clone'], - handler: function () { - PVE.window.Clone.wrap(nodename, vmid, vm.name, template, 'lxc'); - }, - }, - { - text: gettext('Convert to template'), - disabled: template, - xtype: 'pveMenuItem', - iconCls: 'fa fa-fw fa-file-o', - hidden: !caps.vms['VM.Allocate'], - confirmMsg: PVE.Utils.formatGuestTaskConfirmation( - 'vztemplate', - vmid, - vm.name, - ), - handler: function () { - Proxmox.Utils.API2Request({ - url: base_url + '/template', - waitMsgTarget: me, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - }); - }, - }, - { - iconCls: 'fa fa-heartbeat ', - hidden: !caps.nodes['Sys.Console'], - text: gettext('Manage HA'), - handler: function () { - var ha = vm.hastate; - Ext.create('PVE.ha.VMResourceEdit', { - vmid: vmid, - guestType: 'ct', - isCreate: !ha || ha === 'unmanaged', - }).show(); - }, - }, - { - text: gettext('Remove'), - disabled: !caps.vms['VM.Allocate'], - itemId: 'removeBtn', - handler: function () { - Ext.create('PVE.window.SafeDestroyGuest', { - url: base_url, - item: { - type: 'CT', - id: vmid, - formattedIdentifier: PVE.Utils.getFormattedGuestIdentifier( - vmid, - vm.name, - ), - }, - taskName: 'vzdestroy', - }).show(); - }, - iconCls: 'fa fa-trash-o', - }, - ], - }, - }); - - var consoleBtn = Ext.create('PVE.button.ConsoleButton', { - disabled: !caps.vms['VM.Console'], - consoleType: 'lxc', - consoleName: vm.name, - hidden: template, - nodename: nodename, - vmid: vmid, - }); - - var statusTxt = Ext.create('Ext.toolbar.TextItem', { - data: { - lock: undefined, - }, - tpl: ['', ' ({lock})', ''], - }); - - let tagsContainer = Ext.create('PVE.panel.TagEditContainer', { - tags: vm.tags, - canEdit: !!caps.vms['VM.Config.Options'], - listeners: { - change: function (tags) { - Proxmox.Utils.API2Request({ - url: base_url + '/config', - method: 'PUT', - params: { - tags, - }, - success: function () { - me.statusStore.load(); - }, - failure: function (response) { - Ext.Msg.alert('Error', response.htmlStatus); - me.statusStore.load(); - }, - }); - }, - }, - }); - - let vm_text = `${vm.vmid} (${vm.name})`; - - Ext.apply(me, { - title: Ext.String.format(gettext("Container {0} on node '{1}'"), vm_text, nodename), - hstateid: 'lxctab', - tbarSpacing: false, - tbar: [ - statusTxt, - tagsContainer, - '->', - startBtn, - shutdownBtn, - migrateBtn, - consoleBtn, - moreBtn, - ], - defaults: { statusStore: me.statusStore }, - items: [ - { - title: gettext('Summary'), - xtype: 'pveGuestSummary', - iconCls: 'fa fa-book', - itemId: 'summary', - }, - ], - }); - - if (caps.vms['VM.Console'] && !template) { - me.items.push({ - title: gettext('Console'), - itemId: 'consolejs', - iconCls: 'fa fa-terminal', - xtype: 'pveNoVncConsole', - vmid: vmid, - consoleType: 'lxc', - xtermjs: true, - nodename: nodename, - }); - } - - me.items.push( - { - title: gettext('Resources'), - itemId: 'resources', - expandedOnInit: true, - iconCls: 'fa fa-cube', - xtype: 'pveLxcRessourceView', - }, - { - title: gettext('Network'), - iconCls: 'fa fa-exchange', - itemId: 'network', - xtype: 'pveLxcNetworkView', - }, - { - title: gettext('DNS'), - iconCls: 'fa fa-globe', - itemId: 'dns', - xtype: 'pveLxcDNS', - }, - { - title: gettext('Options'), - itemId: 'options', - iconCls: 'fa fa-gear', - xtype: 'pveLxcOptions', - }, - { - title: gettext('Task History'), - itemId: 'tasks', - iconCls: 'fa fa-list-alt', - xtype: 'proxmoxNodeTasks', - nodename: nodename, - preFilter: { - vmid, - }, - }, - ); - - if (caps.vms['VM.Backup']) { - me.items.push( - { - title: gettext('Backup'), - iconCls: 'fa fa-floppy-o', - xtype: 'pveBackupView', - itemId: 'backup', - }, - { - title: gettext('Replication'), - iconCls: 'fa fa-retweet', - xtype: 'pveReplicaView', - itemId: 'replication', - }, - ); - } - - if ( - (caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] || caps.vms['VM.Audit']) && - !template - ) { - me.items.push({ - title: gettext('Snapshots'), - iconCls: 'fa fa-history', - xtype: 'pveGuestSnapshotTree', - type: 'lxc', - itemId: 'snapshot', - }); - } - - if (caps.vms['VM.Audit']) { - me.items.push( - { - xtype: 'pveFirewallRules', - title: gettext('Firewall'), - iconCls: 'fa fa-shield', - allow_iface: true, - base_url: base_url + '/firewall/rules', - list_refs_url: base_url + '/firewall/refs', - itemId: 'firewall', - firewall_type: 'vm', - }, - { - xtype: 'pveFirewallOptions', - groups: ['firewall'], - iconCls: 'fa fa-gear', - onlineHelp: 'pve_firewall_vm_container_configuration', - title: gettext('Options'), - base_url: base_url + '/firewall/options', - fwtype: 'vm', - itemId: 'firewall-options', - }, - { - xtype: 'pveFirewallAliases', - title: gettext('Alias'), - groups: ['firewall'], - iconCls: 'fa fa-external-link', - base_url: base_url + '/firewall/aliases', - itemId: 'firewall-aliases', - }, - { - xtype: 'pveIPSet', - title: gettext('IPSet'), - groups: ['firewall'], - iconCls: 'fa fa-list-ol', - base_url: base_url + '/firewall/ipset', - list_refs_url: base_url + '/firewall/refs', - itemId: 'firewall-ipset', - }, - ); - } - - if (caps.vms['VM.Console']) { - me.items.push({ - title: gettext('Log'), - groups: ['firewall'], - iconCls: 'fa fa-list', - onlineHelp: 'chapter_pve_firewall', - itemId: 'firewall-fwlog', - xtype: 'proxmoxLogView', - url: '/api2/extjs' + base_url + '/firewall/log', - log_select_timespan: true, - submitFormat: 'U', - }); - } - - if (caps.vms['Permissions.Modify']) { - me.items.push({ - xtype: 'pveACLView', - title: gettext('Permissions'), - itemId: 'permissions', - iconCls: 'fa fa-unlock', - path: '/vms/' + vmid, - }); - } - - me.callParent(); - - var prevStatus = 'unknown'; - me.mon(me.statusStore, 'load', function (s, records, success) { - var status; - var lock; - var rec; - - if (!success) { - status = 'unknown'; - } else { - rec = s.data.get('status'); - status = rec ? rec.data.value : 'unknown'; - rec = s.data.get('template'); - template = rec ? rec.data.value : false; - rec = s.data.get('lock'); - lock = rec ? rec.data.value : undefined; - } - - statusTxt.update({ lock: lock }); - - rec = s.data.get('tags'); - tagsContainer.loadTags(rec?.data?.value); - - startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status === 'running' || template); - shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running'); - me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped'); - consoleBtn.setDisabled(template); - - if (prevStatus === 'stopped' && status === 'running') { - let con = me.down('#consolejs'); - if (con) { - con.reload(); - } - } - - prevStatus = status; - }); - - me.on('afterrender', function () { - me.statusStore.startUpdate(); - }); - - me.on('destroy', function () { - me.statusStore.stopUpdate(); - }); - }, -}); -Ext.define('PVE.lxc.CreateWizard', { - extend: 'PVE.window.Wizard', - mixins: ['Proxmox.Mixin.CBind'], - - viewModel: { - data: { - nodename: '', - storage: '', - unprivileged: true, - }, - formulas: { - cgroupMode: function (get) { - const nodeInfo = PVE.data.ResourceStore.getNodes().find( - (node) => node.node === get('nodename'), - ); - return nodeInfo ? nodeInfo['cgroup-mode'] : 2; - }, - }, - }, - - cbindData: { - nodename: undefined, - }, - - subject: gettext('LXC Container'), - - items: [ - { - xtype: 'inputpanel', - title: gettext('General'), - onlineHelp: 'pct_general', - column1: [ - { - xtype: 'pveNodeSelector', - name: 'nodename', - cbind: { - selectCurNode: '{!nodename}', - preferredValue: '{nodename}', - }, - bind: { - value: '{nodename}', - }, - fieldLabel: gettext('Node'), - allowBlank: false, - onlineValidator: true, - }, - { - xtype: 'pveGuestIDSelector', - name: 'vmid', // backend only knows vmid - guestType: 'lxc', - value: '', - loadNextFreeID: true, - validateExists: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'hostname', - vtype: 'DnsName', - value: '', - fieldLabel: gettext('Hostname'), - skipEmptyText: true, - allowBlank: true, - }, - { - xtype: 'proxmoxcheckbox', - name: 'unprivileged', - value: true, - uncheckedValue: 0, - bind: { - value: '{unprivileged}', - }, - fieldLabel: gettext('Unprivileged container'), - }, - { - xtype: 'proxmoxcheckbox', - name: 'features', - inputValue: 'nesting=1', - value: true, - clearOnDisable: true, - bind: { - disabled: '{!unprivileged}', - }, - fieldLabel: gettext('Nesting'), - }, - { - xtype: 'proxmoxcheckbox', - name: 'ha-managed', - // only submit value of checkbox if checked - uncheckedValue: undefined, - fieldLabel: gettext('Add to HA'), - }, - ], - column2: [ - { - xtype: 'pvePoolSelector', - fieldLabel: gettext('Resource Pool'), - name: 'pool', - value: '', - allowBlank: true, - }, - { - xtype: 'textfield', - inputType: 'password', - name: 'password', - value: '', - fieldLabel: gettext('Password'), - allowBlank: false, - minLength: 5, - change: function (f, value) { - if (f.rendered) { - f.up().down('field[name=confirmpw]').validate(); - } - }, - }, - { - xtype: 'textfield', - inputType: 'password', - name: 'confirmpw', - value: '', - fieldLabel: gettext('Confirm password'), - allowBlank: true, - submitValue: false, - validator: function (value) { - var pw = this.up().down('field[name=password]').getValue(); - if (pw !== value) { - return 'Passwords do not match!'; - } - return true; - }, - }, - { - xtype: 'textarea', - name: 'ssh-public-keys', - value: '', - fieldLabel: gettext('SSH public key(s)'), - allowBlank: true, - validator: function (value) { - let pwfield = this.up().down('field[name=password]'); - if (value.length) { - let keys = value.indexOf('\n') !== -1 ? value.split('\n') : [value]; - if (keys.some((key) => key !== '' && !PVE.Parser.parseSSHKey(key))) { - return 'Failed to recognize ssh key'; - } - pwfield.allowBlank = true; - } else { - pwfield.allowBlank = false; - } - pwfield.validate(); - return true; - }, - afterRender: function () { - if (!window.FileReader) { - return; // No FileReader support in this browser - } - let cancelEvent = (ev) => { - ev = ev.event; - if (ev.preventDefault) { - ev.preventDefault(); - } - }; - this.inputEl.on('dragover', cancelEvent); - this.inputEl.on('dragenter', cancelEvent); - this.inputEl.on('drop', (ev) => { - cancelEvent(ev); - let files = ev.event.dataTransfer.files; - PVE.Utils.loadSSHKeyFromFile(files[0], (v) => this.setValue(v)); - }); - }, - }, - { - xtype: 'pveMultiFileButton', - name: 'file', - hidden: !window.FileReader, - text: gettext('Load SSH Key File'), - listeners: { - change: function (btn, e, value) { - e = e.event; - let field = this.up().down('textarea[name=ssh-public-keys]'); - for (const file of e?.target?.files ?? []) { - PVE.Utils.loadSSHKeyFromFile(file, (v) => { - let oldValue = field.getValue(); - field.setValue( - oldValue ? `${oldValue}\n${v.trim()}` : v.trim(), - ); - }); - } - btn.reset(); - }, - }, - }, - ], - advancedColumnB: [ - { - xtype: 'pveTagFieldSet', - name: 'tags', - maxHeight: 150, - }, - ], - }, - { - xtype: 'inputpanel', - title: gettext('Template'), - onlineHelp: 'pct_container_images', - column1: [ - { - xtype: 'pveStorageSelector', - name: 'tmplstorage', - fieldLabel: gettext('Storage'), - storageContent: 'vztmpl', - autoSelect: true, - allowBlank: false, - bind: { - value: '{storage}', - nodename: '{nodename}', - }, - }, - { - xtype: 'pveFileSelector', - name: 'ostemplate', - storageContent: 'vztmpl', - fieldLabel: gettext('Template'), - bind: { - storage: '{storage}', - nodename: '{nodename}', - }, - allowBlank: false, - }, - ], - }, - { - xtype: 'pveMultiMPPanel', - title: gettext('Disks'), - insideWizard: true, - isCreate: true, - unused: false, - confid: 'rootfs', - }, - { - xtype: 'pveLxcCPUInputPanel', - title: gettext('CPU'), - insideWizard: true, - }, - { - xtype: 'pveLxcMemoryInputPanel', - title: gettext('Memory'), - insideWizard: true, - }, - { - xtype: 'pveLxcNetworkInputPanel', - title: gettext('Network'), - insideWizard: true, - bind: { - nodename: '{nodename}', - }, - isCreate: true, - }, - { - xtype: 'pveLxcDNSInputPanel', - title: gettext('DNS'), - insideWizard: true, - }, - { - title: gettext('Confirm'), - layout: 'fit', - items: [ - { - xtype: 'grid', - store: { - model: 'KeyValue', - sorters: [ - { - property: 'key', - direction: 'ASC', - }, - ], - }, - columns: [ - { header: 'Key', width: 150, dataIndex: 'key' }, - { header: 'Value', flex: 1, dataIndex: 'value', renderer: Ext.htmlEncode }, - ], - }, - ], - dockedItems: [ - { - xtype: 'proxmoxcheckbox', - name: 'start', - dock: 'bottom', - margin: '5 0 0 0', - boxLabel: gettext('Start after created'), - }, - ], - listeners: { - show: function (panel) { - let wizard = this.up('window'); - let kv = wizard.getValues(); - let data = []; - Ext.Object.each(kv, function (key, value) { - if (key === 'delete' || key === 'tmplstorage') { - // ignore - return; - } - if (key === 'password') { - // don't show pw - return; - } - data.push({ key: key, value: value }); - }); - - let summaryStore = panel.down('grid').getStore(); - summaryStore.suspendEvents(); - summaryStore.removeAll(); - summaryStore.add(data); - summaryStore.sort(); - summaryStore.resumeEvents(); - summaryStore.fireEvent('refresh'); - }, - }, - onSubmit: function () { - let wizard = this.up('window'); - let kv = wizard.getValues(); - delete kv.delete; - - let nodename = kv.nodename; - delete kv.nodename; - delete kv.tmplstorage; - - if (!kv.pool.length) { - delete kv.pool; - } - if (!kv.password.length && kv['ssh-public-keys']) { - delete kv.password; - } - - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/lxc`, - waitMsgTarget: wizard, - method: 'POST', - params: kv, - success: function (response, opts) { - Ext.create('Proxmox.window.TaskViewer', { - autoShow: true, - upid: response.result.data, - }); - wizard.close(); - }, - failure: (response, opts) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, - }, - ], -}); -Ext.define('PVE.lxc.DeviceInputPanel', { - extend: 'Proxmox.panel.InputPanel', - mixins: ['Proxmox.Mixin.CBind'], - - autoComplete: false, - - controller: { - xclass: 'Ext.app.ViewController', - }, - - setVMConfig: function (vmconfig) { - let me = this; - me.vmconfig = vmconfig; - - if (me.isCreate) { - PVE.Utils.forEachLxcDev((i, name) => { - if (!Ext.isDefined(vmconfig[name])) { - me.confid = name; - me.down('field[name=devid]').setValue(i); - return false; - } - return undefined; - }); - } - }, - - onGetValues: function (values) { - let me = this; - let confid = me.isCreate ? 'dev' + values.devid : me.confid; - delete values.devid; - let val = PVE.Parser.printPropertyString(values, 'path'); - let ret = {}; - ret[confid] = val; - return ret; - }, - - items: [ - { - xtype: 'proxmoxintegerfield', - name: 'devid', - minValue: 0, - maxValue: PVE.Utils.lxc_dev_count - 1, - hidden: true, - allowBlank: false, - disabled: true, - cbind: { - disabled: '{!isCreate}', - }, - }, - { - xtype: 'textfield', - name: 'path', - fieldLabel: gettext('Device Path'), - labelWidth: 120, - editable: true, - allowBlank: false, - emptyText: '/dev/xyz', - validator: (v) => - v.startsWith('/dev/') ? true : gettext('Path has to start with /dev/'), - }, - ], - - advancedColumn1: [ - { - xtype: 'proxmoxintegerfield', - name: 'uid', - editable: true, - fieldLabel: Ext.String.format(gettext('{0} in CT'), 'UID'), - labelWidth: 120, - emptyText: '0', - minValue: 0, - }, - { - xtype: 'proxmoxintegerfield', - name: 'gid', - editable: true, - fieldLabel: Ext.String.format(gettext('{0} in CT'), 'GID'), - labelWidth: 120, - emptyText: '0', - minValue: 0, - }, - ], - - advancedColumn2: [ - { - xtype: 'textfield', - name: 'mode', - editable: true, - fieldLabel: Ext.String.format(gettext('Access Mode in CT')), - labelWidth: 120, - emptyText: '0660', - validator: function (value) { - if (/^0[0-7]{3}$|^$/i.test(value)) { - return true; - } - return gettext('Access mode has to be an octal number'); - }, - }, - { - xtype: 'checkbox', - name: 'deny-write', - fieldLabel: gettext('Read only'), - labelWidth: 120, - checked: false, - }, - ], -}); - -Ext.define('PVE.lxc.DeviceEdit', { - extend: 'Proxmox.window.Edit', - - vmconfig: undefined, - - isAdd: true, - width: 450, - - initComponent: function () { - let me = this; - - me.isCreate = !me.confid; - - let ipanel = Ext.create('PVE.lxc.DeviceInputPanel', { - confid: me.confid, - isCreate: me.isCreate, - pveSelNode: me.pveSelNode, - }); - - let subject; - if (me.isCreate) { - subject = gettext('Device'); - } else { - subject = gettext('Device') + ' (' + me.confid + ')'; - } - - Ext.apply(me, { - subject: subject, - items: [ipanel], - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - ipanel.setVMConfig(response.result.data); - if (me.isCreate) { - return; - } - - let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'path'); - - let values = { - path: data.path, - mode: data.mode, - uid: data.uid, - gid: data.gid, - 'deny-write': data['deny-write'], - }; - - ipanel.setValues(values); - }, - }); - }, -}); -Ext.define('PVE.lxc.DNSInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveLxcDNSInputPanel', - - insideWizard: false, - - onGetValues: function (values) { - var me = this; - - var deletes = []; - if (!values.searchdomain && !me.insideWizard) { - deletes.push('searchdomain'); - } - - if (values.nameserver) { - let list = values.nameserver.split(/[ ,;]+/); - values.nameserver = list.join(' '); - } else if (!me.insideWizard) { - deletes.push('nameserver'); - } - - if (deletes.length) { - values.delete = deletes.join(','); - } - - return values; - }, - - initComponent: function () { - var me = this; - - var items = [ - { - xtype: 'proxmoxtextfield', - name: 'searchdomain', - skipEmptyText: true, - fieldLabel: gettext('DNS domain'), - emptyText: gettext('use host settings'), - allowBlank: true, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('DNS servers'), - vtype: 'IP64AddressWithSuffixList', - allowBlank: true, - emptyText: gettext('use host settings'), - name: 'nameserver', - itemId: 'nameserver', - }, - ]; - - if (me.insideWizard) { - me.column1 = items; - } else { - me.items = items; - } - - me.callParent(); - }, -}); - -Ext.define('PVE.lxc.DNSEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - var ipanel = Ext.create('PVE.lxc.DNSInputPanel'); - - Ext.apply(me, { - subject: gettext('Resources'), - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - - if (values.nameserver) { - values.nameserver.replace(/[,;]/, ' '); - values.nameserver.replace(/^\s+/, ''); - } - - ipanel.setValues(values); - }, - }); - } - }, -}); - -Ext.define('PVE.lxc.DNS', { - extend: 'Proxmox.grid.PendingObjectGrid', - alias: ['widget.pveLxcDNS'], - - onlineHelp: 'pct_container_network', - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var caps = Ext.state.Manager.get('GuiCap'); - - var rows = { - hostname: { - required: true, - defaultValue: me.pveSelNode.data.name, - header: gettext('Hostname'), - editor: caps.vms['VM.Config.Network'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Hostname'), - items: { - xtype: 'inputpanel', - items: { - fieldLabel: gettext('Hostname'), - xtype: 'textfield', - name: 'hostname', - vtype: 'DnsName', - allowBlank: true, - emptyText: 'CT' + vmid.toString(), - }, - onGetValues: function (values) { - var params = values; - if ( - values.hostname === undefined || - values.hostname === null || - values.hostname === '' - ) { - params = { hostname: 'CT' + vmid.toString() }; - } - return params; - }, - }, - } - : undefined, - }, - searchdomain: { - header: gettext('DNS domain'), - defaultValue: '', - editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined, - renderer: function (value) { - return value || gettext('use host settings'); - }, - }, - nameserver: { - header: gettext('DNS server'), - defaultValue: '', - editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined, - renderer: function (value) { - return value || gettext('use host settings'); - }, - }, - }; - - var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config'; - - var reload = function () { - me.rstore.load(); - }; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var run_editor = function () { - var rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - var rowdef = rows[rec.data.key]; - if (!rowdef.editor) { - return; - } - - var win; - if (Ext.isString(rowdef.editor)) { - win = Ext.create(rowdef.editor, { - pveSelNode: me.pveSelNode, - confid: rec.data.key, - url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config', - }); - } else { - let config = Ext.apply( - { - pveSelNode: me.pveSelNode, - confid: rec.data.key, - url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config', - }, - rowdef.editor, - ); - win = Ext.createWidget(rowdef.editor.xtype, config); - win.load(); - } - //win.load(); - win.show(); - win.on('destroy', reload); - }; - - var edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - enableFn: function (rec) { - var rowdef = rows[rec.data.key]; - return !!rowdef.editor; - }, - handler: run_editor, - }); - - var revert_btn = new PVE.button.PendingRevert(); - - var set_button_status = function () { - let button_sm = me.getSelectionModel(); - let rec = button_sm.getSelection()[0]; - - if (!rec) { - edit_btn.disable(); - return; - } - let key = rec.data.key; - - let rowdef = rows[key]; - edit_btn.setDisabled(!rowdef.editor); - - let pending = rec.data.delete || me.hasPendingChanges(key); - revert_btn.setDisabled(!pending); - }; - - Ext.apply(me, { - url: '/api2/json/nodes/' + nodename + '/lxc/' + vmid + '/pending', - selModel: sm, - cwidth1: 150, - interval: 5000, - run_editor: run_editor, - tbar: [edit_btn, revert_btn], - rows: rows, - editorConfig: { - url: '/api2/extjs/' + baseurl, - }, - listeners: { - itemdblclick: run_editor, - selectionchange: set_button_status, - activate: reload, - }, - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - me.on('deactivate', me.rstore.stopUpdate); - - me.mon(me.getStore(), 'datachanged', function () { - set_button_status(); - }); - }, -}); -Ext.define('PVE.lxc.FeaturesInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveLxcFeaturesInputPanel', - onlineHelp: 'pct_options', - - // used to save the mounts fstypes until sending - mounts: [], - - fstypes: ['nfs', 'cifs'], - - viewModel: { - parent: null, - data: { - unprivileged: false, - }, - formulas: { - privilegedOnly: function (get) { - return get('unprivileged') ? gettext('privileged only') : ''; - }, - unprivilegedOnly: function (get) { - return !get('unprivileged') ? gettext('unprivileged only') : ''; - }, - }, - }, - - items: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('keyctl'), - name: 'keyctl', - bind: { - disabled: '{!unprivileged}', - boxLabel: '{unprivilegedOnly}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Nesting'), - name: 'nesting', - }, - { - xtype: 'proxmoxcheckbox', - name: 'nfs', - fieldLabel: 'NFS', - bind: { - disabled: '{unprivileged}', - boxLabel: '{privilegedOnly}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'cifs', - fieldLabel: 'SMB/CIFS', - bind: { - disabled: '{unprivileged}', - boxLabel: '{privilegedOnly}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'fuse', - fieldLabel: 'FUSE', - }, - { - xtype: 'proxmoxcheckbox', - name: 'mknod', - fieldLabel: gettext('Create Device Nodes'), - boxLabel: gettext('Experimental'), - }, - ], - - onGetValues: function (values) { - var me = this; - var mounts = me.mounts; - me.fstypes.forEach(function (fs) { - if (values[fs]) { - mounts.push(fs); - } - delete values[fs]; - }); - - if (mounts.length) { - values.mount = mounts.join(';'); - } - - var featuresstring = PVE.Parser.printPropertyString(values, undefined); - if (featuresstring === '') { - return { delete: 'features' }; - } - return { features: featuresstring }; - }, - - setValues: function (values) { - var me = this; - - me.viewModel.set('unprivileged', values.unprivileged); - - if (values.features) { - let res = PVE.Parser.parsePropertyString(values.features); - me.mounts = []; - if (res.mount) { - res.mount.split(/[; ]/).forEach(function (item) { - if (me.fstypes.indexOf(item) === -1) { - me.mounts.push(item); - } else { - res[item] = 1; - } - }); - } - this.callParent([res]); - } - }, - - initComponent: function () { - let me = this; - me.mounts = []; // reset state - me.callParent(); - }, -}); - -Ext.define('PVE.lxc.FeaturesEdit', { - extend: 'Proxmox.window.Edit', - xtype: 'pveLxcFeaturesEdit', - - subject: gettext('Features'), - autoLoad: true, - width: 350, - - items: [ - { - xtype: 'pveLxcFeaturesInputPanel', - }, - ], -}); -Ext.define('PVE.lxc.EnvVariableField', { - extend: 'Ext.form.FieldContainer', - mixins: { - field: 'Ext.form.field.Field', - }, - xtype: 'pveLxcEnvVariableField', - - name: 'variable', - - layout: { - type: 'hbox', - align: 'stretch', - }, - - config: { - value: null, - }, - - // called when X icon-button is clicked, with this field as argument. - onRemove: Ext.emptyFn, - - setValue: function (nameValue) { - let me = this; - let viewModel = me.getViewModel(); - - me.value = nameValue; - let [name, value] = nameValue?.split('=') ?? ['', '']; - - viewModel.set('value', value); - viewModel.set('name', name); - - // TODO: sub-fields might not be available when this is called, so we cannot just set the - // value on field directly or call resetOriginalValue for correct reset orig. val. behavior - me.resetOriginalValue(); - }, - - getValue: function () { - let viewModel = this.getViewModel(); - let name = viewModel.get('name'); - let value = viewModel.get('value') ?? ''; - - return name?.length ? `${name}=${value}` : ''; - }, - - viewModel: { - parent: null, - data: { - name: '', - value: '', - }, - formulas: { - valueEmpty: (get) => !get('value')?.length, - }, - }, - - defaultType: 'textfield', - items: [ - { - xtype: 'proxmoxtextfield', - emptyText: gettext('Name'), - bind: { - allowBlank: '{valueEmpty}', - value: '{name}', - }, - submitValue: false, - flex: 2, - }, - { - xtype: 'box', - html: '=', - padding: '0 5', - }, - { - xtype: 'proxmoxtextfield', - emptyText: gettext('Value'), - bind: { - value: '{value}', - }, - submitValue: false, - flex: 3, - }, - { - xtype: 'button', - cls: 'x-btn-default-toolbar-small proxmox-inline-button', - iconCls: 'x-btn-icon-el-default-toolbar-small fa fa-trash-o', - handler: function (button, event) { - let field = button.up('pveLxcEnvVariableField'); - field.onRemove.call(field, field); - }, - }, - ], -}); - -Ext.define('PVE.lxc.EnvInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveLxcEnvInputPanel', - onlineHelp: 'pct_options', - - onGetValues: function (formValues) { - let variables = formValues?.variable; - if (typeof variables === 'string') { - variables = [variables]; - } - variables = variables?.filter((v) => typeof v === 'string' && v.length); - - let submitValues = {}; - if (variables?.length) { - submitValues.env = variables.join('\0'); - } else { - submitValues.delete = 'env'; - } - - return submitValues; - }, - - items: [ - { - xtype: 'fieldcontainer', - layout: { - type: 'hbox', - align: 'stretch', - }, - defaults: { - padding: '0 4', - }, - items: [ - { - xtype: 'displayfield', - flex: 2, - value: gettext('Name'), - }, - { - xtype: 'box', - html: ' ', - }, - { - xtype: 'displayfield', - flex: 3, - value: gettext('Value'), - }, - ], - }, - { - xtype: 'container', - name: 'variableContainer', - layout: 'anchor', - items: [], - }, - { - xtype: 'fieldcontainer', - layout: { - type: 'hbox', - align: 'start', - }, - items: { - xtype: 'button', - text: gettext('Add Variable'), - handler: function (button, event) { - let variableContainer = button - .up('pveLxcEnvInputPanel') - .down('container[name=variableContainer]'); - - variableContainer.add( - Ext.create({ - xtype: 'pveLxcEnvVariableField', - onRemove: (field) => variableContainer.remove(field), - }), - ); - }, - }, - }, - ], - - setValues: function (values) { - let me = this; - - me.env = values; // TODO: needed? - - let variableContainer = me.down('container[name=variableContainer]'); - - values.env?.split(/\0+/).forEach((value) => { - variableContainer.add( - Ext.create({ - xtype: 'pveLxcEnvVariableField', - onRemove: (field) => variableContainer.remove(field), - value, - }), - ); - }); - }, - - initComponent: function () { - let me = this; - me.mounts = []; // reset state - - me.callParent(); - }, -}); - -Ext.define('PVE.lxc.EnvEdit', { - extend: 'Proxmox.window.Edit', - xtype: 'pveLxcEnvEdit', - - subject: gettext('Environment'), - autoLoad: true, - width: 720, - - showReset: false, // TODO: fix reset handling for EnvVar inputpanel/fields. - - items: [ - { - xtype: 'pveLxcEnvInputPanel', - }, - ], -}); -Ext.define('PVE.lxc.MountPointInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveLxcMountPointInputPanel', - - onlineHelp: 'pct_container_storage', - - insideWizard: false, - - unused: false, // add unused disk imaged - unprivileged: false, - - vmconfig: {}, // used to select unused disks - - setUnprivileged: function (unprivileged) { - var me = this; - var vm = me.getViewModel(); - me.unprivileged = unprivileged; - vm.set('unpriv', unprivileged); - }, - - onGetValues: function (values) { - var me = this; - - var confid = me.confid || 'mp' + values.mpid; - me.mp.file = me.down('field[name=file]').getValue(); - - if (me.unused) { - confid = 'mp' + values.mpid; - } else if (me.isCreate) { - me.mp.file = values.hdstorage + ':' + values.disksize; - } - - // delete unnecessary fields - delete values.mpid; - delete values.hdstorage; - delete values.disksize; - delete values.diskformat; - - let setMPOpt = (k, src, v) => PVE.Utils.propertyStringSet(me.mp, src, k, v); - - setMPOpt('mp', values.mp); - let mountOpts = (values.mountoptions || []).join(';'); - setMPOpt('mountoptions', values.mountoptions, mountOpts); - setMPOpt('mp', values.mp); - setMPOpt('backup', values.backup); - setMPOpt('quota', values.quota); - setMPOpt('ro', values.ro); - setMPOpt('acl', values.acl); - setMPOpt('replicate', values.replicate); - - let res = {}; - res[confid] = PVE.Parser.printLxcMountPoint(me.mp); - return res; - }, - - setMountPoint: function (mp) { - let me = this; - let vm = me.getViewModel(); - vm.set('mptype', mp.type); - if (mp.mountoptions) { - mp.mountoptions = mp.mountoptions.split(';'); - } - me.mp = mp; - me.filterMountOptions(); - me.setValues(mp); - }, - - filterMountOptions: function () { - let me = this; - if (me.confid === 'rootfs') { - let field = me.down('field[name=mountoptions]'); - let exclude = ['nodev', 'noexec']; - let filtered = field.comboItems.filter((v) => !exclude.includes(v[0])); - field.setComboItems(filtered); - } - }, - - updateVMConfig: function (vmconfig) { - let me = this; - let vm = me.getViewModel(); - me.vmconfig = vmconfig; - vm.set('unpriv', vmconfig.unprivileged); - me.down('field[name=mpid]').validate(); - }, - - setVMConfig: function (vmconfig) { - let me = this; - - me.updateVMConfig(vmconfig); - PVE.Utils.forEachLxcMP((bus, i, name) => { - if (!Ext.isDefined(vmconfig[name])) { - me.down('field[name=mpid]').setValue(i); - return false; - } - return undefined; - }); - }, - - setNodename: function (nodename) { - let me = this; - let vm = me.getViewModel(); - vm.set('node', nodename); - me.down('#diskstorage').setNodename(nodename); - }, - - controller: { - xclass: 'Ext.app.ViewController', - - control: { - 'field[name=mpid]': { - change: function (field, value) { - let _me = this; - let view = this.getView(); - if (view.confid !== 'rootfs') { - view.fireEvent('diskidchange', view, `mp${value}`); - } - field.validate(); - }, - }, - '#hdstorage': { - change: function (field, newValue) { - let me = this; - if (!newValue) { - return; - } - - let rec = field.store.getById(newValue); - if (!rec) { - return; - } - me.getViewModel().set('type', rec.data.type); - }, - }, - }, - init: function (view) { - let _me = this; - let vm = this.getViewModel(); - view.mp = {}; - vm.set('confid', view.confid); - vm.set('unused', view.unused); - vm.set('node', view.nodename); - vm.set('unpriv', view.unprivileged); - vm.set('hideStorSelector', view.unused || !view.isCreate); - - if (view.isCreate) { - // can be array if created from unused disk - vm.set('isIncludedInBackup', true); - if (view.insideWizard) { - view.filterMountOptions(); - } - } - if (view.selectFree) { - view.setVMConfig(view.vmconfig); - } - }, - }, - - viewModel: { - data: { - unpriv: false, - unused: false, - showStorageSelector: false, - mptype: '', - type: '', - confid: '', - node: '', - }, - - formulas: { - quota: function (get) { - return !( - get('type') === 'zfs' || - get('type') === 'zfspool' || - get('unpriv') || - get('isBind') - ); - }, - hasMP: function (get) { - return !!get('confid') && !get('unused'); - }, - isRoot: function (get) { - return get('confid') === 'rootfs'; - }, - isBind: function (get) { - return get('mptype') === 'bind'; - }, - isBindOrRoot: function (get) { - return get('isBind') || get('isRoot'); - }, - }, - }, - - column1: [ - { - xtype: 'proxmoxintegerfield', - name: 'mpid', - fieldLabel: gettext('Mount Point ID'), - minValue: 0, - maxValue: PVE.Utils.lxc_mp_counts.mp - 1, - hidden: true, - allowBlank: false, - disabled: true, - bind: { - hidden: '{hasMP}', - disabled: '{hasMP}', - }, - validator: function (value) { - let view = this.up('inputpanel'); - if (!view.rendered) { - return undefined; - } - if (Ext.isDefined(view.vmconfig['mp' + value])) { - return 'Mount point is already in use.'; - } - return true; - }, - }, - { - xtype: 'pveDiskStorageSelector', - itemId: 'diskstorage', - storageContent: 'rootdir', - hidden: true, - autoSelect: true, - selectformat: false, - defaultSize: 8, - bind: { - hidden: '{hideStorSelector}', - disabled: '{hideStorSelector}', - nodename: '{node}', - }, - }, - { - xtype: 'textfield', - disabled: true, - submitValue: false, - fieldLabel: gettext('Disk image'), - name: 'file', - bind: { - hidden: '{!hideStorSelector}', - }, - }, - ], - - column2: [ - { - xtype: 'textfield', - name: 'mp', - value: '', - emptyText: gettext('/some/path'), - allowBlank: false, - disabled: true, - fieldLabel: gettext('Path'), - bind: { - hidden: '{isRoot}', - disabled: '{isRoot}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'backup', - fieldLabel: gettext('Backup'), - autoEl: { - tag: 'div', - 'data-qtip': gettext('Include volume in backup job'), - }, - bind: { - hidden: '{isRoot}', - disabled: '{isBindOrRoot}', - value: '{isIncludedInBackup}', - }, - }, - ], - - advancedColumn1: [ - { - xtype: 'proxmoxcheckbox', - name: 'quota', - defaultValue: 0, - bind: { - disabled: '{!quota}', - }, - fieldLabel: gettext('Enable quota'), - listeners: { - disable: function () { - this.reset(); - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'ro', - defaultValue: 0, - bind: { - hidden: '{isRoot}', - disabled: '{isRoot}', - }, - fieldLabel: gettext('Read-only'), - }, - { - xtype: 'proxmoxKVComboBox', - name: 'mountoptions', - fieldLabel: gettext('Mount options'), - deleteEmpty: false, - comboItems: [ - ['discard', 'discard'], - ['lazytime', 'lazytime'], - ['noatime', 'noatime'], - ['nodev', 'nodev'], - ['noexec', 'noexec'], - ['nosuid', 'nosuid'], - ], - multiSelect: true, - value: [], - allowBlank: true, - }, - ], - - advancedColumn2: [ - { - xtype: 'proxmoxKVComboBox', - name: 'acl', - fieldLabel: 'ACLs', - deleteEmpty: false, - comboItems: [ - ['__default__', Proxmox.Utils.defaultText], - ['1', Proxmox.Utils.enabledText], - ['0', Proxmox.Utils.disabledText], - ], - value: '__default__', - bind: { - disabled: '{isBind}', - }, - allowBlank: true, - }, - { - xtype: 'proxmoxcheckbox', - inputValue: '0', // reverses the logic - name: 'replicate', - fieldLabel: gettext('Skip replication'), - }, - ], -}); - -Ext.define('PVE.lxc.MountPointEdit', { - extend: 'Proxmox.window.Edit', - - unprivileged: false, - - initComponent: function () { - let me = this; - - let nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - let unused = me.confid && me.confid.match(/^unused\d+$/); - - me.isCreate = me.confid ? unused : true; - - let ipanel = Ext.create('PVE.lxc.MountPointInputPanel', { - confid: me.confid, - nodename: nodename, - unused: unused, - unprivileged: me.unprivileged, - isCreate: me.isCreate, - }); - - let subject; - if (unused) { - subject = gettext('Unused Disk'); - } else if (me.isCreate) { - subject = gettext('Mount Point'); - } else { - subject = gettext('Mount Point') + ' (' + me.confid + ')'; - } - - Ext.apply(me, { - subject: subject, - defaultFocus: me.confid !== 'rootfs' ? 'textfield[name=mp]' : 'tool', - items: ipanel, - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - ipanel.setVMConfig(response.result.data); - - if (me.confid) { - let value = response.result.data[me.confid]; - let mp = PVE.Parser.parseLxcMountPoint(value); - if (!mp) { - Ext.Msg.alert(gettext('Error'), 'Unable to parse mount point options'); - me.close(); - return; - } - ipanel.setMountPoint(mp); - me.isValid(); // trigger validation - } - }, - }); - }, -}); -Ext.define('PVE.window.MPResize', { - extend: 'Ext.window.Window', - - resizable: false, - - resize_disk: function (disk, size) { - var me = this; - var params = { disk: disk, size: '+' + size + 'G' }; - - Proxmox.Utils.API2Request({ - params: params, - url: '/nodes/' + me.nodename + '/lxc/' + me.vmid + '/resize', - waitMsgTarget: me, - method: 'PUT', - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, opts) { - var upid = response.result.data; - var win = Ext.create('Proxmox.window.TaskViewer', { upid: upid }); - win.show(); - me.close(); - }, - }); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - var items = [ - { - xtype: 'displayfield', - name: 'disk', - value: me.disk, - fieldLabel: gettext('Disk'), - vtype: 'StorageId', - allowBlank: false, - }, - ]; - - me.hdsizesel = Ext.createWidget('numberfield', { - name: 'size', - minValue: 0, - maxValue: 128 * 1024, - decimalPrecision: 3, - value: '0', - fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`, - allowBlank: false, - }); - - items.push(me.hdsizesel); - - me.formPanel = Ext.create('Ext.form.Panel', { - bodyPadding: 10, - border: false, - fieldDefaults: { - labelWidth: 120, - anchor: '100%', - }, - items: items, - }); - - var form = me.formPanel.getForm(); - - var submitBtn; - - me.title = gettext('Resize disk'); - submitBtn = Ext.create('Ext.Button', { - text: gettext('Resize disk'), - handler: function () { - if (form.isValid()) { - let values = form.getValues(); - me.resize_disk(me.disk, values.size); - } - }, - }); - - Ext.apply(me, { - modal: true, - border: false, - layout: 'fit', - buttons: [submitBtn], - items: [me.formPanel], - }); - - me.callParent(); - }, -}); -Ext.define('PVE.lxc.NetworkInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveLxcNetworkInputPanel', - - insideWizard: false, - - onlineHelp: 'pct_container_network', - - setNodename: function (nodename) { - let me = this; - - if (!nodename || me.nodename === nodename) { - return; - } - me.nodename = nodename; - - let bridgeSelector = me.query('[isFormField][name=bridge]')[0]; - bridgeSelector.setNodename(nodename); - }, - - onGetValues: function (values) { - let me = this; - - let id; - if (me.isCreate) { - id = values.id; - delete values.id; - } else { - id = me.ifname; - } - let newdata = {}; - if (id) { - if (values.ipv6mode !== 'static') { - values.ip6 = values.ipv6mode; - } - if (values.ipv4mode !== 'static') { - values.ip = values.ipv4mode; - } - newdata[id] = PVE.Parser.printLxcNetwork(values); - } - return newdata; - }, - - initComponent: function () { - let me = this; - - let cdata = {}; - if (me.insideWizard) { - me.ifname = 'net0'; - cdata.name = 'eth0'; - me.dataCache = {}; - } - cdata.firewall = me.insideWizard || me.isCreate; - - if (!me.dataCache) { - throw 'no dataCache specified'; - } - - if (!me.isCreate) { - if (!me.ifname) { - throw 'no interface name specified'; - } - if (!me.dataCache[me.ifname]) { - throw "no such interface '" + me.ifname + "'"; - } - cdata = PVE.Parser.parseLxcNetwork(me.dataCache[me.ifname]); - } - - for (let i = 0; i < 32; i++) { - let ifname = 'net' + i.toString(); - if (me.isCreate && !me.dataCache[ifname]) { - me.ifname = ifname; - break; - } - } - - me.column1 = [ - { - xtype: 'hidden', - name: 'id', - value: me.ifname, - }, - { - xtype: 'textfield', - name: 'name', - fieldLabel: gettext('Name'), - emptyText: '(e.g., eth0)', - allowBlank: false, - value: cdata.name, - validator: function (value) { - for (const [key, netRaw] of Object.entries(me.dataCache)) { - if (!key.match(/^net\d+/) || key === me.ifname) { - continue; - } - let net = PVE.Parser.parseLxcNetwork(netRaw); - if (net.name === value) { - return 'interface name already in use'; - } - } - return true; - }, - }, - { - xtype: 'textfield', - name: 'hwaddr', - fieldLabel: gettext('MAC address'), - vtype: 'MacAddress', - value: cdata.hwaddr, - allowBlank: true, - emptyText: 'auto', - }, - { - xtype: 'PVE.form.BridgeSelector', - name: 'bridge', - nodename: me.nodename, - fieldLabel: gettext('Bridge'), - value: cdata.bridge, - allowBlank: false, - }, - { - xtype: 'pveVlanField', - name: 'tag', - value: cdata.tag, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Firewall'), - name: 'firewall', - value: cdata.firewall, - }, - ]; - - let dhcp4 = cdata.ip === 'dhcp'; - if (dhcp4) { - cdata.ip = ''; - cdata.gw = ''; - } - - let auto6 = cdata.ip6 === 'auto'; - let dhcp6 = cdata.ip6 === 'dhcp'; - if (auto6 || dhcp6) { - cdata.ip6 = ''; - cdata.gw6 = ''; - } - - me.column2 = [ - { - layout: { - type: 'hbox', - align: 'middle', - }, - border: false, - margin: '0 0 5 0', - items: [ - { - xtype: 'label', - text: 'IPv4:', // do not localize - }, - { - xtype: 'radiofield', - boxLabel: gettext('Static'), - name: 'ipv4mode', - inputValue: 'static', - checked: !dhcp4, - margin: '0 0 0 10', - listeners: { - change: function (cb, value) { - me.down('field[name=ip]').setEmptyText( - value ? Proxmox.Utils.NoneText : '', - ); - me.down('field[name=ip]').setDisabled(!value); - me.down('field[name=gw]').setDisabled(!value); - }, - }, - }, - { - xtype: 'radiofield', - boxLabel: 'DHCP', // do not localize - name: 'ipv4mode', - inputValue: 'dhcp', - checked: dhcp4, - margin: '0 0 0 10', - }, - ], - }, - { - xtype: 'textfield', - name: 'ip', - vtype: 'IPCIDRAddress', - value: cdata.ip, - emptyText: dhcp4 ? '' : Proxmox.Utils.NoneText, - disabled: dhcp4, - fieldLabel: 'IPv4/CIDR', // do not localize - }, - { - xtype: 'textfield', - name: 'gw', - value: cdata.gw, - vtype: 'IPAddress', - disabled: dhcp4, - fieldLabel: gettext('Gateway') + ' (IPv4)', - margin: '0 0 3 0', // override bottom margin to account for the menuseparator - }, - { - xtype: 'menuseparator', - height: '3', - margin: '0', - }, - { - layout: { - type: 'hbox', - align: 'middle', - }, - border: false, - margin: '0 0 5 0', - items: [ - { - xtype: 'label', - text: 'IPv6:', // do not localize - }, - { - xtype: 'radiofield', - boxLabel: gettext('Static'), - name: 'ipv6mode', - inputValue: 'static', - checked: !(auto6 || dhcp6), - margin: '0 0 0 10', - listeners: { - change: function (cb, value) { - me.down('field[name=ip6]').setEmptyText( - value ? Proxmox.Utils.NoneText : '', - ); - me.down('field[name=ip6]').setDisabled(!value); - me.down('field[name=gw6]').setDisabled(!value); - }, - }, - }, - { - xtype: 'radiofield', - boxLabel: 'DHCP', // do not localize - name: 'ipv6mode', - inputValue: 'dhcp', - checked: dhcp6, - margin: '0 0 0 10', - }, - { - xtype: 'radiofield', - boxLabel: 'SLAAC', // do not localize - name: 'ipv6mode', - inputValue: 'auto', - checked: auto6, - margin: '0 0 0 10', - }, - ], - }, - { - xtype: 'textfield', - name: 'ip6', - value: cdata.ip6, - emptyText: dhcp6 || auto6 ? '' : Proxmox.Utils.NoneText, - vtype: 'IP6CIDRAddress', - disabled: dhcp6 || auto6, - fieldLabel: 'IPv6/CIDR', // do not localize - }, - { - xtype: 'textfield', - name: 'gw6', - vtype: 'IP6Address', - value: cdata.gw6, - disabled: dhcp6 || auto6, - fieldLabel: gettext('Gateway') + ' (IPv6)', - }, - ]; - - me.advancedColumn1 = [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Disconnect'), - name: 'link_down', - value: cdata.link_down, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: 'MTU', - emptyText: gettext('Same as bridge'), - name: 'mtu', - value: cdata.mtu, - minValue: 576, - maxValue: 65535, - }, - ]; - - me.advancedColumn2 = [ - { - xtype: 'numberfield', - name: 'rate', - fieldLabel: gettext('Rate limit') + ' (MB/s)', - minValue: 0, - maxValue: 10 * 1024, - value: cdata.rate, - emptyText: 'unlimited', - allowBlank: true, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Host-Managed'), - name: 'host-managed', - value: cdata['host-managed'], - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.lxc.NetworkEdit', { - extend: 'Proxmox.window.Edit', - - isAdd: true, - - initComponent: function () { - let me = this; - - if (!me.dataCache) { - throw 'no dataCache specified'; - } - if (!me.nodename) { - throw 'no node name specified'; - } - - Ext.apply(me, { - subject: gettext('Network Device') + ' (veth)', - digest: me.dataCache.digest, - items: [ - { - xtype: 'pveLxcNetworkInputPanel', - ifname: me.ifname, - nodename: me.nodename, - dataCache: me.dataCache, - isCreate: me.isCreate, - }, - ], - }); - - me.callParent(); - }, -}); - -Ext.define( - 'PVE.lxc.NetworkView', - { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveLxcNetworkView', - - onlineHelp: 'pct_container_network', - - dataCache: {}, // used to store result of last load - - stateful: true, - stateId: 'grid-lxc-network', - - load: async function () { - let me = this; - - Proxmox.Utils.setErrorMask(me, true); - - let nodename = me.pveSelNode.data.node; - let vmid = me.pveSelNode.data.vmid; - - try { - let ifResponse = await Proxmox.Async.api2({ - url: `/nodes/${nodename}/lxc/${vmid}/interfaces`, - method: 'GET', - }); - let confResponse = await Proxmox.Async.api2({ - url: me.url, - }); - Proxmox.Utils.setErrorMask(me, false); - - let interfaces = []; - for (const [, iface] of Object.entries(ifResponse?.result?.data || {})) { - interfaces[iface['hardware-address']] = iface; - } - - let records = []; - me.dataCache = confResponse.result.data || {}; - for (const [key, value] of Object.entries(confResponse.result.data)) { - if (!key.match(/^net\d+/)) { - continue; - } - let config = PVE.Parser.parseLxcNetwork(value); - let net = structuredClone(config); - net.id = key; - - let iface = interfaces[config.hwaddr.toLowerCase()]; - if (iface) { - net.name = iface.name; - net.ip = []; - net.ip6 = []; - for (const i of iface['ip-addresses']) { - let ip_with_prefix = `${i['ip-address']}/${i.prefix}`; - if (i['ip-address-type'] === 'inet') { - if (config.ip === ip_with_prefix) { - net.ip.push(`${ip_with_prefix} (static)`); - } else { - // this could be dhcp, but also a static address set directly on the container - net.ip.push(`${ip_with_prefix} (dynamic)`); - } - } else if (i['ip-address-type'] === 'inet6') { - if (config.ip6 === ip_with_prefix) { - net.ip6.push(`${ip_with_prefix} (static)`); - } else { - // this could be dhcp, slaac, but also a static address set directly on the container - net.ip6.push(`${ip_with_prefix} (dynamic)`); - } - } - } - } - records.push(net); - } - - me.store.loadData(records); - me.down('button[name=addButton]').setDisabled(records.length >= 32); - } catch (error) { - Proxmox.Utils.setErrorMask(me, gettext('Error') + ': ' + error); - } - }, - - initComponent: function () { - let me = this; - - let nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - let vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - let caps = Ext.state.Manager.get('GuiCap'); - - me.url = `/nodes/${nodename}/lxc/${vmid}/config`; - - let store = new Ext.data.Store({ - model: 'pve-lxc-network', - sorters: [ - { - property: 'id', - direction: 'ASC', - }, - ], - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec || !caps.vms['VM.Config.Network']) { - return false; // disable default-propagation when triggered by grid dblclick - } - Ext.create('PVE.lxc.NetworkEdit', { - url: me.url, - nodename: nodename, - dataCache: me.dataCache, - ifname: rec.data.id, - listeners: { - destroy: () => me.load(), - }, - autoShow: true, - }); - return undefined; - }; - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: [ - { - text: gettext('Add'), - name: 'addButton', - disabled: !caps.vms['VM.Config.Network'], - handler: function () { - Ext.create('PVE.lxc.NetworkEdit', { - url: me.url, - nodename: nodename, - isCreate: true, - dataCache: me.dataCache, - listeners: { - destroy: () => me.load(), - }, - autoShow: true, - }); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Remove'), - disabled: true, - selModel: sm, - enableFn: function (rec) { - return !!caps.vms['VM.Config.Network']; - }, - confirmMsg: ({ data }) => - Ext.String.format( - gettext('Are you sure you want to remove entry {0}'), - `'${data.id}'`, - ), - handler: function (btn, e, rec) { - Proxmox.Utils.API2Request({ - url: me.url, - waitMsgTarget: me, - method: 'PUT', - params: { - delete: rec.data.id, - digest: me.dataCache.digest, - }, - callback: () => me.load(), - failure: (response, opts) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - selModel: sm, - disabled: true, - enableFn: (rec) => !!caps.vms['VM.Config.Network'], - handler: run_editor, - }, - ], - columns: [ - { - header: 'ID', - width: 50, - dataIndex: 'id', - }, - { - header: gettext('Name'), - width: 80, - dataIndex: 'name', - }, - { - header: gettext('Bridge'), - width: 80, - dataIndex: 'bridge', - }, - { - header: gettext('Firewall'), - width: 80, - dataIndex: 'firewall', - renderer: Proxmox.Utils.format_boolean, - }, - { - header: gettext('VLAN Tag'), - width: 70, - dataIndex: 'tag', - }, - { - header: gettext('MAC address'), - width: 110, - dataIndex: 'hwaddr', - }, - { - header: gettext('IP address'), - width: 300, - dataIndex: 'ip', - renderer: function (_value, _metaData, rec) { - const formatIpValue = (value, prefix) => { - if (Array.isArray(value) && value.length > 0) { - // multiple addresses (usually from the api) - return value.join('
    ') + '
    '; - } else if (typeof value === 'string') { - if (value === 'dhcp') { - // ipv4 and ipv6 dhcp - return `${prefix}dhcp
    `; - } else if (value === 'auto') { - // ipv6 slaac - return `${prefix}auto
    `; - } else if (value.length > 0) { - // single address (usually from config) - return value + '
    '; - } - } - return ''; - }; - - return ( - formatIpValue(rec.data.ip, 'ip: ') + - formatIpValue(rec.data.ip6, 'ip6: ') - ); - }, - }, - { - header: gettext('Gateway'), - width: 150, - dataIndex: 'gw', - renderer: function (value, metaData, rec) { - if (rec.data.gw && rec.data.gw6) { - return rec.data.gw + '
    ' + rec.data.gw6; - } else if (rec.data.gw6) { - return rec.data.gw6; - } else { - return rec.data.gw; - } - }, - }, - { - header: gettext('MTU'), - width: 80, - dataIndex: 'mtu', - }, - { - header: gettext('Disconnected'), - width: 100, - dataIndex: 'link_down', - renderer: Proxmox.Utils.format_boolean, - }, - ], - listeners: { - activate: me.load, - itemdblclick: run_editor, - }, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-lxc-network', { - extend: 'Ext.data.Model', - proxy: { type: 'memory' }, - fields: [ - 'id', - 'name', - 'hwaddr', - 'bridge', - 'ip', - 'gw', - 'ip6', - 'gw6', - 'tag', - 'firewall', - 'mtu', - 'link_down', - ], - }); - }, -); -Ext.define('PVE.lxc.Options', { - extend: 'Proxmox.grid.PendingObjectGrid', - alias: ['widget.pveLxcOptions'], - - onlineHelp: 'pct_options', - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var caps = Ext.state.Manager.get('GuiCap'); - - var rows = { - onboot: { - header: gettext('Start at boot'), - defaultValue: '', - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Start at boot'), - items: { - xtype: 'proxmoxcheckbox', - name: 'onboot', - uncheckedValue: 0, - defaultValue: 0, - fieldLabel: gettext('Start at boot'), - }, - } - : undefined, - }, - startup: { - header: gettext('Start/Shutdown order'), - defaultValue: '', - renderer: PVE.Utils.render_kvm_startup, - editor: - caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify'] - ? { - xtype: 'pveWindowStartupEdit', - onlineHelp: 'pct_startup_and_shutdown', - } - : undefined, - }, - ostype: { - header: gettext('OS Type'), - defaultValue: Proxmox.Utils.unknownText, - }, - arch: { - header: gettext('Architecture'), - defaultValue: Proxmox.Utils.unknownText, - }, - console: { - header: '/dev/console', - defaultValue: 1, - renderer: Proxmox.Utils.format_enabled_toggle, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: '/dev/console', - items: { - xtype: 'proxmoxcheckbox', - name: 'console', - uncheckedValue: 0, - defaultValue: 1, - deleteDefaultValue: true, - checked: true, - fieldLabel: '/dev/console', - }, - } - : undefined, - }, - tty: { - header: gettext('TTY count'), - defaultValue: 2, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('TTY count'), - items: { - xtype: 'proxmoxintegerfield', - name: 'tty', - minValue: 0, - maxValue: 6, - fieldLabel: gettext('TTY count'), - emptyText: gettext('Default'), - deleteEmpty: true, - }, - } - : undefined, - }, - cmode: { - header: gettext('Console mode'), - defaultValue: 'tty', - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Console mode'), - items: { - xtype: 'proxmoxKVComboBox', - name: 'cmode', - deleteEmpty: true, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (tty)'], - ['tty', '/dev/tty[X]'], - ['console', '/dev/console'], - ['shell', 'shell'], - ], - fieldLabel: gettext('Console mode'), - }, - } - : undefined, - }, - protection: { - header: gettext('Protection'), - defaultValue: false, - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Protection'), - items: { - xtype: 'proxmoxcheckbox', - name: 'protection', - uncheckedValue: 0, - defaultValue: 0, - deleteDefaultValue: true, - fieldLabel: gettext('Enabled'), - }, - } - : undefined, - }, - unprivileged: { - header: gettext('Unprivileged container'), - renderer: Proxmox.Utils.format_boolean, - defaultValue: 0, - }, - features: { - header: gettext('Features'), - defaultValue: Proxmox.Utils.noneText, - editor: 'PVE.lxc.FeaturesEdit', - }, - hookscript: { - header: gettext('Hookscript'), - renderer: Ext.htmlEncode, - }, - entrypoint: { - header: gettext('Entrypoint'), - defaultValue: '/sbin/init', - renderer: Ext.htmlEncode, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Entrypoint Init Command'), - defaultFocus: undefined, - items: [ - { - xtype: 'proxmoxtextfield', - name: 'entrypoint', - deleteEmpty: true, - emptyText: '/sbin/init', - }, - - { - xtype: 'displayfield', - reference: 'emptyWarning', - userCls: 'pmx-hint', - value: gettext( - 'Changing the entrypoint command can lead to start failure!', - ), - }, - ], - } - : undefined, - }, - env: { - header: gettext('Environment'), - renderer: (v) => (v ? Ext.htmlEncode(v.replaceAll(/\0+/g, ' ')) : null), - defaultValue: Proxmox.Utils.noneText, - editor: 'PVE.lxc.EnvEdit', - }, - }; - - var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config'; - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - enableFn: function (rec) { - var rowdef = rows[rec.data.key]; - return !!rowdef.editor; - }, - handler: function () { - me.run_editor(); - }, - }); - - var revert_btn = new PVE.button.PendingRevert(); - - var set_button_status = function () { - let button_sm = me.getSelectionModel(); - let rec = button_sm.getSelection()[0]; - - if (!rec) { - edit_btn.disable(); - return; - } - - var key = rec.data.key; - var pending = rec.data.delete || me.hasPendingChanges(key); - var rowdef = rows[key]; - - if (key === 'features') { - let unprivileged = me.getStore().getById('unprivileged').data.value; - let root = Proxmox.UserName === 'root@pam'; - let vmalloc = caps.vms['VM.Allocate']; - edit_btn.setDisabled(!(root || (vmalloc && unprivileged))); - } else { - edit_btn.setDisabled(!rowdef.editor); - } - - revert_btn.setDisabled(!pending); - }; - - Ext.apply(me, { - url: '/api2/json/nodes/' + nodename + '/lxc/' + vmid + '/pending', - selModel: sm, - interval: 5000, - tbar: [edit_btn, revert_btn], - rows: rows, - editorConfig: { - url: '/api2/extjs/' + baseurl, - }, - listeners: { - itemdblclick: me.run_editor, - selectionchange: set_button_status, - }, - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - me.on('deactivate', me.rstore.stopUpdate); - - me.mon(me.getStore(), 'datachanged', function () { - set_button_status(); - }); - }, -}); -var labelWidth = 120; - -Ext.define('PVE.lxc.MemoryEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - Ext.apply(me, { - subject: gettext('Memory'), - items: Ext.create('PVE.lxc.MemoryInputPanel'), - }); - - me.callParent(); - - me.load(); - }, -}); - -Ext.define('PVE.lxc.CPUEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveLxcCPUEdit', - - viewModel: { - data: { - cgroupMode: 2, - }, - }, - - initComponent: function () { - let me = this; - me.getViewModel().set('cgroupMode', me.cgroupMode); - - Ext.apply(me, { - subject: gettext('CPU'), - items: Ext.create('PVE.lxc.CPUInputPanel'), - }); - - me.callParent(); - - me.load(); - }, -}); - -// The view model of the parent should contain a 'cgroupMode' variable (or params for v2 are used). -Ext.define('PVE.lxc.CPUInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveLxcCPUInputPanel', - - onlineHelp: 'pct_cpu', - - insideWizard: false, - - viewModel: { - formulas: { - cpuunitsDefault: (get) => (get('cgroupMode') === 1 ? 1024 : 100), - cpuunitsMax: (get) => (get('cgroupMode') === 1 ? 500000 : 10000), - }, - }, - - onGetValues: function (values) { - let me = this; - let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault'); - - PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard); - PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard); - - return values; - }, - - advancedColumn1: [ - { - xtype: 'numberfield', - name: 'cpulimit', - minValue: 0, - value: '', - step: 1, - fieldLabel: gettext('CPU limit'), - allowBlank: true, - emptyText: gettext('unlimited'), - }, - ], - - advancedColumn2: [ - { - xtype: 'proxmoxintegerfield', - name: 'cpuunits', - fieldLabel: gettext('CPU units'), - value: '', - minValue: 8, - maxValue: '10000', - emptyText: '100', - bind: { - emptyText: '{cpuunitsDefault}', - maxValue: '{cpuunitsMax}', - }, - labelWidth: labelWidth, - deleteEmpty: true, - allowBlank: true, - }, - ], - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: 'proxmoxintegerfield', - name: 'cores', - minValue: 1, - maxValue: 8192, - value: me.insideWizard ? 1 : '', - fieldLabel: gettext('Cores'), - allowBlank: true, - deleteEmpty: true, - emptyText: gettext('unlimited'), - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.lxc.MemoryInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveLxcMemoryInputPanel', - - onlineHelp: 'pct_memory', - - insideWizard: false, - - initComponent: function () { - var me = this; - - var items = [ - { - xtype: 'proxmoxintegerfield', - name: 'memory', - minValue: 16, - value: '512', - step: 32, - fieldLabel: gettext('Memory') + ' (MiB)', - labelWidth: labelWidth, - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'swap', - minValue: 0, - value: '512', - step: 32, - fieldLabel: gettext('Swap') + ' (MiB)', - labelWidth: labelWidth, - allowBlank: false, - }, - ]; - - if (me.insideWizard) { - me.column1 = items; - } else { - me.items = items; - } - - me.callParent(); - }, -}); -Ext.define('PVE.lxc.RessourceView', { - extend: 'Proxmox.grid.PendingObjectGrid', - alias: ['widget.pveLxcRessourceView'], - - onlineHelp: 'pct_configuration', - - renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { - let me = this; - let rowdef = me.rows[key] || {}; - - let txt = rowdef.header || key; - let icon = ''; - - metaData.tdAttr = 'valign=middle'; - if (rowdef.tdCls) { - metaData.tdCls = rowdef.tdCls; - } else if (rowdef.iconCls) { - icon = ``; - metaData.tdCls += ' pve-itype-fa'; - } - // only return icons in grid but not remove dialog - if (rowIndex !== undefined) { - return icon + txt; - } else { - return txt; - } - }, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var caps = Ext.state.Manager.get('GuiCap'); - var diskCap = caps.vms['VM.Config.Disk']; - - var mpeditor = caps.vms['VM.Config.Disk'] ? 'PVE.lxc.MountPointEdit' : undefined; - - const nodeInfo = PVE.data.ResourceStore.getNodes().find((node) => node.node === nodename); - let cpuEditor = { - xtype: 'pveLxcCPUEdit', - cgroupMode: nodeInfo['cgroup-mode'], - }; - - var rows = { - memory: { - header: gettext('Memory'), - editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined, - defaultValue: 512, - tdCls: 'pmx-itype-icon-memory', - group: 1, - renderer: function (value) { - return Proxmox.Utils.format_size(value * 1024 * 1024); - }, - }, - swap: { - header: gettext('Swap'), - editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined, - defaultValue: 512, - iconCls: 'refresh', - group: 2, - renderer: function (value) { - return Proxmox.Utils.format_size(value * 1024 * 1024); - }, - }, - cores: { - header: gettext('Cores'), - editor: caps.vms['VM.Config.CPU'] ? cpuEditor : undefined, - defaultValue: '', - tdCls: 'pmx-itype-icon-processor', - group: 3, - renderer: function (value) { - var cpulimit = me.getObjectValue('cpulimit'); - var cpuunits = me.getObjectValue('cpuunits'); - var res; - if (value) { - res = value; - } else { - res = gettext('unlimited'); - } - - if (cpulimit) { - res += ' [cpulimit=' + cpulimit + ']'; - } - - if (cpuunits) { - res += ' [cpuunits=' + cpuunits + ']'; - } - return res; - }, - }, - rootfs: { - header: gettext('Root Disk'), - defaultValue: Proxmox.Utils.noneText, - editor: mpeditor, - iconCls: 'hdd-o', - group: 4, - renderer: Ext.htmlEncode, - }, - cpulimit: { - visible: false, - }, - cpuunits: { - visible: false, - }, - unprivileged: { - visible: false, - }, - }; - - PVE.Utils.forEachLxcMP(function (bus, i, confid) { - var group = 5; - var header; - if (bus === 'mp') { - header = gettext('Mount Point') + ' (' + confid + ')'; - } else { - header = gettext('Unused Disk') + ' ' + i; - group += 1; - } - rows[confid] = { - group: group, - order: i, - iconCls: 'hdd-o', - editor: mpeditor, - header: header, - renderer: Ext.htmlEncode, - }; - }, true); - - let deveditor = Proxmox.UserName === 'root@pam' ? 'PVE.lxc.DeviceEdit' : undefined; - - PVE.Utils.forEachLxcDev(function (i, confid) { - rows[confid] = { - group: 7, - order: i, - tdCls: 'pve-itype-icon-pci', - editor: deveditor, - header: gettext('Device') + ' (' + confid + ')', - renderer: Ext.htmlEncode, - }; - }); - - var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config'; - - me.selModel = Ext.create('Ext.selection.RowModel', {}); - - var run_resize = function () { - var rec = me.selModel.getSelection()[0]; - if (!rec) { - return; - } - - var win = Ext.create('PVE.window.MPResize', { - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - }); - - win.show(); - }; - - var run_remove = function (b, e, rec) { - Proxmox.Utils.API2Request({ - url: '/api2/extjs/' + baseurl, - waitMsgTarget: me, - method: 'PUT', - params: { - delete: rec.data.key, - }, - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - }); - }; - - let run_move = function () { - let rec = me.selModel.getSelection()[0]; - if (!rec) { - return; - } - - var win = Ext.create('PVE.window.HDMove', { - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - type: 'lxc', - }); - - win.show(); - - win.on('destroy', me.reload, me); - }; - - let run_reassign = function () { - let rec = me.selModel.getSelection()[0]; - if (!rec) { - return; - } - - Ext.create('PVE.window.GuestDiskReassign', { - disk: rec.data.key, - nodename: nodename, - autoShow: true, - vmid: vmid, - type: 'lxc', - listeners: { - destroy: () => me.reload(), - }, - }); - }; - - var edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - selModel: me.selModel, - disabled: true, - enableFn: function (rec) { - if (!rec) { - return false; - } - var rowdef = rows[rec.data.key]; - return !!rowdef.editor; - }, - handler: function () { - me.run_editor(); - }, - }); - - var remove_btn = new Proxmox.button.Button({ - text: gettext('Remove'), - defaultText: gettext('Remove'), - altText: gettext('Detach'), - selModel: me.selModel, - disabled: true, - dangerous: true, - confirmMsg: function (rec) { - let warn = Ext.String.format(gettext('Are you sure you want to remove entry {0}')); - if (this.text === this.altText) { - warn = gettext('Are you sure you want to detach entry {0}'); - } - let rendered = me.renderKey(rec.data.key, {}, rec); - let msg = Ext.String.format(warn, `'${rendered}'`); - - if (rec.data.key.match(/^unused\d+$/)) { - msg += ' ' + gettext('This will permanently erase all data.'); - } - return msg; - }, - handler: run_remove, - listeners: { - render: function (btn) { - // hack: calculate the max button width on first display to prevent the whole - // toolbar to move when we switch between the "Remove" and "Detach" labels - let def = btn.getSize().width; - - btn.setText(btn.altText); - let alt = btn.getSize().width; - - btn.setText(btn.defaultText); - - let optimal = alt > def ? alt : def; - btn.setSize({ width: optimal }); - }, - }, - }); - - let move_menuitem = new Ext.menu.Item({ - text: gettext('Move Storage'), - tooltip: gettext('Move volume to another storage'), - iconCls: 'fa fa-database', - selModel: me.selModel, - handler: run_move, - }); - - let reassign_menuitem = new Ext.menu.Item({ - text: gettext('Reassign Owner'), - tooltip: gettext('Reassign volume to another CT'), - iconCls: 'fa fa-cube', - handler: run_reassign, - reference: 'reassing_item', - }); - - let resize_menuitem = new Ext.menu.Item({ - text: gettext('Resize'), - iconCls: 'fa fa-plus', - selModel: me.selModel, - handler: run_resize, - }); - - let volumeaction_btn = new Proxmox.button.Button({ - text: gettext('Volume Action'), - disabled: true, - menu: { - items: [move_menuitem, reassign_menuitem, resize_menuitem], - }, - }); - - let revert_btn = new PVE.button.PendingRevert(); - - let set_button_status = function () { - let rec = me.selModel.getSelection()[0]; - - if (!rec) { - edit_btn.disable(); - remove_btn.disable(); - volumeaction_btn.disable(); - revert_btn.disable(); - return; - } - let { key, value, delete: isDelete } = rec.data; - let rowdef = rows[key]; - - let pending = isDelete || me.hasPendingChanges(key); - let isRootFS = key === 'rootfs'; - let isDisk = isRootFS || key.match(/^(mp|unused)\d+/); - let isUnusedDisk = key.match(/^unused\d+/); - let isUsedDisk = isDisk && !isUnusedDisk; - let isDevice = key.match(/^dev\d+/); - - let noedit = isDelete || !rowdef.editor; - if (!noedit && Proxmox.UserName !== 'root@pam' && key.match(/^mp\d+$/)) { - let mp = PVE.Parser.parseLxcMountPoint(value); - if (mp.type !== 'volume') { - noedit = true; - } - } - edit_btn.setDisabled(noedit); - - volumeaction_btn.setDisabled(!isDisk || !diskCap); - move_menuitem.setDisabled(isUnusedDisk); - reassign_menuitem.setDisabled(isRootFS); - resize_menuitem.setDisabled(isUnusedDisk); - - remove_btn.setDisabled(!(isDisk || isDevice) || isRootFS || !diskCap || pending); - revert_btn.setDisabled(!pending); - - remove_btn.setText(isUsedDisk ? remove_btn.altText : remove_btn.defaultText); - }; - - let sorterFn = function (rec1, rec2) { - let v1 = rec1.data.key, - v2 = rec2.data.key; - - let g1 = rows[v1].group || 0, - g2 = rows[v2].group || 0; - if (g1 - g2 !== 0) { - return g1 - g2; - } - - let order1 = rows[v1].order || 0, - order2 = rows[v2].order || 0; - if (order1 - order2 !== 0) { - return order1 - order2; - } - - if (v1 > v2) { - return 1; - } else if (v1 < v2) { - return -1; - } else { - return 0; - } - }; - - Ext.apply(me, { - url: `/api2/json/nodes/${nodename}/lxc/${vmid}/pending`, - selModel: me.selModel, - interval: 2000, - cwidth1: 170, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: [ - { - text: gettext('Mount Point'), - iconCls: 'fa fa-fw fa-hdd-o black', - disabled: !caps.vms['VM.Config.Disk'], - handler: function () { - Ext.create('PVE.lxc.MountPointEdit', { - autoShow: true, - url: `/api2/extjs/${baseurl}`, - unprivileged: me.getObjectValue('unprivileged'), - pveSelNode: me.pveSelNode, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }, - { - text: gettext('Device Passthrough'), - iconCls: 'pve-itype-icon-pci', - disabled: Proxmox.UserName !== 'root@pam', - handler: function () { - Ext.create('PVE.lxc.DeviceEdit', { - autoShow: true, - url: `/api2/extjs/${baseurl}`, - pveSelNode: me.pveSelNode, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }, - ], - }), - }, - edit_btn, - remove_btn, - volumeaction_btn, - revert_btn, - ], - rows: rows, - sorterFn: sorterFn, - editorConfig: { - pveSelNode: me.pveSelNode, - url: '/api2/extjs/' + baseurl, - }, - listeners: { - itemdblclick: me.run_editor, - selectionchange: set_button_status, - }, - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - me.on('deactivate', me.rstore.stopUpdate); - - me.mon(me.getStore(), 'datachanged', function () { - set_button_status(); - }); - - Ext.apply(me.editorConfig, { unprivileged: me.getObjectValue('unprivileged') }); - }, -}); -Ext.define('PVE.lxc.MultiMPPanel', { - extend: 'PVE.panel.MultiDiskPanel', - alias: 'widget.pveMultiMPPanel', - - onlineHelp: 'pct_container_storage', - - controller: { - xclass: 'Ext.app.ViewController', - - // count of mps + rootfs - maxCount: PVE.Utils.lxc_mp_counts.mp + 1, - - getNextFreeDisk: function (vmconfig) { - let nextFreeDisk; - if (!vmconfig.rootfs) { - return { - confid: 'rootfs', - }; - } else { - for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) { - let confid = `mp${i}`; - if (!vmconfig[confid]) { - nextFreeDisk = { - confid, - }; - break; - } - } - } - return nextFreeDisk; - }, - - addPanel: function (itemId, vmconfig, nextFreeDisk) { - let me = this; - return me.getView().add({ - vmconfig, - border: false, - showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'), - xtype: 'pveLxcMountPointInputPanel', - confid: nextFreeDisk.confid === 'rootfs' ? 'rootfs' : null, - bind: { - nodename: '{nodename}', - unprivileged: '{unprivileged}', - }, - padding: '0 5 0 10', - itemId, - selectFree: true, - isCreate: true, - insideWizard: true, - }); - }, - - getBaseVMConfig: function () { - let me = this; - - return { - unprivileged: me.getViewModel().get('unprivileged'), - }; - }, - - diskSorter: { - sorterFn: function (rec1, rec2) { - if (rec1.data.name === 'rootfs') { - return -1; - } else if (rec2.data.name === 'rootfs') { - return 1; - } - - let mp_match = /^mp(\d+)$/; - let [, id1] = mp_match.exec(rec1.data.name); - let [, id2] = mp_match.exec(rec2.data.name); - - return parseInt(id1, 10) - parseInt(id2, 10); - }, - }, - - deleteDisabled: (view, rI, cI, item, rec) => rec.data.name === 'rootfs', - }, -}); -Ext.define('PVE.menu.Item', { - extend: 'Ext.menu.Item', - alias: 'widget.pveMenuItem', - - // set to wrap the handler callback in a confirm dialog showing this text - confirmMsg: false, - - // set to focus 'No' instead of 'Yes' button and show a warning symbol - dangerous: false, - - initComponent: function () { - let me = this; - if (me.handler) { - me.setHandler(me.handler, me.scope); - } - me.callParent(); - }, - - setHandler: function (fn, scope) { - let me = this; - me.scope = scope; - me.handler = function (button, e) { - if (me.confirmMsg) { - Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1; - Ext.Msg.show({ - title: gettext('Confirm'), - icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION, - msg: me.confirmMsg, - buttons: Ext.Msg.YESNO, - defaultFocus: me.dangerous ? 'no' : 'yes', - callback: function (btn) { - if (btn === 'yes') { - Ext.callback(fn, me.scope, [me, e], 0, me); - } - }, - }); - } else { - Ext.callback(fn, me.scope, [me, e], 0, me); - } - }; - }, -}); -Ext.define('PVE.menu.TemplateMenu', { - extend: 'Ext.menu.Menu', - - initComponent: function () { - let me = this; - - let info = me.pveSelNode.data; - if (!info.node) { - throw 'no node name specified'; - } - if (!info.vmid) { - throw 'no VM ID specified'; - } - - let guestType = me.pveSelNode.data.type; - if (guestType !== 'qemu' && guestType !== 'lxc') { - throw `invalid guest type ${guestType}`; - } - - let template = me.pveSelNode.data.template; - - me.title = (guestType === 'qemu' ? 'VM ' : 'CT ') + info.vmid; - - let caps = Ext.state.Manager.get('GuiCap'); - let standaloneNode = PVE.Utils.isStandaloneNode(); - - me.items = [ - { - text: gettext('Migrate'), - iconCls: 'fa fa-fw fa-send-o', - hidden: standaloneNode || !caps.vms['VM.Migrate'], - handler: function () { - Ext.create('PVE.window.Migrate', { - vmtype: guestType, - nodename: info.node, - vmid: info.vmid, - autoShow: true, - }); - }, - }, - { - text: gettext('Clone'), - iconCls: 'fa fa-fw fa-clone', - hidden: !caps.vms['VM.Clone'], - handler: function () { - Ext.create('PVE.window.Clone', { - nodename: info.node, - guestType: guestType, - vmid: info.vmid, - isTemplate: template, - autoShow: true, - }); - }, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.ceph.CephInstallWizardInfo', { - extend: 'Ext.panel.Panel', - xtype: 'pveCephInstallWizardInfo', - - html: `

    Ceph?

    -

    "Ceph is a unified, - distributed storage system, designed for excellent performance, reliability, - and scalability."

    -

    - Ceph is currently not installed on this node. This wizard - will guide you through the installation. Click on the next button below - to begin. After the initial installation, the wizard will offer to create - an initial configuration. This configuration step is only - needed once per cluster and will be skipped if a config is already present. -

    -

    - Before starting the installation, please take a look at our documentation, - by clicking the help button below. If you want to gain deeper knowledge about - Ceph, visit ceph.com. -

    `, -}); - -Ext.define('PVE.ceph.CephVersionSelector', { - extend: 'Ext.form.field.ComboBox', - xtype: 'pveCephVersionSelector', - - fieldLabel: gettext('Ceph version to install'), - - displayField: 'display', - valueField: 'release', - - queryMode: 'local', - editable: false, - forceSelection: true, - - store: { - fields: [ - 'release', - 'version', - { - name: 'display', - calculate: (d) => `${d.release} (${d.version})`, - }, - ], - proxy: { - type: 'memory', - reader: { - type: 'json', - }, - }, - data: [{ release: 'squid', version: '19.2' }], - }, -}); - -Ext.define('PVE.ceph.CephHighestVersionDisplay', { - extend: 'Ext.form.field.Display', - xtype: 'pveCephHighestVersionDisplay', - - fieldLabel: gettext('Ceph in the cluster'), - - value: 'unknown', - - // called on success with (release, versionTxt, versionParts) - gotNewestVersion: Ext.emptyFn, - - initComponent: function () { - let me = this; - - me.callParent(arguments); - - Proxmox.Utils.API2Request({ - method: 'GET', - url: '/cluster/ceph/metadata', - params: { - scope: 'versions', - }, - waitMsgTarget: me, - success: (response) => { - let res = response.result; - if (!res || !res.data || !res.data.node) { - me.setValue(gettext('Could not detect a ceph installation in the cluster')); - return; - } - let nodes = res.data.node; - if (me.nodename) { - // can happen on ceph purge, we do not yet cleanup old version data - delete nodes[me.nodename]; - } - - let maxversion = []; - let maxversiontext = ''; - for (const [_nodename, data] of Object.entries(nodes)) { - let version = data.version.parts; - if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) { - maxversion = version; - maxversiontext = data.version.str; - } - } - // FIXME: get from version selector store - const major2release = { - 13: 'luminous', - 14: 'nautilus', - 15: 'octopus', - 16: 'pacific', - 17: 'quincy', - 18: 'reef', - 19: 'squid', - 20: 'tentacle', - }; - let release = major2release[maxversion[0]] || 'unknown'; - let newestVersionTxt = `${Ext.String.capitalize(release)} (${maxversiontext})`; - - if (release === 'unknown') { - me.setValue(gettext('Could not detect a ceph installation in the cluster')); - } else { - me.setValue( - Ext.String.format( - gettext('Newest ceph version in cluster is {0}'), - newestVersionTxt, - ), - ); - } - me.gotNewestVersion(release, maxversiontext, maxversion); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, -}); - -Ext.define('PVE.ceph.CephInstallWizard', { - extend: 'PVE.window.Wizard', - alias: 'widget.pveCephInstallWizard', - mixins: ['Proxmox.Mixin.CBind'], - - resizable: false, - nodename: undefined, - - width: 760, // 4:3 - height: 570, - - viewModel: { - data: { - nodename: '', - cephRelease: 'squid', // default - cephRepo: 'enterprise', - configuration: true, - isInstalled: false, - nodeHasSubscription: true, // avoid warning hint until fully loaded - allHaveSubscription: true, // avoid warning hint until fully loaded - selectedReleaseIsTechPreview: false, // avoid warning hint until fully loaded - }, - formulas: { - repoHintHidden: (get) => get('allHaveSubscription') && get('cephRepo') === 'enterprise', - repoHint: function (get) { - let repo = get('cephRepo'); - let nodeSub = get('nodeHasSubscription'), - allSub = get('allHaveSubscription'); - - if (repo === 'enterprise') { - if (!nodeSub) { - return gettext( - 'The enterprise repository is enabled, but there is no active subscription!', - ); - } else if (!allSub) { - return gettext( - 'Not all nodes have an active subscription, which is required for cluster-wide enterprise repo access', - ); - } - return ''; // should be hidden - } else if (repo === 'no-subscription') { - return allSub - ? gettext( - 'Cluster has active subscriptions and would be eligible for using the enterprise repository.', - ) - : gettext( - 'The no-subscription repository is not the best choice for production setups.', - ); - } else if (repo === 'manual') { - return gettext( - 'The manual repository option expects that the repository is already configured. For example, in combination with the Proxmox Offline Mirror.', - ); - } else { - return gettext( - 'The test repository should only be used for test setups or after consulting the official Proxmox support!', - ); - } - }, - }, - }, - cbindData: { - nodename: undefined, - }, - - title: gettext('Setup'), - navigateNext: function () { - var tp = this.down('#wizcontent'); - var atab = tp.getActiveTab(); - - var next = tp.items.indexOf(atab) + 1; - var ntab = tp.items.getAt(next); - if (ntab) { - ntab.enable(); - tp.setActiveTab(ntab); - } - }, - setInitialTab: function (index) { - var tp = this.down('#wizcontent'); - var initialTab = tp.items.getAt(index); - initialTab.enable(); - tp.setActiveTab(initialTab); - }, - onShow: function () { - this.callParent(arguments); - let viewModel = this.getViewModel(); - var isInstalled = this.getViewModel().get('isInstalled'); - if (isInstalled) { - viewModel.set('configuration', false); - this.setInitialTab(2); - } - - PVE.Utils.getClusterSubscriptionLevel().then((subcriptionMap) => { - viewModel.set('nodeHasSubscription', !!subcriptionMap[this.nodename]); - - let allHaveSubscription = Object.values(subcriptionMap).every((level) => !!level); - viewModel.set('allHaveSubscription', allHaveSubscription); - }); - }, - items: [ - { - xtype: 'panel', - title: gettext('Info'), - viewModel: {}, // needed to inherit parent viewModel data - border: false, - bodyBorder: false, - onlineHelp: 'chapter_pveceph', - layout: { - type: 'vbox', - align: 'stretch', - }, - defaults: { - border: false, - bodyBorder: false, - }, - items: [ - { - xtype: 'pveCephInstallWizardInfo', - }, - { - flex: 1, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Hint'), - labelClsExtra: 'pmx-hint', - submitValue: false, - labelWidth: 50, - bind: { - value: '{repoHint}', - hidden: '{repoHintHidden}', - }, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Note'), - labelClsExtra: 'pmx-hint', - submitValue: false, - labelWidth: 50, - value: gettext( - 'The selected release is currently considered a Technology Preview. Although we are not aware of any major issues, there may be some bugs and the Enterprise Repository is not yet available.', - ), - bind: { - hidden: '{!selectedReleaseIsTechPreview}', - }, - }, - { - xtype: 'pveCephHighestVersionDisplay', - labelWidth: 150, - cbind: { - nodename: '{nodename}', - }, - gotNewestVersion: function (release, maxversiontext, maxversion) { - if (release === 'unknown') { - return; - } - let wizard = this.up('pveCephInstallWizard'); - wizard.getViewModel().set('cephRelease', release); - }, - }, - { - xtype: 'container', - layout: 'hbox', - defaults: { - border: false, - layout: 'anchor', - flex: 1, - }, - items: [ - { - xtype: 'pveCephVersionSelector', - labelWidth: 150, - padding: '0 10 0 0', - submitValue: false, - bind: { - value: '{cephRelease}', - }, - listeners: { - change: function (field, release) { - let me = this; - let wizard = this.up('pveCephInstallWizard'); - wizard - .down('#next') - .setText( - Ext.String.format( - gettext('Start {0} installation'), - release, - ), - ); - - let record = me.store.findRecord( - 'release', - release, - 0, - false, - true, - true, - ); - let releaseIsTechPreview = !!record.data.preview; - wizard - .getViewModel() - .set('selectedReleaseIsTechPreview', releaseIsTechPreview); - - let repoSelector = wizard.down('#repoSelector'); - if (releaseIsTechPreview) { - repoSelector.store.filterBy( - (entry) => entry.get('key') !== 'enterprise', - ); - } else { - repoSelector.store.clearFilter(); - } - }, - }, - }, - { - xtype: 'proxmoxKVComboBox', - id: 'repoSelector', // TODO: use name or reference (how to lookup that here?) - fieldLabel: gettext('Repository'), - padding: '0 0 0 10', - comboItems: [ - ['enterprise', gettext('Enterprise (recommended)')], - ['no-subscription', gettext('No-Subscription')], - ['test', gettext('Test')], - ['manual', gettext('Manual')], - ], - labelWidth: 150, - submitValue: false, - value: 'enterprise', - bind: { - value: '{cephRepo}', - }, - }, - ], - }, - ], - listeners: { - activate: function () { - // notify owning container that it should display a help button - if (this.onlineHelp) { - Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp); - } - let wizard = this.up('pveCephInstallWizard'); - let release = wizard.getViewModel().get('cephRelease'); - wizard.down('#back').hide(true); - wizard - .down('#next') - .setText(Ext.String.format(gettext('Start {0} installation'), release)); - }, - deactivate: function () { - if (this.onlineHelp) { - Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp); - } - this.up('pveCephInstallWizard').down('#next').setText(gettext('Next')); - }, - }, - }, - { - title: gettext('Installation'), - xtype: 'panel', - layout: 'fit', - cbind: { - nodename: '{nodename}', - }, - viewModel: {}, // needed to inherit parent viewModel data - listeners: { - afterrender: function () { - var me = this; - if (this.getViewModel().get('isInstalled')) { - this.mask( - 'Ceph is already installed, click next to create your configuration.', - ['pve-static-mask'], - ); - } else { - me.down('pveNoVncConsole').fireEvent('activate'); - } - }, - activate: function () { - let me = this; - const nodename = me.nodename; - me.updateStore = Ext.create('Proxmox.data.UpdateStore', { - storeid: 'ceph-status-' + nodename, - interval: 1000, - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/' + nodename + '/ceph/status', - }, - listeners: { - load: function (rec, response, success, operation) { - if (success) { - me.updateStore.stopUpdate(); - me.down('textfield').setValue('success'); - } else if ( - operation.error.statusText.match('not initialized', 'i') - ) { - me.updateStore.stopUpdate(); - me.up('pveCephInstallWizard') - .getViewModel() - .set('configuration', false); - me.down('textfield').setValue('success'); - } else if ( - operation.error.statusText.match('rados_connect failed', 'i') - ) { - me.updateStore.stopUpdate(); - me.up('pveCephInstallWizard') - .getViewModel() - .set('configuration', true); - me.down('textfield').setValue('success'); - } else if ( - !operation.error.statusText.match('not installed', 'i') - ) { - let msg = Ext.htmlEncode(operation.error.statusText); - Proxmox.Utils.setErrorMask(me, msg); - } - }, - }, - }); - me.updateStore.startUpdate(); - }, - destroy: function () { - var me = this; - if (me.updateStore) { - me.updateStore.stopUpdate(); - } - }, - }, - items: [ - { - xtype: 'pveNoVncConsole', - itemId: 'jsconsole', - consoleType: 'cmd', - xtermjs: true, - cbind: { - nodename: '{nodename}', - }, - beforeLoad: function () { - let me = this; - let wizard = me.up('pveCephInstallWizard'); - let release = wizard.getViewModel().get('cephRelease'); - let repo = wizard.getViewModel().get('cephRepo'); - me.cmdOpts = `--version\0${release}\0--repository\0${repo}`; - }, - cmd: 'ceph_install', - }, - { - xtype: 'textfield', - name: 'installSuccess', - value: '', - allowBlank: false, - submitValue: false, - hidden: true, - }, - ], - }, - { - xtype: 'inputpanel', - title: gettext('Configuration'), - onlineHelp: 'chapter_pveceph', - height: 300, - cbind: { - nodename: '{nodename}', - }, - viewModel: { - data: { - replicas: undefined, - minreplicas: undefined, - }, - }, - listeners: { - activate: function () { - this.up('pveCephInstallWizard').down('#submit').setText(gettext('Next')); - }, - afterrender: function () { - if (this.up('pveCephInstallWizard').getViewModel().get('configuration')) { - this.mask('Configuration already initialized', ['pve-static-mask']); - } else { - this.unmask(); - } - }, - deactivate: function () { - this.up('pveCephInstallWizard').down('#submit').setText(gettext('Finish')); - }, - }, - column1: [ - { - xtype: 'displayfield', - value: gettext('Ceph cluster configuration') + ':', - }, - { - xtype: 'proxmoxNetworkSelector', - name: 'network', - value: '', - fieldLabel: 'Public Network IP/CIDR', - autoSelect: false, - type: 'include_sdn', - bind: { - allowBlank: '{configuration}', - }, - cbind: { - nodename: '{nodename}', - }, - }, - { - xtype: 'proxmoxNetworkSelector', - name: 'cluster-network', - fieldLabel: 'Cluster Network IP/CIDR', - allowBlank: true, - autoSelect: false, - type: 'include_sdn', - emptyText: gettext('Same as Public Network'), - cbind: { - nodename: '{nodename}', - }, - }, - // FIXME: add hint about cluster network and/or reference user to docs?? - ], - column2: [ - { - xtype: 'displayfield', - value: gettext('First Ceph monitor') + ':', - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Monitor node'), - cbind: { - value: '{nodename}', - }, - }, - { - xtype: 'displayfield', - value: gettext( - 'Additional monitors are recommended. They can be created at any time in the Monitor tab.', - ), - userCls: 'pmx-hint', - }, - ], - advancedColumn1: [ - { - xtype: 'numberfield', - name: 'size', - fieldLabel: 'Number of replicas', - bind: { - value: '{replicas}', - }, - maxValue: 7, - minValue: 2, - emptyText: '3', - }, - { - xtype: 'numberfield', - name: 'min_size', - fieldLabel: 'Minimum replicas', - bind: { - maxValue: '{replicas}', - value: '{minreplicas}', - }, - minValue: 2, - maxValue: 3, - setMaxValue: function (value) { - this.maxValue = Ext.Number.from(value, 2); - // allow enough to avoid split brains with max 'size', but more makes simply no sense - if (this.maxValue > 4) { - this.maxValue = 4; - } - this.toggleSpinners(); - this.validate(); - }, - emptyText: '2', - }, - ], - onGetValues: function (values) { - ['cluster-network', 'size', 'min_size'].forEach(function (field) { - if (!values[field]) { - delete values[field]; - } - }); - return values; - }, - onSubmit: function () { - var me = this; - if (!this.up('pveCephInstallWizard').getViewModel().get('configuration')) { - let wizard = me.up('window'); - let kv = wizard.getValues(); - delete kv.delete; - let nodename = me.nodename; - delete kv.nodename; - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/ceph/init`, - waitMsgTarget: wizard, - method: 'POST', - params: kv, - success: function () { - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/ceph/mon/${nodename}`, - waitMsgTarget: wizard, - method: 'POST', - success: function () { - me.up('pveCephInstallWizard').navigateNext(); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - } else { - me.up('pveCephInstallWizard').navigateNext(); - } - }, - }, - { - title: gettext('Success'), - xtype: 'panel', - border: false, - bodyBorder: false, - onlineHelp: 'pve_ceph_install', - html: - '

    Installation successful!

    ' + - '

    The basic installation and configuration is complete. Depending on your setup, some of the following steps are required to start using Ceph:

    ' + - '
    1. Install Ceph on other nodes
    2. ' + - '
    3. Create additional Ceph Monitors
    4. ' + - '
    5. Create Ceph OSDs
    6. ' + - '
    7. Create Ceph Pools
    ' + - '

    To learn more, click on the help button below.

    ', - listeners: { - activate: function () { - // notify owning container that it should display a help button - if (this.onlineHelp) { - Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp); - } - - var tp = this.up('#wizcontent'); - var idx = tp.items.indexOf(this) - 1; - for (; idx >= 0; idx--) { - let nc = tp.items.getAt(idx); - if (nc) { - nc.disable(); - } - } - }, - deactivate: function () { - if (this.onlineHelp) { - Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp); - } - }, - }, - onSubmit: function () { - var wizard = this.up('pveCephInstallWizard'); - wizard.close(); - }, - }, - ], -}); -Ext.define('PVE.node.CephConfigDb', { - extend: 'Ext.grid.Panel', - alias: 'widget.pveNodeCephConfigDb', - - border: false, - store: { - proxy: { - type: 'proxmox', - }, - }, - - columns: [ - { - dataIndex: 'section', - text: 'WHO', - width: 100, - renderer: Ext.htmlEncode, - }, - { - dataIndex: 'mask', - text: 'MASK', - hidden: true, - width: 80, - renderer: Ext.htmlEncode, - }, - { - dataIndex: 'level', - hidden: true, - text: 'LEVEL', - renderer: Ext.htmlEncode, - }, - { - dataIndex: 'name', - flex: 1, - text: 'OPTION', - renderer: Ext.htmlEncode, - }, - { - dataIndex: 'value', - flex: 1, - text: 'VALUE', - renderer: Ext.htmlEncode, - }, - { - dataIndex: 'can_update_at_runtime', - text: 'Runtime Updatable', - hidden: true, - width: 80, - renderer: Proxmox.Utils.format_boolean, - }, - ], - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.store.proxy.url = '/api2/json/nodes/' + nodename + '/ceph/cfg/db'; - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.getStore()); - me.getStore().load(); - }, -}); -Ext.define('PVE.node.CephConfig', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveNodeCephConfig', - - bodyStyle: 'white-space:pre', - bodyPadding: 5, - border: false, - scrollable: true, - load: function () { - var me = this; - - Proxmox.Utils.API2Request({ - url: me.url, - waitMsgTarget: me, - failure: function (response, opts) { - me.update(gettext('Error') + ' ' + response.htmlStatus); - var msg = response.htmlStatus; - PVE.Utils.showCephInstallOrMask( - me.ownerCt, - msg, - me.pveSelNode.data.node, - function (win) { - me.mon(win, 'cephInstallWindowClosed', function () { - me.load(); - }); - }, - ); - }, - success: function (response, opts) { - var data = response.result.data; - me.update(Ext.htmlEncode(data)); - }, - }); - }, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - Ext.apply(me, { - url: '/nodes/' + nodename + '/ceph/cfg/raw', - listeners: { - activate: function () { - me.load(); - }, - }, - }); - - me.callParent(); - - me.load(); - }, -}); - -Ext.define('PVE.node.CephConfigCrush', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveNodeCephConfigCrush', - - onlineHelp: 'chapter_pveceph', - - layout: 'border', - items: [ - { - title: gettext('Configuration'), - xtype: 'pveNodeCephConfig', - region: 'center', - }, - { - title: 'Crush Map', // do not localize - xtype: 'pveNodeCephCrushMap', - region: 'east', - split: true, - width: '50%', - }, - { - title: gettext('Configuration Database'), - xtype: 'pveNodeCephConfigDb', - region: 'south', - split: true, - weight: -30, - height: '50%', - }, - ], - - initComponent: function () { - var me = this; - me.defaults = { - pveSelNode: me.pveSelNode, - }; - me.callParent(); - }, -}); -Ext.define('PVE.node.CephCrushMap', { - extend: 'Ext.panel.Panel', - alias: ['widget.pveNodeCephCrushMap'], - bodyStyle: 'white-space:pre', - bodyPadding: 5, - border: false, - stateful: true, - stateId: 'layout-ceph-crush', - scrollable: true, - load: function () { - var me = this; - - Proxmox.Utils.API2Request({ - url: me.url, - waitMsgTarget: me, - failure: function (response, opts) { - me.update(gettext('Error') + ' ' + response.htmlStatus); - var msg = response.htmlStatus; - PVE.Utils.showCephInstallOrMask(me.ownerCt, msg, me.pveSelNode.data.node, (win) => - me.mon(win, 'cephInstallWindowClosed', () => me.load()), - ); - }, - success: function (response, opts) { - var data = response.result.data; - me.update(Ext.htmlEncode(data)); - }, - }); - }, - - initComponent: function () { - let me = this; - - let nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - Ext.apply(me, { - url: `/nodes/${nodename}/ceph/crush`, - listeners: { - activate: () => me.load(), - }, - }); - - me.callParent(); - - me.load(); - }, -}); -Ext.define('PVE.CephCreateFS', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveCephCreateFS', - - showTaskViewer: true, - onlineHelp: 'pveceph_fs_create', - - subject: 'Ceph FS', - isCreate: true, - method: 'POST', - - setFSName: function (fsName) { - var me = this; - - if (fsName === '' || fsName === undefined) { - fsName = 'cephfs'; - } - - me.url = '/nodes/' + me.nodename + '/ceph/fs/' + fsName; - }, - - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('Name'), - name: 'name', - value: 'cephfs', - listeners: { - change: function (f, value) { - this.up('pveCephCreateFS').setFSName(value); - }, - }, - submitValue: false, // already encoded in apicall URL - emptyText: 'cephfs', - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: 'Placement Groups', - name: 'pg_num', - value: 128, - emptyText: 128, - minValue: 8, - maxValue: 32768, - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Add as Storage'), - value: true, - name: 'add-storage', - autoEl: { - tag: 'div', - 'data-qtip': gettext('Add the new CephFS to the cluster storage configuration.'), - }, - }, - ], - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - me.setFSName(); - - me.callParent(); - }, -}); - -Ext.define( - 'PVE.NodeCephFSPanel', - { - extend: 'Ext.panel.Panel', - xtype: 'pveNodeCephFSPanel', - mixins: ['Proxmox.Mixin.CBind'], - - title: gettext('CephFS'), - onlineHelp: 'pveceph_fs', - - border: false, - defaults: { - border: false, - cbind: { - nodename: '{nodename}', - }, - }, - - viewModel: { - parent: null, - data: { - mdsCount: 0, - }, - formulas: { - canCreateFS: function (get) { - return get('mdsCount') > 0; - }, - }, - }, - - items: [ - { - xtype: 'grid', - emptyText: Ext.String.format(gettext('No {0} configured.'), 'CephFS'), - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - view.rstore = Ext.create('Proxmox.data.UpdateStore', { - autoLoad: true, - xtype: 'update', - interval: 5 * 1000, - autoStart: true, - storeid: 'pve-ceph-fs', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${view.nodename}/ceph/fs`, - }, - model: 'pve-ceph-fs', - }); - view.setStore( - Ext.create('Proxmox.data.DiffStore', { - rstore: view.rstore, - sorters: { - property: 'name', - direction: 'ASC', - }, - }), - ); - // manages the "install ceph?" overlay - PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true); - view.on('destroy', () => view.rstore.stopUpdate()); - }, - - onCreate: function () { - let view = this.getView(); - view.rstore.stopUpdate(); - Ext.create('PVE.CephCreateFS', { - autoShow: true, - nodename: view.nodename, - listeners: { - destroy: () => view.rstore.startUpdate(), - }, - }); - }, - }, - tbar: [ - { - text: gettext('Create CephFS'), - reference: 'createButton', - handler: 'onCreate', - bind: { - disabled: '{!canCreateFS}', - }, - }, - ], - columns: [ - { - header: gettext('Name'), - flex: 1, - dataIndex: 'name', - renderer: Ext.htmlEncode, - }, - { - header: gettext('Data Pool'), - flex: 1, - dataIndex: 'data_pool', - renderer: Ext.htmlEncode, - }, - { - header: gettext('Metadata Pool'), - flex: 1, - dataIndex: 'metadata_pool', - renderer: Ext.htmlEncode, - }, - ], - cbind: { - nodename: '{nodename}', - }, - }, - { - xtype: 'pveNodeCephMDSList', - title: gettext('Metadata Servers'), - stateId: 'grid-ceph-mds', - type: 'mds', - storeLoadCallback: function (store, records, success) { - var vm = this.getViewModel(); - if (!success || !records) { - vm.set('mdsCount', 0); - return; - } - let count = 0; - for (const mds of records) { - if (mds.data.state === 'up:standby') { - count++; - } - } - vm.set('mdsCount', count); - }, - cbind: { - nodename: '{nodename}', - }, - }, - ], - }, - function () { - Ext.define('pve-ceph-fs', { - extend: 'Ext.data.Model', - fields: ['name', 'data_pool', 'metadata_pool'], - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/localhost/ceph/fs', - }, - idProperty: 'name', - }); - }, -); -Ext.define('PVE.ceph.Log', { - extend: 'Proxmox.panel.LogView', - xtype: 'cephLogView', - - nodename: undefined, - - failCallback: function (response) { - var me = this; - var msg = response.htmlStatus; - var windowShow = PVE.Utils.showCephInstallOrMask(me, msg, me.nodename, function (win) { - me.mon(win, 'cephInstallWindowClosed', function () { - me.loadTask.delay(200); - }); - }); - if (!windowShow) { - Proxmox.Utils.setErrorMask(me, msg); - } - }, -}); -Ext.define('PVE.node.CephMonMgrList', { - extend: 'Ext.container.Container', - xtype: 'pveNodeCephMonMgr', - - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'chapter_pveceph', - - defaults: { - border: false, - onlineHelp: 'chapter_pveceph', - flex: 1, - }, - - layout: { - type: 'vbox', - align: 'stretch', - }, - - items: [ - { - xtype: 'pveNodeCephServiceList', - cbind: { pveSelNode: '{pveSelNode}' }, - type: 'mon', - additionalColumns: [ - { - header: gettext('Quorum'), - width: 70, - sortable: true, - renderer: Proxmox.Utils.format_boolean, - dataIndex: 'quorum', - }, - ], - stateId: 'grid-ceph-monitor', - showCephInstallMask: true, - title: gettext('Monitor'), - }, - { - xtype: 'pveNodeCephServiceList', - type: 'mgr', - stateId: 'grid-ceph-manager', - cbind: { pveSelNode: '{pveSelNode}' }, - title: gettext('Manager'), - }, - ], -}); -Ext.define('PVE.CephCreateOsd', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCephCreateOsd', - - subject: 'Ceph OSD', - - showProgress: true, - - onlineHelp: 'pve_ceph_osds', - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.isCreate = true; - - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/ceph/crush`, - method: 'GET', - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function ({ result: { data } }) { - let classes = [ - ...new Set( - Array.from( - data.matchAll(/^device\s[0-9]*\sosd\.[0-9]*\sclass\s(.*)$/gim), - (m) => m[1], - ).filter((v) => !['hdd', 'ssd', 'nvme'].includes(v)), - ), - ].map((v) => [v, v]); - - if (classes.length) { - let kvField = me.down('field[name=crush-device-class]'); - kvField.setComboItems([...kvField.comboItems, ...classes]); - } - }, - }); - - Ext.applyIf(me, { - url: '/nodes/' + me.nodename + '/ceph/osd', - method: 'POST', - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - Object.keys(values || {}).forEach(function (name) { - if (values[name] === '') { - delete values[name]; - } - }); - - return values; - }, - column1: [ - { - xtype: 'pmxDiskSelector', - name: 'dev', - nodename: me.nodename, - diskType: 'unused', - includePartitions: true, - fieldLabel: gettext('Disk'), - allowBlank: false, - }, - ], - column2: [ - { - xtype: 'pmxDiskSelector', - name: 'db_dev', - nodename: me.nodename, - diskType: 'journal_disks', - includePartitions: true, - fieldLabel: gettext('DB Disk'), - value: '', - autoSelect: false, - allowBlank: true, - emptyText: gettext('use OSD disk'), - listeners: { - change: function (field, val) { - me.down('field[name=db_dev_size]').setDisabled(!val); - }, - }, - }, - { - xtype: 'numberfield', - name: 'db_dev_size', - fieldLabel: `${gettext('DB size')} (${gettext('GiB')})`, - minValue: 1, - maxValue: 128 * 1024, - decimalPrecision: 2, - allowBlank: true, - disabled: true, - emptyText: gettext('Automatic'), - }, - ], - advancedColumn1: [ - { - xtype: 'proxmoxcheckbox', - name: 'encrypted', - fieldLabel: gettext('Encrypt OSD'), - }, - { - xtype: 'proxmoxKVComboBox', - comboItems: [ - ['hdd', 'HDD'], - ['ssd', 'SSD'], - ['nvme', 'NVMe'], - ], - name: 'crush-device-class', - nodename: me.nodename, - fieldLabel: gettext('Device Class'), - value: '', - autoSelect: false, - allowBlank: true, - editable: true, - emptyText: gettext('auto detect'), - deleteEmpty: !me.isCreate, - }, - ], - advancedColumn2: [ - { - xtype: 'pmxDiskSelector', - name: 'wal_dev', - nodename: me.nodename, - diskType: 'journal_disks', - includePartitions: true, - fieldLabel: gettext('WAL Disk'), - value: '', - autoSelect: false, - allowBlank: true, - emptyText: gettext('use OSD/DB disk'), - listeners: { - change: function (field, val) { - me.down('field[name=wal_dev_size]').setDisabled(!val); - }, - }, - }, - { - xtype: 'numberfield', - name: 'wal_dev_size', - fieldLabel: `${gettext('WAL size')} (${gettext('GiB')})`, - minValue: 0.5, - maxValue: 128 * 1024, - decimalPrecision: 2, - allowBlank: true, - disabled: true, - emptyText: gettext('Automatic'), - }, - ], - }, - { - xtype: 'displayfield', - padding: '5 0 0 0', - userCls: 'pmx-hint', - value: - 'Note: Ceph is not compatible with disks backed by a hardware ' + - 'RAID controller. For details see ' + - 'the reference documentation.', - }, - ], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.CephRemoveOsd', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveCephRemoveOsd'], - - isRemove: true, - - showProgress: true, - method: 'DELETE', - items: [ - { - xtype: 'proxmoxcheckbox', - name: 'cleanup', - checked: true, - labelWidth: 130, - fieldLabel: gettext('Cleanup Disks'), - }, - { - xtype: 'displayfield', - name: 'osd-flag-hint', - userCls: 'pmx-hint', - value: gettext('Global flags limiting the self healing of Ceph are enabled.'), - hidden: true, - }, - { - xtype: 'displayfield', - name: 'degraded-objects-hint', - userCls: 'pmx-hint', - value: gettext('Objects are degraded. Consider waiting until the cluster is healthy.'), - hidden: true, - }, - ], - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - if (me.osdid === undefined || me.osdid < 0) { - throw 'no osdid specified'; - } - - me.isCreate = true; - - me.title = gettext('Destroy') + ': Ceph OSD osd.' + me.osdid.toString(); - - Ext.applyIf(me, { - url: '/nodes/' + me.nodename + '/ceph/osd/' + me.osdid.toString(), - }); - - me.callParent(); - - if (me.warnings.flags) { - me.down('field[name=osd-flag-hint]').setHidden(false); - } - if (me.warnings.degraded) { - me.down('field[name=degraded-objects-hint]').setHidden(false); - } - }, -}); - -Ext.define('PVE.CephSetFlags', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCephSetFlags', - - showProgress: true, - - width: 720, - layout: 'fit', - - onlineHelp: 'pve_ceph_osds', - isCreate: true, - title: gettext('Manage Global OSD Flags'), - submitText: gettext('Apply'), - - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - let me = this; - let val = {}; - me.down('#flaggrid') - .getStore() - .each((rec) => { - val[rec.data.name] = rec.data.value ? 1 : 0; - }); - - return val; - }, - items: [ - { - xtype: 'grid', - itemId: 'flaggrid', - store: { - listeners: { - update: function () { - this.commitChanges(); - }, - }, - }, - - columns: [ - { - text: gettext('Enable'), - xtype: 'checkcolumn', - width: 75, - dataIndex: 'value', - }, - { - text: 'Name', - dataIndex: 'name', - }, - { - text: 'Description', - flex: 1, - dataIndex: 'description', - renderer: Ext.htmlEncode, - }, - ], - }, - ], - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - Ext.applyIf(me, { - url: '/cluster/ceph/flags', - method: 'PUT', - }); - - me.callParent(); - - let grid = me.down('#flaggrid'); - me.load({ - success: function (response, options) { - let data = response.result.data; - grid.getStore().setData(data); - // re-align after store load, else the window is not centered - me.alignTo(Ext.getBody(), 'c-c'); - }, - }); - }, -}); - -Ext.define('PVE.node.CephOsdTree', { - extend: 'Ext.tree.Panel', - alias: ['widget.pveNodeCephOsdTree'], - onlineHelp: 'chapter_pveceph', - - viewModel: { - data: { - nodename: '', - flags: [], - maxversion: '0', - mixedversions: false, - versions: {}, - isOsd: false, - downOsd: false, - upOsd: false, - inOsd: false, - outOsd: false, - osdid: '', - osdhost: '', - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - reload: function () { - let me = this; - let view = me.getView(); - let vm = me.getViewModel(); - let nodename = vm.get('nodename'); - let sm = view.getSelectionModel(); - Proxmox.Utils.API2Request({ - url: '/nodes/' + nodename + '/ceph/osd', - waitMsgTarget: view, - method: 'GET', - failure: function (response, opts) { - let msg = response.htmlStatus; - PVE.Utils.showCephInstallOrMask(view, msg, nodename, (win) => - view.mon(win, 'cephInstallWindowClosed', () => { - me.reload(); - }), - ); - }, - success: function (response, opts) { - let data = response.result.data; - let selected = view.getSelection(); - let name; - if (selected.length) { - name = selected[0].data.name; - } - data.versions = data.versions || {}; - vm.set('versions', data.versions); - // extract max version - let maxversion = '0'; - let mixedversions = false; - let traverse; - traverse = function (node, fn) { - fn(node); - if (Array.isArray(node.children)) { - node.children.forEach((c) => { - traverse(c, fn); - }); - } - }; - traverse(data.root, (node) => { - // compatibility for old api call - if (node.type === 'host' && !node.version) { - node.version = data.versions[node.name]; - } - - if (node.version === undefined) { - return; - } - - if ( - PVE.Utils.compare_ceph_versions(node.version, maxversion) !== 0 && - maxversion !== '0' - ) { - mixedversions = true; - } - - if (PVE.Utils.compare_ceph_versions(node.version, maxversion) > 0) { - maxversion = node.version; - } - }); - vm.set('maxversion', maxversion); - vm.set('mixedversions', mixedversions); - sm.deselectAll(); - view.setRootNode(data.root); - view.expandAll(); - if (name) { - let node = view.getRootNode().findChild('name', name, true); - if (node) { - view.setSelection([node]); - } - } - - let flags = data.flags.split(','); - vm.set('flags', flags); - }, - }); - }, - - osd_cmd: function (comp) { - let me = this; - let vm = this.getViewModel(); - let cmd = comp.cmd; - let params = comp.params || {}; - let osdid = vm.get('osdid'); - - let doRequest = function () { - let targetnode = vm.get('osdhost'); - // cmds not node specific and need to work if the OSD node is down - if (['in', 'out'].includes(cmd)) { - targetnode = vm.get('nodename'); - } - Proxmox.Utils.API2Request({ - url: `/nodes/${targetnode}/ceph/osd/${osdid}/${cmd}`, - waitMsgTarget: me.getView(), - method: 'POST', - params: params, - success: () => { - me.reload(); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }; - - if (cmd === 'scrub') { - Ext.MessageBox.defaultButton = params.deep === 1 ? 2 : 1; - Ext.Msg.show({ - title: gettext('Confirm'), - icon: params.deep === 1 ? Ext.Msg.WARNING : Ext.Msg.QUESTION, - msg: - params.deep !== 1 - ? Ext.String.format(gettext('Scrub OSD.{0}'), osdid) - : Ext.String.format(gettext('Deep Scrub OSD.{0}'), osdid) + - '
    Caution: This can reduce performance while it is running.', - buttons: Ext.Msg.YESNO, - callback: function (btn) { - if (btn !== 'yes') { - return; - } - doRequest(); - }, - }); - } else { - doRequest(); - } - }, - - create_osd: function () { - let me = this; - let vm = this.getViewModel(); - Ext.create('PVE.CephCreateOsd', { - nodename: vm.get('nodename'), - taskDone: () => { - me.reload(); - }, - }).show(); - }, - - destroy_osd: async function () { - let me = this; - let vm = this.getViewModel(); - - let warnings = { - flags: false, - degraded: false, - }; - - let flagsPromise = Proxmox.Async.api2({ - url: `/cluster/ceph/flags`, - method: 'GET', - }); - - let statusPromise = Proxmox.Async.api2({ - url: `/cluster/ceph/status`, - method: 'GET', - }); - - me.getView().mask(gettext('Loading...')); - - try { - let result = await Promise.all([flagsPromise, statusPromise]); - - let flagsData = result[0].result.data; - let statusData = result[1].result.data; - - let flags = Array.from( - flagsData.filter((v) => v.value), - (v) => v.name, - ).filter((v) => ['norebalance', 'norecover', 'noout'].includes(v)); - - if (flags.length) { - warnings.flags = true; - } - if (Object.keys(statusData.pgmap).includes('degraded_objects')) { - warnings.degraded = true; - } - } catch (error) { - Ext.Msg.alert(gettext('Error'), error.htmlStatus); - me.getView().unmask(); - return; - } - - me.getView().unmask(); - Ext.create('PVE.CephRemoveOsd', { - nodename: vm.get('osdhost'), - osdid: vm.get('osdid'), - warnings: warnings, - taskDone: () => { - me.reload(); - }, - autoShow: true, - }); - }, - - set_flags: function () { - let me = this; - let vm = this.getViewModel(); - Ext.create('PVE.CephSetFlags', { - nodename: vm.get('nodename'), - taskDone: () => { - me.reload(); - }, - }).show(); - }, - - service_cmd: function (comp) { - let me = this; - let vm = this.getViewModel(); - let cmd = comp.cmd || comp; - - let doRequest = function () { - Proxmox.Utils.API2Request({ - url: `/nodes/${vm.get('osdhost')}/ceph/${cmd}`, - params: { service: 'osd.' + vm.get('osdid') }, - waitMsgTarget: me.getView(), - method: 'POST', - success: function (response, options) { - let upid = response.result.data; - let win = Ext.create('Proxmox.window.TaskProgress', { - upid: upid, - taskDone: () => { - me.reload(); - }, - }); - win.show(); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }; - - if (cmd === 'stop') { - Proxmox.Utils.API2Request({ - url: `/nodes/${vm.get('osdhost')}/ceph/cmd-safety`, - params: { - service: 'osd', - id: vm.get('osdid'), - action: 'stop', - }, - waitMsgTarget: me.getView(), - method: 'GET', - success: function ({ result: { data } }) { - if (!data.safe) { - Ext.Msg.show({ - title: ngettext('Warning', 'Warnings', 1), - message: data.status, - icon: Ext.Msg.WARNING, - buttons: Ext.Msg.OKCANCEL, - buttonText: { ok: gettext('Stop OSD') }, - fn: function (selection) { - if (selection === 'ok') { - doRequest(); - } - }, - }); - } else { - doRequest(); - } - }, - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - } else { - doRequest(); - } - }, - - run_details: function (view, rec) { - if (rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0) { - this.details(); - } - }, - - details: function () { - let vm = this.getViewModel(); - Ext.create('PVE.CephOsdDetails', { - nodename: vm.get('osdhost'), - osdid: vm.get('osdid'), - }).show(); - }, - - set_selection_status: function (tp, selection) { - if (selection.length < 1) { - return; - } - let rec = selection[0]; - let vm = this.getViewModel(); - - let isOsd = rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0; - - vm.set('isOsd', isOsd); - vm.set('downOsd', isOsd && rec.data.status === 'down'); - vm.set('upOsd', isOsd && rec.data.status !== 'down'); - vm.set('inOsd', isOsd && rec.data.in); - vm.set('outOsd', isOsd && !rec.data.in); - vm.set('osdid', isOsd ? rec.data.id : undefined); - vm.set('osdhost', isOsd ? rec.data.host : undefined); - }, - - render_status: function (value, metaData, rec) { - if (!value) { - return value; - } - let inout = rec.data.in ? 'in' : 'out'; - let updownicon = - value === 'up' ? 'good fa-arrow-circle-up' : 'critical fa-arrow-circle-down'; - - let inouticon = rec.data.in ? 'good fa-circle' : 'warning fa-circle-o'; - - let text = - value + - ' / ' + - inout + - ' '; - - return text; - }, - - render_wal: function (value, metaData, rec) { - if (!value && rec.data.osdtype === 'bluestore' && rec.data.type === 'osd') { - return 'N/A'; - } - return value; - }, - - render_version: function (value, metadata, rec) { - let vm = this.getViewModel(); - let versions = vm.get('versions'); - let icon = ''; - let version = value || ''; - let maxversion = vm.get('maxversion'); - if (value && PVE.Utils.compare_ceph_versions(value, maxversion) !== 0) { - let host_version = rec.parentNode?.data?.version || versions[rec.data.host] || ''; - if ( - rec.data.type === 'host' || - PVE.Utils.compare_ceph_versions(host_version, maxversion) !== 0 - ) { - icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE'); - } else { - icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD'); - } - } else if (value && vm.get('mixedversions')) { - icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK'); - } - - return icon + version; - }, - - render_osd_val: function (value, metaData, rec) { - return rec.data.type === 'osd' ? value : ''; - }, - render_osd_weight: function (value, metaData, rec) { - if (rec.data.type !== 'osd') { - return ''; - } - return Ext.util.Format.number(value, '0.00###'); - }, - - render_osd_latency: function (value, metaData, rec) { - if (rec.data.type !== 'osd') { - return ''; - } - let commit_ms = rec.data.commit_latency_ms, - apply_ms = rec.data.apply_latency_ms; - return apply_ms + ' / ' + commit_ms; - }, - - render_osd_size: function (value, metaData, rec) { - return this.render_osd_val(Proxmox.Utils.render_size(value), metaData, rec); - }, - - control: { - '#': { - selectionchange: 'set_selection_status', - }, - }, - - init: function (view) { - let me = this; - let vm = this.getViewModel(); - - if (!view.pveSelNode.data.node) { - throw 'no node name specified'; - } - - vm.set('nodename', view.pveSelNode.data.node); - - me.callParent(); - me.reload(); - }, - }, - - stateful: true, - stateId: 'grid-ceph-osd', - rootVisible: false, - useArrows: true, - listeners: { - itemdblclick: 'run_details', - }, - - columns: [ - { - xtype: 'treecolumn', - text: 'Name', - dataIndex: 'name', - width: 150, - }, - { - text: 'Type', - dataIndex: 'type', - hidden: true, - align: 'right', - width: 75, - }, - { - text: gettext('Class'), - dataIndex: 'device_class', - align: 'right', - width: 75, - renderer: Ext.htmlEncode, - }, - { - text: 'OSD Type', - dataIndex: 'osdtype', - align: 'right', - width: 100, - }, - { - text: 'Bluestore Device', - dataIndex: 'blfsdev', - align: 'right', - width: 75, - hidden: true, - renderer: Ext.htmlEncode, - }, - { - text: 'DB Device', - dataIndex: 'dbdev', - align: 'right', - width: 75, - hidden: true, - renderer: Ext.htmlEncode, - }, - { - text: 'WAL Device', - dataIndex: 'waldev', - align: 'right', - renderer: 'render_wal', - width: 75, - hidden: true, - }, - { - text: 'Status', - dataIndex: 'status', - align: 'right', - renderer: 'render_status', - width: 120, - }, - { - text: gettext('Version'), - dataIndex: 'version', - align: 'right', - renderer: 'render_version', - }, - { - text: 'weight', - dataIndex: 'crush_weight', - align: 'right', - renderer: 'render_osd_weight', - width: 90, - }, - { - text: 'reweight', - dataIndex: 'reweight', - align: 'right', - renderer: 'render_osd_weight', - width: 90, - }, - { - text: gettext('Used') + ' (%)', - dataIndex: 'percent_used', - align: 'right', - renderer: function (value, metaData, rec) { - if (rec.data.type !== 'osd') { - return ''; - } - return Ext.util.Format.number(value, '0.00'); - }, - width: 100, - }, - { - text: gettext('Total'), - dataIndex: 'total_space', - align: 'right', - renderer: 'render_osd_size', - width: 100, - }, - { - text: 'Apply/Commit
    Latency (ms)', - dataIndex: 'apply_latency_ms', - align: 'right', - renderer: 'render_osd_latency', - width: 120, - }, - { - text: 'PGs', - dataIndex: 'pgs', - align: 'right', - renderer: 'render_osd_val', - width: 90, - }, - ], - - tbar: { - items: [ - { - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: 'reload', - }, - '-', - { - text: gettext('Create: OSD'), - handler: 'create_osd', - }, - { - text: gettext('Manage Global Flags'), - handler: 'set_flags', - }, - '->', - { - xtype: 'tbtext', - data: { - osd: undefined, - }, - bind: { - data: { - osd: '{osdid}', - }, - }, - tpl: [ - '', - 'osd.{osd}:', - '', - gettext('No OSD selected'), - '', - ], - }, - { - text: gettext('Details'), - iconCls: 'fa fa-info-circle', - disabled: true, - bind: { - disabled: '{!isOsd}', - }, - handler: 'details', - }, - { - text: gettext('Start'), - iconCls: 'fa fa-play', - disabled: true, - bind: { - disabled: '{!downOsd}', - }, - cmd: 'start', - handler: 'service_cmd', - }, - { - text: gettext('Stop'), - iconCls: 'fa fa-stop', - disabled: true, - bind: { - disabled: '{!upOsd}', - }, - cmd: 'stop', - handler: 'service_cmd', - }, - { - text: gettext('Restart'), - iconCls: 'fa fa-refresh', - disabled: true, - bind: { - disabled: '{!upOsd}', - }, - cmd: 'restart', - handler: 'service_cmd', - }, - '-', - { - text: 'Out', - iconCls: 'fa fa-circle-o', - disabled: true, - bind: { - disabled: '{!inOsd}', - }, - cmd: 'out', - handler: 'osd_cmd', - }, - { - text: 'In', - iconCls: 'fa fa-circle', - disabled: true, - bind: { - disabled: '{!outOsd}', - }, - cmd: 'in', - handler: 'osd_cmd', - }, - '-', - { - text: gettext('More'), - iconCls: 'fa fa-bars', - disabled: true, - bind: { - disabled: '{!isOsd}', - }, - menu: [ - { - text: gettext('Scrub'), - iconCls: 'fa fa-shower', - cmd: 'scrub', - handler: 'osd_cmd', - }, - { - text: gettext('Deep Scrub'), - iconCls: 'fa fa-bath', - cmd: 'scrub', - params: { - deep: 1, - }, - handler: 'osd_cmd', - }, - { - text: gettext('Destroy'), - itemId: 'remove', - iconCls: 'fa fa-fw fa-trash-o', - bind: { - disabled: '{!downOsd}', - }, - handler: 'destroy_osd', - }, - ], - }, - ], - }, - - fields: [ - 'name', - 'type', - 'status', - 'host', - 'in', - 'id', - { type: 'number', name: 'reweight' }, - { type: 'number', name: 'percent_used' }, - { type: 'integer', name: 'bytes_used' }, - { type: 'integer', name: 'total_space' }, - { type: 'integer', name: 'apply_latency_ms' }, - { type: 'integer', name: 'commit_latency_ms' }, - { type: 'string', name: 'device_class' }, - { type: 'string', name: 'osdtype' }, - { type: 'string', name: 'blfsdev' }, - { type: 'string', name: 'dbdev' }, - { type: 'string', name: 'waldev' }, - { - type: 'string', - name: 'version', - calculate: function (data) { - return PVE.Utils.parse_ceph_version(data); - }, - }, - { - type: 'string', - name: 'iconCls', - calculate: function (data) { - let iconMap = { - host: 'fa-building', - osd: 'fa-hdd-o', - root: 'fa-server', - }; - return `fa x-fa-tree ${iconMap[data.type] ?? 'fa-folder-o'}`; - }, - }, - { type: 'number', name: 'crush_weight' }, - ], -}); -Ext.define('pve-osd-details-devices', { - extend: 'Ext.data.Model', - fields: ['device', 'type', 'physical_device', 'size', 'support_discard', 'dev_node'], - idProperty: 'device', -}); - -Ext.define('PVE.CephOsdDetails', { - extend: 'Ext.window.Window', - alias: ['widget.pveCephOsdDetails'], - - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: function () { - let me = this; - me.baseUrl = `/nodes/${me.nodename}/ceph/osd/${me.osdid}`; - return { - title: `${gettext('Details')}: OSD ${me.osdid}`, - }; - }, - - viewModel: { - data: { - device: '', - }, - }, - - modal: true, - width: 650, - minHeight: 250, - resizable: true, - cbind: { - title: '{title}', - }, - - layout: { - type: 'vbox', - align: 'stretch', - }, - defaults: { - layout: 'fit', - border: false, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - reload: function () { - let view = this.getView(); - - Proxmox.Utils.API2Request({ - url: `${view.baseUrl}/metadata`, - waitMsgTarget: view.lookup('detailsTabs'), - method: 'GET', - failure: function (response, opts) { - Proxmox.Utils.setErrorMask(view.lookup('detailsTabs'), response.htmlStatus); - }, - success: function (response, opts) { - let d = response.result.data; - let osdData = Object.keys(d.osd) - .sort() - .map((x) => ({ key: x, value: d.osd[x] })); - view.osdStore.loadData(osdData); - let devices = view.lookup('devices'); - let deviceStore = devices.getStore(); - deviceStore.loadData(d.devices); - - view.lookup('osdGeneral').rstore.fireEvent( - 'load', - view.osdStore, - osdData, - true, - ); - view.lookup('osdNetwork').rstore.fireEvent( - 'load', - view.osdStore, - osdData, - true, - ); - - // select 'block' device automatically on first load - if (devices.getSelection().length === 0) { - devices.setSelection(deviceStore.findRecord('device', 'block')); - } - }, - }); - }, - - showDevInfo: function (grid, selected) { - let view = this.getView(); - if (selected[0]) { - let device = selected[0].data.device; - this.getViewModel().set('device', device); - - let detailStore = view.lookup('volumeDetails'); - detailStore.rstore.getProxy().setUrl(`api2/json${view.baseUrl}/lv-info`); - detailStore.rstore.getProxy().setExtraParams({ type: device }); - detailStore.setLoading(); - detailStore.rstore.load({ callback: () => detailStore.setLoading(false) }); - } - }, - - init: function () { - this.reload(); - }, - - control: { - 'grid[reference=devices]': { - selectionchange: 'showDevInfo', - }, - }, - }, - tbar: [ - { - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: 'reload', - }, - ], - initComponent: function () { - let me = this; - - me.osdStore = Ext.create('Proxmox.data.ObjectStore'); - - Ext.applyIf(me, { - items: [ - { - xtype: 'tabpanel', - reference: 'detailsTabs', - items: [ - { - xtype: 'proxmoxObjectGrid', - reference: 'osdGeneral', - tooltip: gettext('Various information about the OSD'), - rstore: me.osdStore, - title: gettext('General'), - viewConfig: { - enableTextSelection: true, - }, - gridRows: [ - { - xtype: 'text', - name: 'version', - text: gettext('Version'), - }, - { - xtype: 'text', - name: 'hostname', - text: gettext('Hostname'), - }, - { - xtype: 'text', - name: 'osd_data', - text: gettext('OSD data path'), - }, - { - xtype: 'text', - name: 'osd_objectstore', - text: gettext('OSD object store'), - }, - { - xtype: 'text', - name: 'mem_usage', - text: gettext('Memory usage (PSS)'), - renderer: Proxmox.Utils.render_size, - }, - { - xtype: 'text', - name: 'pid', - text: `${gettext('Process ID')} (PID)`, - }, - ], - }, - { - xtype: 'proxmoxObjectGrid', - reference: 'osdNetwork', - tooltip: gettext('Addresses and ports used by the OSD service'), - rstore: me.osdStore, - title: gettext('Network'), - viewConfig: { - enableTextSelection: true, - }, - gridRows: [ - { - xtype: 'text', - name: 'front_addr', - text: `${gettext('Front Address')}
    (Client & Monitor)`, - renderer: PVE.Utils.render_ceph_osd_addr, - }, - { - xtype: 'text', - name: 'hb_front_addr', - text: gettext('Heartbeat Front Address'), - renderer: PVE.Utils.render_ceph_osd_addr, - }, - { - xtype: 'text', - name: 'back_addr', - text: `${gettext('Back Address')}
    (OSD)`, - renderer: PVE.Utils.render_ceph_osd_addr, - }, - { - xtype: 'text', - name: 'hb_back_addr', - text: gettext('Heartbeat Back Address'), - renderer: PVE.Utils.render_ceph_osd_addr, - }, - ], - }, - { - xtype: 'panel', - title: gettext('Devices'), - tooltip: gettext('Physical devices used by the OSD'), - items: [ - { - xtype: 'grid', - border: false, - reference: 'devices', - store: { - model: 'pve-osd-details-devices', - }, - columns: { - items: [ - { text: gettext('Device'), dataIndex: 'device' }, - { text: gettext('Type'), dataIndex: 'type' }, - { - text: gettext('Physical Device'), - dataIndex: 'physical_device', - }, - { - text: gettext('Size'), - dataIndex: 'size', - renderer: Proxmox.Utils.render_size, - }, - { - text: 'Discard', - dataIndex: 'support_discard', - hidden: true, - }, - { - text: gettext('Device node'), - dataIndex: 'dev_node', - hidden: true, - }, - ], - defaults: { - tdCls: 'pointer', - flex: 1, - }, - }, - }, - { - xtype: 'proxmoxObjectGrid', - reference: 'volumeDetails', - maskOnLoad: true, - viewConfig: { - enableTextSelection: true, - }, - bind: { - title: Ext.String.format( - gettext('Volume Details for {0}'), - '{device}', - ), - }, - rows: { - creation_time: { - header: gettext('Creation time'), - }, - lv_name: { - header: gettext('LV Name'), - }, - lv_path: { - header: gettext('LV Path'), - }, - lv_uuid: { - header: gettext('LV UUID'), - }, - vg_name: { - header: gettext('VG Name'), - }, - }, - url: 'nodes/', //placeholder will be set when device is selected - }, - ], - }, - ], - }, - ], - }); - - me.callParent(); - }, -}); -Ext.define('PVE.CephPoolInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveCephPoolInputPanel', - mixins: ['Proxmox.Mixin.CBind'], - - showProgress: true, - onlineHelp: 'pve_ceph_pools', - - subject: 'Ceph Pool', - - defaultSize: undefined, - defaultMinSize: undefined, - - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - let vm = this.getViewModel(); - if (view.isCreate) { - vm.set('size', Number(view.defaultSize)); - vm.set('minSize', Number(view.defaultMinSize)); - } - }, - sizeChange: function (field, val) { - let vm = this.getViewModel(); - let minSize = Math.round(val / 2); - if (minSize > 1) { - vm.set('minSize', minSize); - } - vm.set('size', val); // bind does not work in a pmxDisplayEditField, update manually - }, - }, - - viewModel: { - data: { - minSize: null, - size: null, - }, - formulas: { - minSizeLabel: (get) => { - if (get('showMinSizeOneWarning') || get('showMinSizeHalfWarning')) { - return `${gettext('Min. Size')} `; - } - return gettext('Min. Size'); - }, - showMinSizeOneWarning: (get) => get('minSize') === 1, - showMinSizeHalfWarning: (get) => { - let minSize = get('minSize'); - let size = get('size'); - if (minSize === 1) { - return false; - } - return minSize < size / 2 && minSize !== size; - }, - }, - }, - - column1: [ - { - xtype: 'pmxDisplayEditField', - fieldLabel: gettext('Name'), - cbind: { - editable: '{isCreate}', - value: '{pool_name}', - }, - name: 'name', - allowBlank: false, - }, - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{!isErasure}', - }, - fieldLabel: gettext('Size'), - name: 'size', - editConfig: { - xtype: 'proxmoxintegerfield', - cbind: { - value: (get) => get('defaultSize'), - }, - minValue: 2, - maxValue: 7, - allowBlank: false, - listeners: { - change: 'sizeChange', - }, - }, - }, - ], - column2: [ - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('PG Autoscaler Mode'), - name: 'pg_autoscale_mode', - comboItems: [ - ['warn', 'warn'], - ['on', 'on'], - ['off', 'off'], - ], - value: 'on', // FIXME: check ceph version and only default to on on octopus and newer - allowBlank: false, - autoSelect: false, - labelWidth: 140, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Add as Storage'), - cbind: { - value: '{isCreate}', - hidden: '{!isCreate}', - }, - name: 'add_storages', - labelWidth: 140, - autoEl: { - tag: 'div', - 'data-qtip': gettext('Add the new pool to the cluster storage configuration.'), - }, - }, - ], - advancedColumn1: [ - { - xtype: 'proxmoxintegerfield', - bind: { - fieldLabel: '{minSizeLabel}', - value: '{minSize}', - }, - name: 'min_size', - cbind: { - value: (get) => get('defaultMinSize'), - minValue: (get) => { - if (Number(get('defaultMinSize')) === 1) { - return 1; - } else { - return get('isCreate') ? 2 : 1; - } - }, - }, - maxValue: 7, - allowBlank: false, - }, - { - xtype: 'displayfield', - bind: { - hidden: '{!showMinSizeHalfWarning}', - }, - hidden: true, - userCls: 'pmx-hint', - value: gettext( - 'min_size < size/2 can lead to data loss, incomplete PGs or unfound objects.', - ), - }, - { - xtype: 'displayfield', - bind: { - hidden: '{!showMinSizeOneWarning}', - }, - hidden: true, - userCls: 'pmx-hint', - value: gettext('a min_size of 1 is not recommended and can lead to data loss'), - }, - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{!isErasure}', - nodename: '{nodename}', - isCreate: '{isCreate}', - }, - fieldLabel: 'Crush Rule', // do not localize - name: 'crush_rule', - editConfig: { - xtype: 'pveCephRuleSelector', - allowBlank: false, - }, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: '# of PGs', - name: 'pg_num', - value: 128, - minValue: 1, - maxValue: 32768, - allowBlank: false, - emptyText: 128, - }, - ], - advancedColumn2: [ - { - xtype: 'numberfield', - fieldLabel: gettext('Target Ratio'), - name: 'target_size_ratio', - minValue: 0, - decimalPrecision: 3, - allowBlank: true, - emptyText: '0.0', - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'The ratio of storage amount this pool will consume compared to other pools with ratios. Used for auto-scaling.', - ), - }, - }, - { - xtype: 'pveSizeField', - name: 'target_size', - fieldLabel: gettext('Target Size'), - unit: 'GiB', - minValue: 0, - allowBlank: true, - allowZero: true, - emptyText: '0', - emptyValue: 0, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'The amount of data eventually stored in this pool. Used for auto-scaling.', - ), - }, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: Ext.String.format(gettext('{0} takes precedence.'), gettext('Target Ratio')), // FIXME: tooltip? - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: 'Min. # of PGs', - name: 'pg_num_min', - minValue: 0, - allowBlank: true, - emptyText: '0', - }, - ], - - onGetValues: function (values) { - Object.keys(values || {}).forEach(function (name) { - if (values[name] === '') { - delete values[name]; - } - }); - - return values; - }, -}); - -Ext.define('PVE.Ceph.PoolEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveCephPoolEdit', - mixins: ['Proxmox.Mixin.CBind'], - - cbindData: { - pool_name: '', - isCreate: (cfg) => !cfg.pool_name, - defaultSize: undefined, - defaultMinSize: undefined, - }, - - cbind: { - autoLoad: (get) => !get('isCreate'), - url: (get) => - get('isCreate') - ? `/nodes/${get('nodename')}/ceph/pool` - : `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}`, - loadUrl: (get) => `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}/status`, - method: (get) => (get('isCreate') ? 'POST' : 'PUT'), - }, - - showProgress: true, - - subject: gettext('Ceph Pool'), - - items: [ - { - xtype: 'pveCephPoolInputPanel', - cbind: { - nodename: '{nodename}', - pool_name: '{pool_name}', - isErasure: '{isErasure}', - isCreate: '{isCreate}', - defaultSize: '{defaultSize}', - defaultMinSize: '{defaultMinSize}', - }, - }, - ], -}); - -Ext.define( - 'PVE.node.Ceph.PoolList', - { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveNodeCephPoolList', - - onlineHelp: 'chapter_pveceph', - - stateful: true, - stateId: 'grid-ceph-pools', - bufferedRenderer: false, - - features: [{ ftype: 'summary' }], - - columns: [ - { - text: gettext('Pool #'), - minWidth: 70, - flex: 1, - align: 'right', - sortable: true, - dataIndex: 'pool', - }, - { - text: gettext('Name'), - minWidth: 120, - flex: 2, - sortable: true, - dataIndex: 'pool_name', - renderer: Ext.htmlEncode, - }, - { - text: gettext('Type'), - minWidth: 100, - flex: 1, - dataIndex: 'type', - hidden: true, - }, - { - text: gettext('Application'), - minWidth: 100, - flex: 1, - dataIndex: 'application_metadata', - hidden: true, - renderer: (v, _meta, _rec) => Ext.htmlEncode(Object.keys(v).toString()), - }, - { - text: gettext('Size') + '/min', - minWidth: 100, - flex: 1, - align: 'right', - renderer: (v, meta, rec) => `${v}/${rec.data.min_size}`, - dataIndex: 'size', - }, - { - text: '# of Placement Groups', - flex: 1, - minWidth: 100, - align: 'right', - dataIndex: 'pg_num', - }, - { - text: gettext('Optimal # of PGs'), - flex: 1, - minWidth: 100, - align: 'right', - dataIndex: 'pg_num_final', - renderer: function (value, metaData) { - if (!value) { - value = ' n/a'; - metaData.tdAttr = 'data-qtip="Needs pg_autoscaler module enabled."'; - } - return value; - }, - }, - { - text: gettext('Min. # of PGs'), - flex: 1, - minWidth: 100, - align: 'right', - dataIndex: 'pg_num_min', - hidden: true, - }, - { - text: gettext('Target Ratio'), - flex: 1, - minWidth: 100, - align: 'right', - dataIndex: 'target_size_ratio', - renderer: Ext.util.Format.numberRenderer('0.0000'), - hidden: true, - }, - { - text: gettext('Target Size'), - flex: 1, - minWidth: 100, - align: 'right', - dataIndex: 'target_size', - hidden: true, - renderer: function (v, metaData, rec) { - let value = Proxmox.Utils.render_size(v); - if (rec.data.target_size_ratio > 0) { - value = ' ' + value; - metaData.tdAttr = - 'data-qtip="Target Size Ratio takes precedence over Target Size."'; - } - return value; - }, - }, - { - text: gettext('Autoscaler Mode'), - flex: 1, - minWidth: 100, - align: 'right', - dataIndex: 'pg_autoscale_mode', - }, - { - text: 'CRUSH Rule (ID)', - flex: 1, - align: 'right', - minWidth: 150, - renderer: (v, meta, rec) => Ext.htmlEncode(`${v} (${rec.data.crush_rule})`), - dataIndex: 'crush_rule_name', - }, - { - text: gettext('Used') + ' (%)', - flex: 1, - minWidth: 150, - sortable: true, - align: 'right', - dataIndex: 'bytes_used', - summaryType: 'sum', - summaryRenderer: Proxmox.Utils.render_size, - renderer: function (v, meta, rec) { - let percentage = Ext.util.Format.percent(rec.data.percent_used, '0.00'); - let used = Proxmox.Utils.render_size(v); - return `${used} (${percentage})`; - }, - }, - ], - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var rstore = Ext.create('Proxmox.data.UpdateStore', { - interval: 3000, - storeid: 'ceph-pool-list' + nodename, - model: 'ceph-pool-list', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${nodename}/ceph/pool`, - }, - }); - let store = Ext.create('Proxmox.data.DiffStore', { rstore: rstore }); - - // manages the "install ceph?" overlay - PVE.Utils.monitor_ceph_installed(me, rstore, nodename); - - var run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec || !rec.data.pool_name) { - return; - } - Ext.create('PVE.Ceph.PoolEdit', { - title: gettext('Edit') + ': Ceph Pool', - nodename: nodename, - pool_name: rec.data.pool_name, - isErasure: rec.data.type === 'erasure', - autoShow: true, - listeners: { - destroy: () => rstore.load(), - }, - }); - }; - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: [ - { - text: gettext('Create'), - handler: function () { - let keys = [ - 'global:osd-pool-default-min-size', - 'global:osd-pool-default-size', - ]; - let params = { - 'config-keys': keys.join(';'), - }; - - Proxmox.Utils.API2Request({ - url: '/nodes/localhost/ceph/cfg/value', - method: 'GET', - params, - waitMsgTarget: me.getView(), - failure: (response) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function ({ result: { data } }) { - let global = data.global; - let defaultSize = global?.['osd-pool-default-size'] ?? 3; - let defaultMinSize = global?.['osd-pool-default-min-size'] ?? 2; - - Ext.create('PVE.Ceph.PoolEdit', { - title: gettext('Create') + ': Ceph Pool', - isCreate: true, - isErasure: false, - defaultSize, - defaultMinSize, - nodename: nodename, - autoShow: true, - listeners: { - destroy: () => rstore.load(), - }, - }); - }, - }); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - selModel: sm, - disabled: true, - handler: run_editor, - }, - { - xtype: 'proxmoxButton', - text: gettext('Destroy'), - selModel: sm, - disabled: true, - handler: function () { - let rec = sm.getSelection()[0]; - if (!rec || !rec.data.pool_name) { - return; - } - let poolName = rec.data.pool_name; - Ext.create('Proxmox.window.SafeDestroy', { - showProgress: true, - url: `/nodes/${nodename}/ceph/pool/${poolName}`, - params: { - remove_storages: 1, - }, - item: { - type: 'CephPool', - id: poolName, - }, - taskName: 'cephdestroypool', - autoShow: true, - listeners: { - destroy: () => rstore.load(), - }, - }); - }, - }, - ], - listeners: { - activate: () => rstore.startUpdate(), - destroy: () => rstore.stopUpdate(), - itemdblclick: run_editor, - }, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('ceph-pool-list', { - extend: 'Ext.data.Model', - fields: [ - 'pool_name', - { name: 'pool', type: 'integer' }, - { name: 'size', type: 'integer' }, - { name: 'min_size', type: 'integer' }, - { name: 'pg_num', type: 'integer' }, - { name: 'pg_num_min', type: 'integer' }, - { name: 'bytes_used', type: 'integer' }, - { name: 'percent_used', type: 'number' }, - { name: 'crush_rule', type: 'integer' }, - { name: 'crush_rule_name', type: 'string' }, - { name: 'pg_autoscale_mode', type: 'string' }, - { name: 'pg_num_final', type: 'integer' }, - { name: 'target_size_ratio', type: 'number' }, - { name: 'target_size', type: 'integer' }, - ], - idProperty: 'pool_name', - }); - }, -); - -Ext.define('PVE.form.CephRuleSelector', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveCephRuleSelector', - - allowBlank: false, - valueField: 'name', - displayField: 'name', - editable: false, - queryMode: 'local', - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no nodename given'; - } - - me.originalAllowBlank = me.allowBlank; - me.allowBlank = true; - - Ext.apply(me, { - store: { - fields: ['name'], - sorters: 'name', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/ceph/rules`, - }, - autoLoad: { - callback: (records, op, success) => { - if (me.isCreate && success && records.length > 0) { - me.select(records[0]); - } - - me.allowBlank = me.originalAllowBlank; - delete me.originalAllowBlank; - me.validate(); - }, - }, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.CephCreateService', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - xtype: 'pveCephCreateService', - - method: 'POST', - isCreate: true, - showProgress: true, - width: 450, - - setNode: function (node) { - let me = this; - me.nodename = node; - me.updateUrl(); - }, - setServiceID: function (value) { - let me = this; - me.serviceID = value; - me.updateUrl(); - }, - updateUrl: function () { - let me = this; - let node = me.nodename; - let serviceID = me.serviceID ?? me.nodename; - - me.url = `/nodes/${node}/ceph/${me.type}/${serviceID}`; - }, - - defaults: { - labelWidth: 75, - }, - items: [ - { - xtype: 'pveNodeSelector', - fieldLabel: gettext('Host'), - selectCurNode: true, - allowBlank: false, - submitValue: false, - listeners: { - change: function (f, value) { - let view = this.up('pveCephCreateService'); - view.lookup('mds-id').setValue(value); - view.setNode(value); - }, - }, - }, - { - xtype: 'textfield', - reference: 'mds-id', - fieldLabel: gettext('MDS ID'), - regex: /^([a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?)$/, - regexText: gettext( - 'ID may consist of alphanumeric characters and hyphen. It cannot start with a number or end in a hyphen.', - ), - submitValue: false, - allowBlank: false, - cbind: { - disabled: (get) => get('type') !== 'mds', - hidden: (get) => get('type') !== 'mds', - }, - listeners: { - change: function (f, value) { - let view = this.up('pveCephCreateService'); - view.setServiceID(value); - }, - }, - }, - { - xtype: 'component', - border: false, - padding: '5 2', - style: { - fontSize: '12px', - }, - userCls: 'pmx-hint', - cbind: { - hidden: (get) => get('type') !== 'mds', - }, - html: gettext( - 'By using different IDs, you can have multiple MDS per node, which increases redundancy with more than one CephFS.', - ), - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - if (!me.type) { - throw 'no type specified'; - } - me.setNode(me.nodename); - - me.callParent(); - }, -}); - -Ext.define('PVE.node.CephServiceController', { - extend: 'Ext.app.ViewController', - alias: 'controller.CephServiceList', - - render_status: (value, metadata, rec) => Ext.htmlEncode(value), - - render_version: function (value, metadata, rec) { - if (value === undefined) { - return ''; - } - let view = this.getView(); - let host = rec.data.host, - nodev = [0]; - if (view.nodeversions[host] !== undefined) { - nodev = view.nodeversions[host].version.parts; - } - - let icon = ''; - if (PVE.Utils.compare_ceph_versions(view.maxversion, nodev) > 0) { - icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE'); - } else if (PVE.Utils.compare_ceph_versions(nodev, value) > 0) { - icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD'); - } else if (view.mixedversions) { - icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK'); - } - return icon + value; - }, - - getMaxVersions: function (store, records, success) { - if (!success || records.length < 1) { - return; - } - let me = this; - let view = me.getView(); - - view.nodeversions = records[0].data.node; - view.maxversion = []; - view.mixedversions = false; - for (const [_nodename, data] of Object.entries(view.nodeversions)) { - let res = PVE.Utils.compare_ceph_versions(data.version.parts, view.maxversion); - if (res !== 0 && view.maxversion.length > 0) { - view.mixedversions = true; - } - if (res > 0) { - view.maxversion = data.version.parts; - } - } - }, - - init: function (view) { - if (view.pveSelNode) { - view.nodename = view.pveSelNode.data.node; - } - if (!view.nodename) { - throw 'no node name specified'; - } - - if (!view.type) { - throw 'no type specified'; - } - - view.versionsstore = Ext.create('Proxmox.data.UpdateStore', { - autoStart: true, - interval: 10000, - storeid: `ceph-versions-${view.type}-list${view.nodename}`, - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/ceph/metadata?scope=versions', - }, - }); - view.versionsstore.on('load', this.getMaxVersions, this); - view.on('destroy', view.versionsstore.stopUpdate); - - view.rstore = Ext.create('Proxmox.data.UpdateStore', { - autoStart: true, - interval: 3000, - storeid: `ceph-${view.type}-list${view.nodename}`, - model: 'ceph-service-list', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${view.nodename}/ceph/${view.type}`, - }, - }); - - view.setStore( - Ext.create('Proxmox.data.DiffStore', { - rstore: view.rstore, - sorters: [{ property: 'name' }], - }), - ); - - if (view.storeLoadCallback) { - view.rstore.on('load', view.storeLoadCallback, this); - } - view.on('destroy', view.rstore.stopUpdate); - - if (view.showCephInstallMask) { - PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true); - } - }, - - service_cmd: function (rec, cmd) { - let view = this.getView(); - if (!rec.data.host) { - Ext.Msg.alert(gettext('Error'), 'entry has no host'); - return; - } - let doRequest = function () { - Proxmox.Utils.API2Request({ - url: `/nodes/${rec.data.host}/ceph/${cmd}`, - method: 'POST', - params: { service: view.type + '.' + rec.data.name }, - success: function (response, options) { - Ext.create('Proxmox.window.TaskProgress', { - autoShow: true, - upid: response.result.data, - taskDone: () => view.rstore.load(), - }); - }, - failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }; - if (cmd === 'stop' && ['mon', 'mds'].includes(view.type)) { - Proxmox.Utils.API2Request({ - url: `/nodes/${rec.data.host}/ceph/cmd-safety`, - params: { - service: view.type, - id: rec.data.name, - action: 'stop', - }, - method: 'GET', - success: function ({ result: { data } }) { - let stopText = { - mon: gettext('Stop MON'), - mds: gettext('Stop MDS'), - }; - if (!data.safe) { - Ext.Msg.show({ - title: ngettext('Warning', 'Warnings', 1), - message: data.status, - icon: Ext.Msg.WARNING, - buttons: Ext.Msg.OKCANCEL, - buttonText: { ok: stopText[view.type] }, - fn: function (selection) { - if (selection === 'ok') { - doRequest(); - } - }, - }); - } else { - doRequest(); - } - }, - failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - } else { - doRequest(); - } - }, - onChangeService: function (button) { - let me = this; - let record = me.getView().getSelection()[0]; - me.service_cmd(record, button.action); - }, - - showSyslog: function () { - let view = this.getView(); - let rec = view.getSelection()[0]; - let service = `ceph-${view.type}@${rec.data.name}`; - Ext.create('Ext.window.Window', { - title: `${gettext('Syslog')}: ${service}`, - autoShow: true, - modal: true, - width: 800, - height: 400, - layout: 'fit', - items: [ - { - xtype: 'proxmoxLogView', - url: `/api2/extjs/nodes/${rec.data.host}/syslog?service=${encodeURIComponent(service)}`, - log_select_timespan: 1, - }, - ], - }); - }, - - onCreate: function () { - let view = this.getView(); - Ext.create('PVE.CephCreateService', { - autoShow: true, - nodename: view.nodename, - subject: view.getTitle(), - type: view.type, - taskDone: () => view.rstore.load(), - }); - }, -}); - -Ext.define( - 'PVE.node.CephServiceList', - { - extend: 'Ext.grid.GridPanel', - xtype: 'pveNodeCephServiceList', - - onlineHelp: 'chapter_pveceph', - emptyText: gettext('No such service configured.'), - - stateful: true, - - // will be called when the store loads - storeLoadCallback: Ext.emptyFn, - - // if set to true, does shows the ceph install mask if needed - showCephInstallMask: false, - - controller: 'CephServiceList', - - tbar: [ - { - xtype: 'proxmoxButton', - text: gettext('Start'), - iconCls: 'fa fa-play', - action: 'start', - disabled: true, - enableFn: (rec) => rec.data.state === 'stopped' || rec.data.state === 'unknown', - handler: 'onChangeService', - }, - { - xtype: 'proxmoxButton', - text: gettext('Stop'), - iconCls: 'fa fa-stop', - action: 'stop', - enableFn: (rec) => rec.data.state !== 'stopped', - disabled: true, - handler: 'onChangeService', - }, - { - xtype: 'proxmoxButton', - text: gettext('Restart'), - iconCls: 'fa fa-refresh', - action: 'restart', - disabled: true, - enableFn: (rec) => rec.data.state !== 'stopped', - handler: 'onChangeService', - }, - '-', - { - text: gettext('Create'), - reference: 'createButton', - handler: 'onCreate', - }, - { - text: gettext('Destroy'), - xtype: 'proxmoxStdRemoveButton', - getUrl: function (rec) { - let view = this.up('grid'); - if (!rec.data.host) { - Ext.Msg.alert(gettext('Error'), 'entry has no host, cannot build API url'); - return ''; - } - return `/nodes/${rec.data.host}/ceph/${view.type}/${rec.data.name}`; - }, - callback: function (options, success, response) { - let view = this.up('grid'); - if (!success) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - return; - } - Ext.create('Proxmox.window.TaskProgress', { - autoShow: true, - upid: response.result.data, - taskDone: () => view.rstore.load(), - }); - }, - handler: function (btn, event, rec) { - let me = this; - let view = me.up('grid'); - let doRequest = function () { - Proxmox.button.StdRemoveButton.prototype.handler.call(me, btn, event, rec); - }; - if (view.type === 'mon') { - Proxmox.Utils.API2Request({ - url: `/nodes/${rec.data.host}/ceph/cmd-safety`, - params: { - service: view.type, - id: rec.data.name, - action: 'destroy', - }, - method: 'GET', - success: function ({ result: { data } }) { - if (!data.safe) { - Ext.Msg.show({ - title: ngettext('Warning', 'Warnings', 1), - message: data.status, - icon: Ext.Msg.WARNING, - buttons: Ext.Msg.OKCANCEL, - buttonText: { ok: gettext('Destroy MON') }, - fn: function (selection) { - if (selection === 'ok') { - doRequest(); - } - }, - }); - } else { - doRequest(); - } - }, - failure: (response, _opts) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - } else { - doRequest(); - } - }, - }, - '-', - { - xtype: 'proxmoxButton', - text: gettext('Syslog'), - disabled: true, - handler: 'showSyslog', - }, - ], - - columns: [ - { - header: gettext('Name'), - flex: 1, - sortable: true, - renderer: function (v) { - return this.type + '.' + v; - }, - dataIndex: 'name', - }, - { - header: gettext('Host'), - flex: 1, - sortable: true, - renderer: function (v) { - return v || Proxmox.Utils.unknownText; - }, - dataIndex: 'host', - }, - { - header: gettext('Status'), - flex: 1, - sortable: false, - renderer: 'render_status', - dataIndex: 'state', - }, - { - header: gettext('Address'), - flex: 3, - sortable: true, - renderer: function (v) { - return v || Proxmox.Utils.unknownText; - }, - dataIndex: 'addr', - }, - { - header: gettext('Version'), - flex: 3, - sortable: true, - dataIndex: 'version', - renderer: 'render_version', - }, - ], - - initComponent: function () { - let me = this; - - if (me.additionalColumns) { - me.columns = me.columns.concat(me.additionalColumns); - } - - me.callParent(); - }, - }, - function () { - Ext.define('ceph-service-list', { - extend: 'Ext.data.Model', - fields: [ - 'addr', - 'name', - 'fs_name', - 'rank', - 'host', - 'quorum', - 'state', - 'ceph_version', - 'ceph_version_short', - { - type: 'string', - name: 'version', - calculate: (data) => PVE.Utils.parse_ceph_version(data), - }, - ], - idProperty: 'name', - }); - }, -); - -Ext.define('PVE.node.CephMDSServiceController', { - extend: 'PVE.node.CephServiceController', - alias: 'controller.CephServiceMDSList', - - render_status: (value, mD, rec) => - Ext.htmlEncode(rec.data.fs_name ? `${value} (${rec.data.fs_name})` : value), -}); - -Ext.define('PVE.node.CephMDSList', { - extend: 'PVE.node.CephServiceList', - xtype: 'pveNodeCephMDSList', - - controller: { - type: 'CephServiceMDSList', - }, -}); -Ext.define('PVE.ceph.Services', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveCephServices', - - layout: { - type: 'hbox', - align: 'stretch', - }, - - bodyPadding: '0 5 20', - defaults: { - xtype: 'box', - style: { - 'text-align': 'center', - }, - }, - - items: [ - { - flex: 1, - xtype: 'pveCephServiceList', - itemId: 'mons', - title: gettext('Monitors'), - }, - { - flex: 1, - xtype: 'pveCephServiceList', - itemId: 'mgrs', - title: gettext('Managers'), - }, - { - flex: 1, - xtype: 'pveCephServiceList', - itemId: 'mdss', - title: gettext('Metadata Servers'), - }, - ], - - updateAll: function (metadata, status) { - var me = this; - - const healthstates = { - HEALTH_UNKNOWN: 0, - HEALTH_ERR: 1, - HEALTH_WARN: 2, - HEALTH_UPGRADE: 3, - HEALTH_OLD: 4, - HEALTH_OK: 5, - }; - // order guarantee since es2020, but browsers did so before. Note, integers would break it. - const healthmap = Object.keys(healthstates); - let maxversion = '00.0.00'; - Object.values(metadata.node || {}).forEach(function (node) { - if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) { - maxversion = node?.version?.parts; - } - }); - var quorummap = status && status.quorum_names ? status.quorum_names : []; - let monmessages = {}, - mgrmessages = {}, - mdsmessages = {}; - if (status) { - if (status.health) { - Ext.Object.each(status.health.checks, function (key, value, _obj) { - if (!Ext.String.startsWith(key, 'MON_')) { - return; - } - for (let i = 0; i < value.detail.length; i++) { - let match = value.detail[i].message.match(/mon.([a-zA-Z0-9\-.]+)/); - if (!match) { - continue; - } - let monid = match[1]; - if (!monmessages[monid]) { - monmessages[monid] = { - worstSeverity: healthstates.HEALTH_OK, - messages: [], - }; - } - - let severityIcon = PVE.Utils.get_ceph_icon_html(value.severity, true); - let details = value.detail.reduce((acc, v) => `${acc}\n${v.message}`, ''); - monmessages[monid].messages.push(severityIcon + details); - - if (healthstates[value.severity] < monmessages[monid].worstSeverity) { - monmessages[monid].worstSeverity = healthstates[value.severity]; - } - } - }); - } - - if (status.mgrmap) { - mgrmessages[status.mgrmap.active_name] = 'active'; - status.mgrmap.standbys.forEach(function (mgr) { - mgrmessages[mgr.name] = 'standby'; - }); - } - - if (status.fsmap) { - status.fsmap.by_rank.forEach(function (mds) { - mdsmessages[mds.name] = 'rank: ' + mds.rank + '; ' + mds.status; - }); - } - } - - let checks = { - mon: function (mon) { - if (quorummap.indexOf(mon.name) !== -1) { - mon.health = healthstates.HEALTH_OK; - } else { - mon.health = healthstates.HEALTH_ERR; - } - if (monmessages[mon.name]) { - if (monmessages[mon.name].worstSeverity < mon.health) { - mon.health = monmessages[mon.name].worstSeverity; - } - Array.prototype.push.apply(mon.messages, monmessages[mon.name].messages); - } - return mon; - }, - mgr: function (mgr) { - if (mgrmessages[mgr.name] === 'active') { - mgr.title = '' + mgr.title + ''; - mgr.statuses.push(gettext('Status') + ': active'); - } else if (mgrmessages[mgr.name] === 'standby') { - mgr.statuses.push(gettext('Status') + ': standby'); - } else if (mgr.health > healthstates.HEALTH_WARN) { - mgr.health = healthstates.HEALTH_WARN; - } - - return mgr; - }, - mds: function (mds) { - if (mdsmessages[mds.name]) { - mds.title = '' + mds.title + ''; - mds.statuses.push(gettext('Status') + ': ' + mdsmessages[mds.name] + ''); - } else if (mds.addr !== Proxmox.Utils.unknownText) { - mds.statuses.push(gettext('Status') + ': standby'); - } - - return mds; - }, - }; - - for (let type of ['mon', 'mgr', 'mds']) { - let ids = Object.keys(metadata[type] || {}); - me[type] = {}; - - for (let id of ids) { - const [name, host] = id.split('@'); - let result = { - id: id, - health: healthstates.HEALTH_OK, - statuses: [], - messages: [], - name: name, - title: metadata[type][id].name || name, - host: host, - version: PVE.Utils.parse_ceph_version(metadata[type][id]), - service: metadata[type][id].service, - addr: - metadata[type][id].addr || - metadata[type][id].addrs || - Proxmox.Utils.unknownText, - }; - - result.statuses = [ - gettext('Host') + ': ' + host, - gettext('Address') + ': ' + result.addr, - ]; - - if (checks[type]) { - result = checks[type](result); - } - - if (result.service && !result.version) { - result.messages.push( - PVE.Utils.get_ceph_icon_html('HEALTH_UNKNOWN', true) + gettext('Stopped'), - ); - result.health = healthstates.HEALTH_UNKNOWN; - } - - if (!result.version && result.addr === Proxmox.Utils.unknownText) { - result.health = healthstates.HEALTH_UNKNOWN; - } - - if (result.version) { - result.statuses.push(gettext('Version') + ': ' + result.version); - - if (PVE.Utils.compare_ceph_versions(result.version, maxversion) !== 0) { - let host_version = - metadata.node[host]?.version?.parts || metadata.version?.[host] || ''; - if (PVE.Utils.compare_ceph_versions(host_version, maxversion) === 0) { - if (result.health > healthstates.HEALTH_OLD) { - result.health = healthstates.HEALTH_OLD; - } - result.messages.push( - PVE.Utils.get_ceph_icon_html('HEALTH_OLD', true) + - gettext( - 'A newer version was installed but old version still running, please restart', - ), - ); - } else { - if (result.health > healthstates.HEALTH_UPGRADE) { - result.health = healthstates.HEALTH_UPGRADE; - } - result.messages.push( - PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE', true) + - gettext( - 'Other cluster members use a newer version of this service, please upgrade and restart', - ), - ); - } - } - } - - result.statuses.push(''); // empty line - result.text = result.statuses.concat(result.messages).join('
    '); - - result.health = healthmap[result.health]; - - me[type][id] = result; - } - } - - me.getComponent('mons').updateAll(Object.values(me.mon)); - me.getComponent('mgrs').updateAll(Object.values(me.mgr)); - me.getComponent('mdss').updateAll(Object.values(me.mds)); - }, -}); - -Ext.define('PVE.ceph.ServiceList', { - extend: 'Ext.container.Container', - xtype: 'pveCephServiceList', - - style: { - 'text-align': 'center', - }, - defaults: { - xtype: 'box', - style: { - 'text-align': 'center', - }, - }, - - items: [ - { - itemId: 'title', - data: { - title: '', - }, - tpl: '

    {title}

    ', - }, - ], - - updateAll: function (list) { - var me = this; - me.suspendLayout = true; - - list.sort((a, b) => (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)); - if (!me.ids) { - me.ids = []; - } - let pendingRemoval = {}; - me.ids.forEach((id) => { - pendingRemoval[id] = true; - }); // mark all as to-remove first here - - for (let i = 0; i < list.length; i++) { - let service = me.getComponent(list[i].id); - if (!service) { - // services and list are sorted, so just insert at i + 1 (first el. is the title) - service = me.insert(i + 1, { - xtype: 'pveCephServiceWidget', - itemId: list[i].id, - }); - me.ids.push(list[i].id); - } else { - delete pendingRemoval[list[i].id]; // drop existing from for-removal - } - service.updateService(list[i].title, list[i].text, list[i].health); - } - Object.keys(pendingRemoval).forEach((id) => me.remove(id)); // GC - - me.suspendLayout = false; - me.updateLayout(); - }, - - initComponent: function () { - var me = this; - me.callParent(); - me.getComponent('title').update({ - title: me.title, - }); - }, -}); - -Ext.define('PVE.ceph.ServiceWidget', { - extend: 'Ext.Component', - alias: 'widget.pveCephServiceWidget', - - userCls: 'monitor inline-block', - data: { - title: '0', - health: 'HEALTH_ERR', - text: '', - iconCls: PVE.Utils.get_health_icon(), - }, - - tpl: ['{title}: ', ''], - - updateService: function (title, text, health) { - var me = this; - - me.update( - Ext.apply(me.data, { - health: health, - text: text, - title: title, - iconCls: PVE.Utils.get_health_icon(PVE.Utils.map_ceph_health[health]), - }), - ); - - if (me.tooltip) { - me.tooltip.setHtml(text); - } - }, - - listeners: { - destroy: function () { - let me = this; - if (me.tooltip) { - me.tooltip.destroy(); - delete me.tooltip; - } - }, - mouseenter: { - element: 'el', - fn: function (events, element) { - let view = this.component; - if (!view) { - return; - } - if (!view.tooltip || view.data.text !== view.tooltip.html) { - view.tooltip = Ext.create('Ext.tip.ToolTip', { - target: view.el, - trackMouse: true, - dismissDelay: 0, - renderTo: Ext.getBody(), - html: view.data.text, - }); - } - view.tooltip.show(); - }, - }, - mouseleave: { - element: 'el', - fn: function (events, element) { - let view = this.component; - if (view.tooltip) { - view.tooltip.destroy(); - delete view.tooltip; - } - }, - }, - }, -}); -Ext.define('pve-ceph-warnings', { - extend: 'Ext.data.Model', - fields: ['id', 'summary', 'detail', 'severity'], - idProperty: 'id', -}); - -Ext.define('PVE.node.CephStatus', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveNodeCephStatus', - - onlineHelp: 'chapter_pveceph', - - scrollable: true, - bodyPadding: 5, - layout: { - type: 'column', - }, - - defaults: { - padding: 5, - }, - - items: [ - { - xtype: 'panel', - title: gettext('Health'), - bodyPadding: 10, - plugins: 'responsive', - responsiveConfig: { - 'width < 1600': { - minHeight: 230, - columnWidth: 1, - }, - 'width >= 1600': { - minHeight: 500, - columnWidth: 0.5, - }, - }, - layout: { - type: 'hbox', - align: 'stretch', - }, - items: [ - { - xtype: 'container', - layout: { - type: 'vbox', - align: 'stretch', - }, - flex: 1, - items: [ - { - xtype: 'pveHealthWidget', - itemId: 'overallhealth', - flex: 1, - title: gettext('Status'), - }, - { - xtype: 'displayfield', - itemId: 'versioninfo', - fieldLabel: gettext('Ceph Version'), - value: '', - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'The newest version installed in the Cluster.', - ), - }, - padding: '10 0 0 0', - style: { - 'text-align': 'center', - }, - }, - ], - }, - { - xtype: 'grid', - itemId: 'warnings', - flex: 2, - maxHeight: 430, - stateful: true, - stateId: 'ceph-status-warnings', - viewConfig: { - enableTextSelection: true, - listeners: { - collapsebody: function (rowNode, record) { - record.set('expanded', false); - record.commit(); - }, - expandbody: function (rowNode, record) { - record.set('expanded', true); - record.commit(); - }, - }, - }, - // we load the store manually, to show an emptyText specify an empty intermediate store - store: { - type: 'diff', - trackRemoved: false, - data: [], - rstore: { - storeid: 'pve-ceph-warnings', - type: 'update', - model: 'pve-ceph-warnings', - }, - }, - updateHealth: function (health) { - let checks = health.checks || {}; - - let checkRecords = Object.keys(checks) - .sort() - .map((key) => { - let check = checks[key]; - let data = { - id: key, - summary: check.summary.message, - detail: check.detail - .reduce((acc, v) => `${acc}\n${v.message}`, '') - .trimStart(), - severity: check.severity, - }; - data.noDetails = data.detail.length === 0; - data.detailsCls = data.detail.length === 0 ? 'pmx-opacity-75' : ''; - if (data.detail.length === 0) { - data.detail = 'no additional data'; - } - return data; - }); - - let rstore = this.getStore().rstore; - rstore.loadData(checkRecords, false); - rstore.fireEvent('load', rstore, checkRecords, true); - }, - emptyText: gettext('No Warnings/Errors'), - columns: [ - { - dataIndex: 'severity', - tooltip: gettext('Severity'), - align: 'center', - width: 38, - renderer: function (value) { - let health = PVE.Utils.map_ceph_health[value]; - let icon = PVE.Utils.get_health_icon(health); - return ``; - }, - sorter: { - sorterFn: function (a, b) { - let health = ['HEALTH_ERR', 'HEALTH_WARN', 'HEALTH_OK']; - return ( - health.indexOf(b.data.severity) - - health.indexOf(a.data.severity) - ); - }, - }, - }, - { - dataIndex: 'summary', - header: gettext('Summary'), - renderer: function (value, metaData, record, rI, cI, store, view) { - if (record.get('expanded')) { - metaData.tdCls = 'pmx-column-wrapped'; - } - return Ext.htmlEncode(value); - }, - flex: 1, - }, - { - xtype: 'actioncolumn', - width: 50, - align: 'center', - tooltip: gettext('Actions'), - items: [ - { - iconCls: 'x-fa fa-clipboard', - tooltip: gettext('Copy to Clipboard'), - handler: function ( - grid, - rowindex, - colindex, - item, - e, - { data }, - ) { - let detail = data.noDetails ? '' : `\n${data.detail}`; - navigator.clipboard - .writeText(`${data.severity}: ${data.summary}${detail}`) - .catch((err) => Ext.Msg.alert(gettext('Error'), err)); - }, - }, - ], - }, - ], - listeners: { - itemdblclick: function (view, record, row, rowIdx, e) { - // inspired by Ext.grid.plugin.RowExpander, but for double click - let rowNode = view.getNode(rowIdx); - let normalRow = Ext.fly(rowNode); - - let collapsedCls = view.rowBodyFeature.rowCollapsedCls; - - if (normalRow.hasCls(collapsedCls)) { - view.rowBodyFeature.rowExpander.toggleRow(rowIdx, record); - } - }, - }, - plugins: [ - { - ptype: 'rowexpander', - expandOnDblClick: false, - scrollIntoViewOnExpand: false, - rowBodyTpl: [ - '
    ',
    -                                '{detail:htmlEncode}',
    -                                '
    ', - ], - }, - ], - }, - ], - }, - { - xtype: 'pveCephStatusDetail', - itemId: 'statusdetail', - plugins: 'responsive', - responsiveConfig: { - 'width < 1600': { - columnWidth: 1, - minHeight: 250, - }, - 'width >= 1600': { - columnWidth: 0.5, - minHeight: 300, - }, - }, - title: gettext('Status'), - }, - { - xtype: 'pveCephServices', - title: gettext('Services'), - itemId: 'services', - plugins: 'responsive', - layout: { - type: 'hbox', - align: 'stretch', - }, - responsiveConfig: { - 'width < 1600': { - columnWidth: 1, - minHeight: 200, - }, - 'width >= 1600': { - columnWidth: 0.5, - minHeight: 200, - }, - }, - }, - { - xtype: 'panel', - title: gettext('Performance'), - columnWidth: 1, - bodyPadding: 5, - layout: { - type: 'hbox', - align: 'center', - }, - items: [ - { - xtype: 'container', - flex: 1, - items: [ - { - xtype: 'proxmoxGauge', - itemId: 'space', - title: gettext('Usage'), - }, - { - flex: 1, - border: false, - }, - { - xtype: 'container', - itemId: 'recovery', - hidden: true, - padding: 25, - items: [ - { - xtype: 'pveRunningChart', - itemId: 'recoverychart', - title: gettext('Recovery') + '/ ' + gettext('Rebalance'), - renderer: PVE.Utils.render_bandwidth, - height: 100, - }, - { - xtype: 'progressbar', - itemId: 'recoveryprogress', - }, - ], - }, - ], - }, - { - xtype: 'container', - flex: 2, - defaults: { - padding: 0, - height: 100, - }, - items: [ - { - xtype: 'pveRunningChart', - itemId: 'reads', - title: gettext('Reads'), - renderer: PVE.Utils.render_bandwidth, - }, - { - xtype: 'pveRunningChart', - itemId: 'writes', - title: gettext('Writes'), - renderer: PVE.Utils.render_bandwidth, - }, - { - xtype: 'pveRunningChart', - itemId: 'readiops', - title: 'IOPS: ' + gettext('Reads'), - renderer: Ext.util.Format.numberRenderer('0,000'), - }, - { - xtype: 'pveRunningChart', - itemId: 'writeiops', - title: 'IOPS: ' + gettext('Writes'), - renderer: Ext.util.Format.numberRenderer('0,000'), - }, - ], - }, - ], - }, - ], - - updateAll: function (store, records, success) { - if (!success || records.length === 0) { - return; - } - - var me = this; - var rec = records[0]; - me.status = rec.data; - - // add health panel - me.down('#overallhealth').updateHealth(PVE.Utils.render_ceph_health(rec.data.health || {})); - me.down('#warnings').updateHealth(rec.data.health || {}); // add errors to gridstore - - me.getComponent('services').updateAll(me.metadata || {}, rec.data); - - me.getComponent('statusdetail').updateAll(me.metadata || {}, rec.data); - - // add performance data - let pgmap = rec.data.pgmap; - let used = pgmap.bytes_used; - let total = pgmap.bytes_total; - - var text = Ext.String.format( - gettext('{0} of {1}'), - Proxmox.Utils.render_size(used), - Proxmox.Utils.render_size(total), - ); - - // update the usage widget - const usage = total > 0 ? used / total : 0; - me.down('#space').updateValue(usage, text); - - let readiops = pgmap.read_op_per_sec; - let writeiops = pgmap.write_op_per_sec; - let reads = pgmap.read_bytes_sec || 0; - let writes = pgmap.write_bytes_sec || 0; - - // update the graphs - me.reads.addDataPoint(reads); - me.writes.addDataPoint(writes); - me.readiops.addDataPoint(readiops); - me.writeiops.addDataPoint(writeiops); - - let degraded = pgmap.degraded_objects || 0; - let misplaced = pgmap.misplaced_objects || 0; - let unfound = pgmap.unfound_objects || 0; - let unhealthy = degraded + unfound + misplaced; - // update recovery - if (pgmap.recovering_objects_per_sec !== undefined || unhealthy > 0) { - let toRecoverObjects = - pgmap.misplaced_total || pgmap.unfound_total || pgmap.degraded_total || 0; - if (toRecoverObjects === 0) { - return; // FIXME: unexpected return and leaves things possible visible when it shouldn't? - } - let recovered = toRecoverObjects - unhealthy || 0; - let speed = pgmap.recovering_bytes_per_sec || 0; - - let recoveryRatio = recovered / toRecoverObjects; - let txt = `${(recoveryRatio * 100).toFixed(2)}%`; - if (speed > 0) { - let obj_per_sec = speed / (4 * 1024 * 1024); // 4 MiB per Object - let duration = Proxmox.Utils.format_duration_human(unhealthy / obj_per_sec); - let speedTxt = PVE.Utils.render_bandwidth(speed); - txt += ` (${speedTxt} - ${duration} left)`; - } - - me.down('#recovery').setVisible(true); - me.down('#recoveryprogress').updateValue(recoveryRatio); - me.down('#recoveryprogress').updateText(txt); - me.down('#recoverychart').addDataPoint(speed); - } else { - me.down('#recovery').setVisible(false); - me.down('#recoverychart').addDataPoint(0); - } - }, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - - me.callParent(); - var baseurl = '/api2/json' + (nodename ? '/nodes/' + nodename : '/cluster') + '/ceph'; - me.store = Ext.create('Proxmox.data.UpdateStore', { - storeid: 'ceph-status-' + (nodename || 'cluster'), - interval: 5000, - proxy: { - type: 'proxmox', - url: baseurl + '/status', - }, - }); - - me.metadatastore = Ext.create('Proxmox.data.UpdateStore', { - storeid: 'ceph-metadata-' + (nodename || 'cluster'), - interval: 15 * 1000, - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/ceph/metadata', - }, - }); - - // save references for the updatefunction - me.iops = me.down('#iops'); - me.readiops = me.down('#readiops'); - me.writeiops = me.down('#writeiops'); - me.reads = me.down('#reads'); - me.writes = me.down('#writes'); - - // manages the "install ceph?" overlay - PVE.Utils.monitor_ceph_installed(me, me.store, nodename); - - me.mon(me.store, 'load', me.updateAll, me); - me.mon( - me.metadatastore, - 'load', - function (store, records, success) { - if (!success || records.length < 1) { - return; - } - me.metadata = records[0].data; - - // update services - me.getComponent('services').updateAll(me.metadata, me.status || {}); - - // update detailstatus panel - me.getComponent('statusdetail').updateAll(me.metadata, me.status || {}); - - let maxversion = []; - let maxversiontext = ''; - for (const [_nodename, data] of Object.entries(me.metadata.node)) { - let version = data.version.parts; - if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) { - maxversion = version; - maxversiontext = data.version.str; - } - } - me.down('#versioninfo').setValue(maxversiontext); - }, - me, - ); - - me.on('destroy', me.store.stopUpdate); - me.on('destroy', me.metadatastore.stopUpdate); - me.store.startUpdate(); - me.metadatastore.startUpdate(); - }, -}); -Ext.define('PVE.ceph.StatusDetail', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveCephStatusDetail', - - layout: { - type: 'hbox', - align: 'stretch', - }, - - bodyPadding: '0 5', - defaults: { - xtype: 'box', - style: { - 'text-align': 'center', - }, - }, - - items: [ - { - flex: 1, - itemId: 'osds', - maxHeight: 250, - scrollable: true, - padding: '0 10 5 10', - data: { - total: 0, - upin: 0, - upout: 0, - downin: 0, - downout: 0, - oldOSD: [], - ghostOSD: [], - }, - tpl: [ - '

    OSDs

    ', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '
    ', - gettext('In'), - '', - gettext('Out'), - '
    ', - gettext('Up'), - '{upin}{upout}
    ', - gettext('Down'), - '{downin}{downout}
    ', - '
    ', - gettext('Total'), - ': {total}', - '

    ', - '', - ' ' + gettext('Outdated OSDs') + '
    ', - '
    ', - '', - '
    osd.{id}:
    ', - '
    {version}

    ', - '
    ', - '
    ', - '
    ', - '
    ', - '', - '', - '
    ', - ` ${gettext('Ghost OSDs')}
    `, - `
    `, - '', - '
    osd.{id}
    ', - '
    ', - '
    ', - '
    ', - '
    ', - ], - }, - { - flex: 1, - border: false, - itemId: 'pgchart', - xtype: 'polar', - height: 184, - innerPadding: 5, - insetPadding: 5, - colors: ['#CFCFCF', '#21BF4B', '#3892d4', '#FFCC00', '#FF6C59'], - store: {}, - series: [ - { - type: 'pie', - donut: 60, - angleField: 'count', - tooltip: { - trackMouse: true, - renderer: function (tooltip, record, ctx) { - var html = record.get('text'); - html += '
    '; - record.get('states').forEach(function (state) { - html += '
    ' + state.state_name + ': ' + state.count.toString(); - }); - tooltip.setHtml(html); - }, - }, - subStyle: { - strokeStyle: false, - }, - }, - ], - }, - { - flex: 1.6, - itemId: 'pgs', - padding: '0 10', - maxHeight: 250, - scrollable: true, - data: { - states: [], - }, - tpl: [ - '

    PGs

    ', - '', - '
    {state_name}:
    ', - '
    {count}

    ', - '
    ', - '
    ', - ], - }, - ], - - // similar to mgr dashboard - pgstates: { - // clean - clean: 1, - active: 1, - - // busy - activating: 2, - backfill_wait: 2, - backfilling: 2, - creating: 2, - deep: 2, - forced_backfill: 2, - forced_recovery: 2, - peered: 2, - peering: 2, - recovering: 2, - recovery_wait: 2, - remapped: 2, - repair: 2, - scrubbing: 2, - snaptrim: 2, - snaptrim_wait: 2, - - // warning - degraded: 3, - undersized: 3, - - // critical - backfill_toofull: 4, - backfill_unfound: 4, - down: 4, - incomplete: 4, - inconsistent: 4, - recovery_toofull: 4, - recovery_unfound: 4, - snaptrim_error: 4, - stale: 4, - }, - - statecategories: [ - { - text: gettext('Unknown'), - count: 0, - states: [], - cls: 'faded', - }, - { - text: gettext('Clean'), - cls: 'good', - }, - { - text: gettext('Busy'), - cls: 'pve-ceph-status-busy', - }, - { - text: ngettext('Warning', 'Warnings', 1), - cls: 'warning', - }, - { - text: gettext('Critical'), - cls: 'critical', - }, - ], - - checkThemeColors: function () { - let me = this; - let rootStyle = getComputedStyle(document.documentElement); - - // get color - let background = rootStyle.getPropertyValue('--pwt-panel-background').trim() || '#ffffff'; - - // set the colors - me.chart.setBackground(background); - me.chart.redraw(); - }, - - updateAll: function (metadata, status) { - let me = this; - me.suspendLayout = true; - - let maxversion = '0'; - Object.values(metadata.node || {}).forEach(function (node) { - if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) { - maxversion = node.version.parts; - } - }); - - let oldOSD = [], - ghostOSD = []; - metadata.osd?.forEach((osd) => { - let version = PVE.Utils.parse_ceph_version(osd); - if (version !== undefined) { - if (PVE.Utils.compare_ceph_versions(version, maxversion) !== 0) { - oldOSD.push({ - id: osd.id, - version: version, - }); - } - } else { - if (Object.keys(osd).length > 1) { - console.warn('got OSD entry with no valid version but other keys', osd); - } - ghostOSD.push({ - id: osd.id, - }); - } - }); - - // update PGs sorted - let pgmap = status.pgmap || {}; - let pgs_by_state = pgmap.pgs_by_state || []; - pgs_by_state.sort(function (a, b) { - return a.state_name < b.state_name ? -1 : a.state_name === b.state_name ? 0 : 1; - }); - - me.statecategories.forEach(function (cat) { - cat.count = 0; - cat.states = []; - }); - - pgs_by_state.forEach(function (state) { - let states = state.state_name.split(/[^a-z]+/); - let result = 0; - for (let i = 0; i < states.length; i++) { - if (me.pgstates[states[i]] > result) { - result = me.pgstates[states[i]]; - } - } - // for the list - state.cls = me.statecategories[result].cls; - - me.statecategories[result].count += state.count; - me.statecategories[result].states.push(state); - }); - - me.chart.getStore().setData(me.statecategories); - me.getComponent('pgs').update({ states: pgs_by_state }); - - let health = status.health || {}; - // we collect monitor/osd information from the checks - const downinregex = /(\d+) osds down/; - let downin_osds = 0; - Ext.Object.each(health.checks, function (key, value, obj) { - var found = null; - if (key === 'OSD_DOWN') { - found = value.summary.message.match(downinregex); - if (found !== null) { - downin_osds = parseInt(found[1], 10); - } - } - }); - - let osdmap = status.osdmap || {}; - if (typeof osdmap.osdmap !== 'undefined') { - osdmap = osdmap.osdmap; - } - // update OSDs counts - let total_osds = osdmap.num_osds || 0; - let in_osds = osdmap.num_in_osds || 0; - let up_osds = osdmap.num_up_osds || 0; - let down_osds = total_osds - up_osds; - - let downout_osds = down_osds - downin_osds; - let upin_osds = in_osds - downin_osds; - let upout_osds = up_osds - upin_osds; - - let osds = { - total: total_osds, - upin: upin_osds, - upout: upout_osds, - downin: downin_osds, - downout: downout_osds, - oldOSD: oldOSD, - ghostOSD, - }; - let osdcomponent = me.getComponent('osds'); - osdcomponent.update(Ext.apply(osdcomponent.data, osds)); - - me.suspendLayout = false; - me.updateLayout(); - }, - - initComponent: function () { - var me = this; - me.callParent(); - - me.chart = me.getComponent('pgchart'); - me.checkThemeColors(); - - // switch colors on media query changes - me.mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); - me.themeListener = (e) => { - me.checkThemeColors(); - }; - me.mediaQueryList.addEventListener('change', me.themeListener); - }, - - doDestroy: function () { - let me = this; - - me.mediaQueryList.removeEventListener('change', me.themeListener); - - me.callParent(); - }, -}); -Ext.define('PVE.node.ACMEAccountCreate', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - width: 450, - title: gettext('Register Account'), - isCreate: true, - method: 'POST', - submitText: gettext('Register'), - url: '/cluster/acme/account', - showTaskViewer: true, - defaultExists: false, - referenceHolder: true, - onlineHelp: 'sysadmin_certs_acme_account', - - viewModel: { - data: { - customDirectory: false, - eabRequired: false, - }, - formulas: { - eabEmptyText: function (get) { - return get('eabRequired') ? gettext('required') : gettext('optional'); - }, - }, - }, - - items: [ - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Account Name'), - name: 'name', - cbind: { - emptyText: (get) => (get('defaultExists') ? '' : 'default'), - allowBlank: (get) => !get('defaultExists'), - }, - }, - { - xtype: 'textfield', - name: 'contact', - vtype: 'email', - allowBlank: false, - fieldLabel: gettext('E-Mail'), - }, - { - xtype: 'proxmoxComboGrid', - notFoundIsValid: true, - isFormField: false, - allowBlank: false, - valueField: 'url', - displayField: 'name', - fieldLabel: gettext('ACME Directory'), - store: { - listeners: { - load: function () { - this.add({ name: gettext('Custom'), url: '' }); - }, - }, - autoLoad: true, - fields: ['name', 'url'], - idProperty: ['name'], - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/acme/directories', - }, - }, - listConfig: { - columns: [ - { - header: gettext('Name'), - dataIndex: 'name', - flex: 1, - }, - { - header: gettext('URL'), - dataIndex: 'url', - flex: 1, - }, - ], - }, - listeners: { - change: function (combogrid, value) { - let me = this; - - let vm = me.up('window').getViewModel(); - let dirField = me.up('window').lookupReference('directoryInput'); - let tosButton = me.up('window').lookupReference('queryTos'); - - let isCustom = combogrid.getSelection().get('name') === gettext('Custom'); - vm.set('customDirectory', isCustom); - - dirField.setValue(value); - - if (!isCustom) { - tosButton.click(); - } else { - me.up('window').clearToSFields(); - } - }, - }, - }, - { - xtype: 'fieldcontainer', - layout: 'hbox', - fieldLabel: gettext('URL'), - bind: { - hidden: '{!customDirectory}', - }, - items: [ - { - xtype: 'proxmoxtextfield', - name: 'directory', - reference: 'directoryInput', - flex: 1, - allowBlank: false, - listeners: { - change: function (textbox, value) { - let me = this; - me.up('window').clearToSFields(); - }, - }, - }, - { - xtype: 'proxmoxButton', - margin: '0 0 0 5', - reference: 'queryTos', - text: gettext('Query URL'), - listeners: { - click: function (button) { - let me = this; - - let w = me.up('window'); - let vm = w.getViewModel(); - let disp = w.down('#tos_url_display'); - let field = w.down('#tos_url'); - let checkbox = w.down('#tos_checkbox'); - let value = w.lookupReference('directoryInput').getValue(); - w.clearToSFields(); - - if (!value) { - return; - } else { - disp.setValue(gettext('Loading')); - } - - Proxmox.Utils.API2Request({ - url: '/cluster/acme/meta', - method: 'GET', - params: { - directory: value, - }, - success: function (response, opt) { - if ( - response.result.data && - response.result.data.termsOfService - ) { - field.setValue(response.result.data.termsOfService); - disp.setValue(response.result.data.termsOfService); - checkbox.setHidden(false); - } else { - // Needed to pass input verification and enable register button - // has no influence on the submitted form - checkbox.setValue(true); - disp.setValue('No terms of service agreement required'); - } - vm.set( - 'eabRequired', - !!( - response.result.data && - response.result.data.externalAccountRequired - ), - ); - }, - failure: function (response, opt) { - disp.setValue(undefined); - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - }, - }, - ], - }, - { - xtype: 'displayfield', - itemId: 'tos_url_display', - renderer: PVE.Utils.render_optional_url, - name: 'tos_url_display', - }, - { - xtype: 'hidden', - itemId: 'tos_url', - name: 'tos_url', - }, - { - xtype: 'proxmoxcheckbox', - itemId: 'tos_checkbox', - boxLabel: gettext('Accept TOS'), - submitValue: false, - validateValue: function (value) { - if (value && this.checked) { - return true; - } - return false; - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'eab-kid', - fieldLabel: gettext('EAB Key ID'), - bind: { - hidden: '{!customDirectory}', - allowBlank: '{!eabRequired}', - emptyText: '{eabEmptyText}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'eab-hmac-key', - fieldLabel: gettext('EAB Key'), - bind: { - hidden: '{!customDirectory}', - allowBlank: '{!eabRequired}', - emptyText: '{eabEmptyText}', - }, - }, - ], - - clearToSFields: function () { - let me = this; - - let disp = me.down('#tos_url_display'); - let field = me.down('#tos_url'); - let checkbox = me.down('#tos_checkbox'); - - disp.setValue('Terms of service not fetched yet'); - field.setValue(undefined); - checkbox.setValue(undefined); - checkbox.setHidden(true); - }, -}); - -Ext.define('PVE.node.ACMEDomainEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveACMEDomainEdit', - - subject: gettext('Domain'), - isCreate: false, - width: 450, - onlineHelp: 'sysadmin_certificate_management', - - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - let me = this; - let win = me.up('pveACMEDomainEdit'); - let nodeconfig = win.nodeconfig; - let olddomain = win.domain || {}; - - let params = { - digest: nodeconfig.digest, - }; - - let configkey = olddomain.configkey; - let acmeObj = PVE.Parser.parseACME(nodeconfig.acme); - - if (values.type === 'dns') { - if (!olddomain.configkey || olddomain.configkey === 'acme') { - // look for first free slot - for (let i = 0; i < PVE.Utils.acmedomain_count; i++) { - if (nodeconfig[`acmedomain${i}`] === undefined) { - configkey = `acmedomain${i}`; - break; - } - } - if (olddomain.domain) { - // we have to remove the domain from the acme domainlist - PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); - params.acme = PVE.Parser.printACME(acmeObj); - } - } - - delete values.type; - params[configkey] = PVE.Parser.printPropertyString(values, 'domain'); - } else { - if (olddomain.configkey && olddomain.configkey !== 'acme') { - // delete the old dns entry - params.delete = [olddomain.configkey]; - } - - // add new, remove old and make entries unique - PVE.Utils.add_domain_to_acme(acmeObj, values.domain); - PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); - params.acme = PVE.Parser.printACME(acmeObj); - } - - return params; - }, - items: [ - { - xtype: 'proxmoxKVComboBox', - name: 'type', - fieldLabel: gettext('Challenge Type'), - allowBlank: false, - value: 'standalone', - comboItems: [ - ['standalone', 'HTTP'], - ['dns', 'DNS'], - ], - validator: function (value) { - let me = this; - let win = me.up('pveACMEDomainEdit'); - let oldconfigkey = win.domain ? win.domain.configkey : undefined; - let val = me.getValue(); - if (val === 'dns' && (!oldconfigkey || oldconfigkey === 'acme')) { - // we have to check if there is a 'acmedomain' slot left - let found = false; - for (let i = 0; i < PVE.Utils.acmedomain_count; i++) { - if (!win.nodeconfig[`acmedomain${i}`]) { - found = true; - } - } - if (!found) { - return gettext('Only 5 Domains with type DNS can be configured'); - } - } - - return true; - }, - listeners: { - change: function (cb, value) { - let me = this; - let view = me.up('pveACMEDomainEdit'); - let pluginField = view.down('field[name=plugin]'); - pluginField.setDisabled(value !== 'dns'); - pluginField.setHidden(value !== 'dns'); - }, - }, - }, - { - xtype: 'hidden', - name: 'alias', - }, - { - xtype: 'pveACMEPluginSelector', - name: 'plugin', - disabled: true, - hidden: true, - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'domain', - allowBlank: false, - vtype: 'DnsName', - value: '', - fieldLabel: gettext('Domain'), - }, - ], - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no nodename given'; - } - - if (!me.nodeconfig) { - throw 'no nodeconfig given'; - } - - me.isCreate = !me.domain; - if (me.isCreate) { - me.domain = `${me.nodename}.`; // TODO: FQDN of node - } - - me.url = `/api2/extjs/nodes/${me.nodename}/config`; - - me.callParent(); - - if (!me.isCreate) { - me.setValues(me.domain); - } else { - me.setValues({ domain: me.domain }); - } - }, -}); - -Ext.define('pve-acme-domains', { - extend: 'Ext.data.Model', - fields: ['domain', 'type', 'alias', 'plugin', 'configkey'], - idProperty: 'domain', -}); - -Ext.define('PVE.node.ACME', { - extend: 'Ext.grid.Panel', - alias: 'widget.pveACMEView', - - margin: '10 0 0 0', - title: 'ACME', - - emptyText: gettext('No Domains configured'), - - viewModel: { - data: { - domaincount: 0, - account: undefined, // the account we display - configaccount: undefined, // the account set in the config - accountEditable: false, - accountsAvailable: false, - }, - - formulas: { - canOrder: (get) => !!get('account') && get('domaincount') > 0, - editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'), - editBtnText: (get) => (get('accountEditable') ? gettext('Apply') : gettext('Edit')), - accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'), - accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'), - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - let accountSelector = this.lookup('accountselector'); - accountSelector.store.on('load', this.onAccountsLoad, this); - }, - - onAccountsLoad: function (store, records, success) { - let me = this; - let vm = me.getViewModel(); - let configaccount = vm.get('configaccount'); - vm.set('accountsAvailable', records.length > 0); - if (me.autoChangeAccount && records.length > 0) { - me.changeAccount(records[0].data.name, () => { - vm.set('accountEditable', false); - me.reload(); - }); - me.autoChangeAccount = false; - } else if (configaccount) { - if (store.findExact('name', configaccount) !== -1) { - vm.set('account', configaccount); - } else { - vm.set('account', null); - } - } - }, - - addDomain: function () { - let me = this; - let view = me.getView(); - - Ext.create('PVE.node.ACMEDomainEdit', { - nodename: view.nodename, - nodeconfig: view.nodeconfig, - apiCallDone: function () { - me.reload(); - }, - }).show(); - }, - - editDomain: function () { - let me = this; - let view = me.getView(); - - let selection = view.getSelection(); - if (selection.length < 1) { - return; - } - - Ext.create('PVE.node.ACMEDomainEdit', { - nodename: view.nodename, - nodeconfig: view.nodeconfig, - domain: selection[0].data, - apiCallDone: function () { - me.reload(); - }, - }).show(); - }, - - removeDomain: function () { - let me = this; - let view = me.getView(); - let selection = view.getSelection(); - if (selection.length < 1) { - return; - } - - let rec = selection[0].data; - let params = {}; - if (rec.configkey !== 'acme') { - params.delete = rec.configkey; - } else { - let acme = PVE.Parser.parseACME(view.nodeconfig.acme); - PVE.Utils.remove_domain_from_acme(acme, rec.domain); - params.acme = PVE.Parser.printACME(acme); - } - - Proxmox.Utils.API2Request({ - method: 'PUT', - url: `/nodes/${view.nodename}/config`, - params, - success: function (response, opt) { - me.reload(); - }, - failure: function (response, opt) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - - toggleEditAccount: function () { - let me = this; - let vm = me.getViewModel(); - let editable = vm.get('accountEditable'); - if (editable) { - me.changeAccount(vm.get('account'), function () { - vm.set('accountEditable', false); - me.reload(); - }); - } else { - vm.set('accountEditable', true); - } - }, - - changeAccount: function (account, callback) { - let me = this; - let view = me.getView(); - let params = {}; - - let acme = PVE.Parser.parseACME(view.nodeconfig.acme); - acme.account = account; - params.acme = PVE.Parser.printACME(acme); - - Proxmox.Utils.API2Request({ - method: 'PUT', - waitMsgTarget: view, - url: `/nodes/${view.nodename}/config`, - params, - success: function (response, opt) { - if (Ext.isFunction(callback)) { - callback(); - } - }, - failure: function (response, opt) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - - order: function () { - let me = this; - let view = me.getView(); - - Proxmox.Utils.API2Request({ - method: 'POST', - params: { - force: 1, - }, - url: `/nodes/${view.nodename}/certificates/acme/certificate`, - success: function (response, opt) { - Ext.create('Proxmox.window.TaskViewer', { - upid: response.result.data, - taskDone: function (success) { - me.orderFinished(success); - }, - }).show(); - }, - failure: function (response, opt) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - - orderFinished: function (success) { - if (!success) { - return; - } - // reload only if the Web UI is open on the same node that the cert was ordered for - if (this.getView().nodename !== Proxmox.NodeName) { - return; - } - var txt = gettext( - 'pveproxy will be restarted with new certificates, please reload the GUI!', - ); - Ext.getBody().mask(txt, ['pve-static-mask']); - // reload after 10 seconds automatically - Ext.defer(function () { - window.location.reload(true); - }, 10000); - }, - - reload: function () { - let me = this; - let view = me.getView(); - view.rstore.load(); - }, - - addAccount: function () { - let me = this; - Ext.create('PVE.node.ACMEAccountCreate', { - autoShow: true, - taskDone: function () { - me.reload(); - let accountSelector = me.lookup('accountselector'); - me.autoChangeAccount = true; - accountSelector.store.load(); - }, - }); - }, - }, - - tbar: [ - { - xtype: 'proxmoxButton', - text: gettext('Add'), - handler: 'addDomain', - selModel: false, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - disabled: true, - handler: 'editDomain', - }, - { - xtype: 'proxmoxStdRemoveButton', - handler: 'removeDomain', - }, - '-', - { - xtype: 'button', - reference: 'order', - text: gettext('Order Certificates Now'), - bind: { - disabled: '{!canOrder}', - }, - handler: 'order', - }, - '-', - { - xtype: 'displayfield', - value: gettext('Using Account') + ':', - bind: { - hidden: '{!accountsAvailable}', - }, - }, - { - xtype: 'displayfield', - reference: 'accounttext', - renderer: (val) => val || Proxmox.Utils.NoneText, - bind: { - value: '{account}', - hidden: '{accountTextHidden}', - }, - }, - { - xtype: 'pveACMEAccountSelector', - hidden: true, - reference: 'accountselector', - bind: { - value: '{account}', - hidden: '{accountValueHidden}', - }, - }, - { - xtype: 'button', - iconCls: 'fa black fa-pencil', - bind: { - iconCls: '{editBtnIcon}', - text: '{editBtnText}', - hidden: '{!accountsAvailable}', - }, - handler: 'toggleEditAccount', - }, - { - xtype: 'displayfield', - value: gettext('No Account available.'), - bind: { - hidden: '{accountsAvailable}', - }, - }, - { - xtype: 'button', - hidden: true, - reference: 'accountlink', - text: gettext('Add ACME Account'), - bind: { - hidden: '{accountsAvailable}', - }, - handler: 'addAccount', - }, - ], - - updateStore: function (store, records, success) { - let me = this; - let data = []; - let rec; - if (success && records.length > 0) { - rec = records[0]; - } else { - rec = { - data: {}, - }; - } - - me.nodeconfig = rec.data; // save nodeconfig for updates - - let account = 'default'; - - if (rec.data.acme) { - let obj = PVE.Parser.parseACME(rec.data.acme); - (obj.domains || []).forEach((domain) => { - if (domain === '') { - return; - } - let record = { - domain, - type: 'standalone', - configkey: 'acme', - }; - data.push(record); - }); - - if (obj.account) { - account = obj.account; - } - } - - let vm = me.getViewModel(); - let oldaccount = vm.get('account'); - - // account changed, and we do not edit currently, load again to verify - if (oldaccount !== account && !vm.get('accountEditable')) { - vm.set('configaccount', account); - me.lookup('accountselector').store.load(); - } - - for (let i = 0; i < PVE.Utils.acmedomain_count; i++) { - let acmedomain = rec.data[`acmedomain${i}`]; - if (!acmedomain) { - continue; - } - - let record = PVE.Parser.parsePropertyString(acmedomain, 'domain'); - record.type = 'dns'; - record.configkey = `acmedomain${i}`; - data.push(record); - } - - vm.set('domaincount', data.length); - me.store.loadData(data, false); - }, - - listeners: { - itemdblclick: 'editDomain', - }, - - columns: [ - { - dataIndex: 'domain', - flex: 5, - text: gettext('Domain'), - }, - { - dataIndex: 'type', - flex: 1, - text: gettext('Type'), - }, - { - dataIndex: 'plugin', - flex: 1, - text: gettext('Plugin'), - }, - ], - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no nodename given'; - } - - me.rstore = Ext.create('Proxmox.data.UpdateStore', { - interval: 10 * 1000, - autoStart: true, - storeid: `pve-node-domains-${me.nodename}`, - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/config`, - }, - }); - - me.store = Ext.create('Ext.data.Store', { - model: 'pve-acme-domains', - sorters: 'domain', - }); - - me.callParent(); - me.mon(me.rstore, 'load', 'updateStore', me); - Proxmox.Utils.monStoreErrors(me, me.rstore); - me.on('destroy', me.rstore.stopUpdate, me.rstore); - }, -}); -Ext.define('PVE.node.CertificateView', { - extend: 'Ext.container.Container', - xtype: 'pveCertificatesView', - - onlineHelp: 'sysadmin_certificate_management', - - mixins: ['Proxmox.Mixin.CBind'], - scrollable: 'y', - - items: [ - { - xtype: 'pveCertView', - border: 0, - cbind: { - nodename: '{nodename}', - }, - }, - { - xtype: 'pveACMEView', - border: 0, - cbind: { - nodename: '{nodename}', - }, - }, - ], -}); - -Ext.define('PVE.node.CertificateViewer', { - extend: 'Proxmox.window.Edit', - - title: gettext('Certificate'), - - fieldDefaults: { - labelWidth: 120, - }, - width: 800, - - items: { - xtype: 'inputpanel', - maxHeight: 900, - scrollable: 'y', - columnT: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Name'), - name: 'filename', - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Fingerprint'), - name: 'fingerprint', - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Issuer'), - name: 'issuer', - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Subject'), - name: 'subject', - }, - ], - column1: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Public Key Type'), - name: 'public-key-type', - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Public Key Size'), - name: 'public-key-bits', - }, - ], - column2: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Valid Since'), - renderer: Proxmox.Utils.render_timestamp, - name: 'notbefore', - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Expires'), - renderer: Proxmox.Utils.render_timestamp, - name: 'notafter', - }, - ], - columnB: [ - { - xtype: 'displayfield', - fieldLabel: gettext('Subject Alternative Names'), - name: 'san', - renderer: PVE.Utils.render_san, - }, - { - xtype: 'fieldset', - title: gettext('Raw Certificate'), - collapsible: true, - collapsed: true, - items: [ - { - xtype: 'textarea', - name: 'pem', - editable: false, - grow: true, - growMax: 350, - fieldStyle: { - 'white-space': 'pre-wrap', - 'font-family': 'monospace', - }, - }, - ], - }, - ], - }, - - initComponent: function () { - let me = this; - - if (!me.cert) { - throw 'no cert given'; - } - if (!me.nodename) { - throw 'no nodename given'; - } - - me.url = `/nodes/${me.nodename}/certificates/info`; - me.callParent(); - - // hide OK/Reset button, because we just want to show data - me.down('toolbar[dock=bottom]').setVisible(false); - - me.load({ - success: function (response) { - if (Ext.isArray(response.result.data)) { - for (const item of response.result.data) { - if (item.filename === me.cert) { - me.setValues(item); - return; - } - } - } - }, - }); - }, -}); - -Ext.define('PVE.node.CertUpload', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCertUpload', - - title: gettext('Upload Custom Certificate'), - resizable: false, - isCreate: true, - submitText: gettext('Upload'), - method: 'POST', - width: 600, - - apiCallDone: function (success, response, options) { - if (!success) { - return; - } - let txt = gettext( - 'API server will be restarted to use new certificates, please reload web-interface!', - ); - Ext.getBody().mask(txt, ['pve-static-mask']); - Ext.defer(() => window.location.reload(true), 10000); // reload after 10 seconds automatically - }, - - items: { - xtype: 'inputpanel', - onGetValues: function (values) { - values.restart = 1; - values.force = 1; - if (!values.key) { - delete values.key; - } - return values; - }, - items: [ - { - fieldLabel: gettext('Private Key (Optional)'), - labelAlign: 'top', - emptyText: gettext('No change'), - name: 'key', - xtype: 'textarea', - }, - { - xtype: 'filebutton', - text: gettext('From File'), - listeners: { - change: function (btn, e, value) { - let form = this.up('form'); - for (const file of e.event.target.files) { - PVE.Utils.loadFile(file, (res) => - form.down('field[name=key]').setValue(res), - ); - } - btn.reset(); - }, - }, - }, - { - fieldLabel: gettext('Certificate Chain'), - labelAlign: 'top', - allowBlank: false, - name: 'certificates', - xtype: 'textarea', - }, - { - xtype: 'filebutton', - text: gettext('From File'), - listeners: { - change: function (btn, e, value) { - let form = this.up('form'); - for (const file of e.event.target.files) { - PVE.Utils.loadFile(file, (res) => - form.down('field[name=certificates]').setValue(res), - ); - } - btn.reset(); - }, - }, - }, - ], - }, - - initComponent: function () { - let me = this; - if (!me.nodename) { - throw 'no nodename given'; - } - me.url = `/nodes/${me.nodename}/certificates/custom`; - - me.callParent(); - }, -}); - -Ext.define('pve-certificate', { - extend: 'Ext.data.Model', - fields: [ - 'filename', - 'fingerprint', - 'issuer', - 'notafter', - 'notbefore', - 'subject', - 'san', - 'public-key-bits', - 'public-key-type', - ], - idProperty: 'filename', -}); - -Ext.define('PVE.node.Certificates', { - extend: 'Ext.grid.Panel', - xtype: 'pveCertView', - - tbar: [ - { - xtype: 'button', - text: gettext('Upload Custom Certificate'), - handler: function () { - let view = this.up('grid'); - Ext.create('PVE.node.CertUpload', { - nodename: view.nodename, - listeners: { - destroy: () => view.reload(), - }, - autoShow: true, - }); - }, - }, - { - xtype: 'proxmoxStdRemoveButton', - itemId: 'deletebtn', - text: gettext('Delete Custom Certificate'), - dangerous: true, - selModel: false, - getUrl: function (rec) { - let view = this.up('grid'); - return `/nodes/${view.nodename}/certificates/custom?restart=1`; - }, - confirmMsg: gettext('Delete custom certificate and switch to generated one?'), - callback: function (options, success, response) { - if (success) { - let txt = gettext( - 'API server will be restarted to use new certificates, please reload web-interface!', - ); - Ext.getBody().mask(txt, ['pve-static-mask']); - // reload after 10 seconds automatically - Ext.defer(() => window.location.reload(true), 10000); - } - }, - }, - '-', - { - xtype: 'proxmoxButton', - itemId: 'viewbtn', - disabled: true, - text: gettext('View Certificate'), - handler: function () { - this.up('grid').viewCertificate(); - }, - }, - ], - - columns: [ - { - header: gettext('File'), - width: 150, - dataIndex: 'filename', - }, - { - header: gettext('Issuer'), - flex: 1, - dataIndex: 'issuer', - }, - { - header: gettext('Subject'), - flex: 1, - dataIndex: 'subject', - }, - { - header: gettext('Public Key Algorithm'), - flex: 1, - dataIndex: 'public-key-type', - hidden: true, - }, - { - header: gettext('Public Key Size'), - flex: 1, - dataIndex: 'public-key-bits', - hidden: true, - }, - { - header: gettext('Valid Since'), - width: 150, - dataIndex: 'notbefore', - renderer: Proxmox.Utils.render_timestamp, - }, - { - header: gettext('Expires'), - width: 150, - dataIndex: 'notafter', - renderer: Proxmox.Utils.render_timestamp, - }, - { - header: gettext('Subject Alternative Names'), - flex: 1, - dataIndex: 'san', - renderer: PVE.Utils.render_san, - }, - { - header: gettext('Fingerprint'), - dataIndex: 'fingerprint', - hidden: true, - }, - { - header: gettext('PEM'), - dataIndex: 'pem', - hidden: true, - }, - ], - - reload: function () { - this.rstore.load(); - }, - - viewCertificate: function () { - let me = this; - let selection = me.getSelection(); - if (!selection || selection.length < 1) { - return; - } - var win = Ext.create('PVE.node.CertificateViewer', { - cert: selection[0].data.filename, - nodename: me.nodename, - }); - win.show(); - }, - - listeners: { - itemdblclick: 'viewCertificate', - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no nodename given'; - } - - me.rstore = Ext.create('Proxmox.data.UpdateStore', { - storeid: 'certs-' + me.nodename, - model: 'pve-certificate', - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/certificates/info', - }, - }); - - me.store = { - type: 'diff', - rstore: me.rstore, - }; - - me.callParent(); - - me.mon(me.rstore, 'load', (store) => - me.down('#deletebtn').setDisabled(!store.getById('pveproxy-ssl.pem')), - ); - me.rstore.startUpdate(); - me.on('destroy', me.rstore.stopUpdate, me.rstore); - }, -}); -Ext.define('PVE.node.CmdMenu', { - extend: 'Ext.menu.Menu', - xtype: 'nodeCmdMenu', - - showSeparator: false, - - items: [ - { - text: gettext('Create VM'), - itemId: 'createvm', - iconCls: 'fa fa-desktop', - handler: function () { - Ext.create('PVE.qemu.CreateWizard', { - nodename: this.up('menu').nodename, - autoShow: true, - }); - }, - }, - { - text: gettext('Create CT'), - itemId: 'createct', - iconCls: 'fa fa-cube', - handler: function () { - Ext.create('PVE.lxc.CreateWizard', { - nodename: this.up('menu').nodename, - autoShow: true, - }); - }, - }, - { xtype: 'menuseparator' }, - { - text: gettext('Bulk Start'), - itemId: 'bulkstart', - iconCls: 'fa fa-fw fa-play', - handler: function () { - Ext.create('PVE.window.BulkAction', { - nodename: this.up('menu').nodename, - title: gettext('Bulk Start'), - btnText: gettext('Start'), - action: 'startall', - autoShow: true, - }); - }, - }, - { - text: gettext('Bulk Shutdown'), - itemId: 'bulkstop', - iconCls: 'fa fa-fw fa-stop', - handler: function () { - Ext.create('PVE.window.BulkAction', { - nodename: this.up('menu').nodename, - title: gettext('Bulk Shutdown'), - btnText: gettext('Shutdown'), - action: 'stopall', - autoShow: true, - }); - }, - }, - { - text: gettext('Bulk Suspend'), - itemId: 'bulksuspend', - iconCls: 'fa fa-fw fa-download', - handler: function () { - Ext.create('PVE.window.BulkAction', { - nodename: this.up('menu').nodename, - title: gettext('Bulk Suspend'), - btnText: gettext('Suspend'), - action: 'suspendall', - autoShow: true, - }); - }, - }, - { - text: gettext('Bulk Migrate'), - itemId: 'bulkmigrate', - iconCls: 'fa fa-fw fa-send-o', - handler: function () { - Ext.create('PVE.window.BulkAction', { - nodename: this.up('menu').nodename, - title: gettext('Bulk Migrate'), - btnText: gettext('Migrate'), - action: 'migrateall', - autoShow: true, - }); - }, - }, - { xtype: 'menuseparator' }, - { - text: gettext('Shell'), - itemId: 'shell', - iconCls: 'fa fa-fw fa-terminal', - handler: function () { - let nodename = this.up('menu').nodename; - PVE.Utils.openDefaultConsoleWindow(true, 'shell', undefined, nodename, undefined); - }, - }, - { xtype: 'menuseparator' }, - { - text: gettext('Wake-on-LAN'), - itemId: 'wakeonlan', - iconCls: 'fa fa-fw fa-power-off', - handler: function () { - let nodename = this.up('menu').nodename; - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/wakeonlan`, - method: 'POST', - failure: (response, opts) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function (response, opts) { - Ext.Msg.show({ - title: 'Success', - icon: Ext.Msg.INFO, - msg: Ext.String.format( - gettext("Wake on LAN packet send for '{0}': '{1}'"), - nodename, - response.result.data, - ), - }); - }, - }); - }, - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no nodename specified'; - } - - me.title = gettext('Node') + " '" + me.nodename + "'"; - me.callParent(); - - let caps = Ext.state.Manager.get('GuiCap'); - - if (!caps.vms['VM.Allocate']) { - me.getComponent('createct').setDisabled(true); - me.getComponent('createvm').setDisabled(true); - } - if (!caps.vms['VM.Migrate']) { - me.getComponent('bulkmigrate').setDisabled(true); - } - if (!caps.vms['VM.PowerMgmt']) { - me.getComponent('bulkstart').setDisabled(true); - me.getComponent('bulkstop').setDisabled(true); - me.getComponent('bulksuspend').setDisabled(true); - } - if (!caps.nodes['Sys.PowerMgmt']) { - me.getComponent('wakeonlan').setDisabled(true); - } - if (!caps.nodes['Sys.Console']) { - me.getComponent('shell').setDisabled(true); - } - if (me.pveSelNode.data.running) { - me.getComponent('wakeonlan').setDisabled(true); - } - - if (PVE.Utils.isStandaloneNode()) { - me.getComponent('bulkmigrate').setVisible(false); - } - }, -}); -Ext.define('PVE.node.Config', { - extend: 'PVE.panel.Config', - alias: 'widget.PVE.node.Config', - - onlineHelp: 'chapter_system_administration', - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var caps = Ext.state.Manager.get('GuiCap'); - - me.statusStore = Ext.create('Proxmox.data.ObjectStore', { - url: '/api2/json/nodes/' + nodename + '/status', - interval: 5000, - }); - - var node_command = function (cmd) { - Proxmox.Utils.API2Request({ - params: { command: cmd }, - url: '/nodes/' + nodename + '/status', - method: 'POST', - waitMsgTarget: me, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }; - - var actionBtn = Ext.create('Ext.Button', { - text: gettext('Bulk Actions'), - iconCls: 'fa fa-fw fa-ellipsis-v', - disabled: !caps.vms['VM.PowerMgmt'] && !caps.vms['VM.Migrate'], - menu: new Ext.menu.Menu({ - items: [ - { - text: gettext('Bulk Start'), - iconCls: 'fa fa-fw fa-play', - disabled: !caps.vms['VM.PowerMgmt'], - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - nodename: nodename, - title: gettext('Bulk Start'), - btnText: gettext('Start'), - action: 'startall', - }); - }, - }, - { - text: gettext('Bulk Shutdown'), - iconCls: 'fa fa-fw fa-stop', - disabled: !caps.vms['VM.PowerMgmt'], - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - nodename: nodename, - title: gettext('Bulk Shutdown'), - btnText: gettext('Shutdown'), - action: 'stopall', - }); - }, - }, - { - text: gettext('Bulk Suspend'), - iconCls: 'fa fa-fw fa-download', - disabled: !caps.vms['VM.PowerMgmt'], - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - nodename: nodename, - title: gettext('Bulk Suspend'), - btnText: gettext('Suspend'), - action: 'suspendall', - }); - }, - }, - { - text: gettext('Bulk Migrate'), - iconCls: 'fa fa-fw fa-send-o', - disabled: !caps.vms['VM.Migrate'], - hidden: PVE.Utils.isStandaloneNode(), - handler: function () { - Ext.create('PVE.window.BulkAction', { - autoShow: true, - nodename: nodename, - title: gettext('Bulk Migrate'), - btnText: gettext('Migrate'), - action: 'migrateall', - }); - }, - }, - ], - }), - }); - - let restartBtn = Ext.create('Proxmox.button.Button', { - text: gettext('Reboot'), - disabled: !caps.nodes['Sys.PowerMgmt'], - dangerous: true, - confirmMsg: Ext.String.format(gettext("Reboot node '{0}'?"), nodename), - handler: function () { - node_command('reboot'); - }, - iconCls: 'fa fa-undo', - }); - - var shutdownBtn = Ext.create('Proxmox.button.Button', { - text: gettext('Shutdown'), - disabled: !caps.nodes['Sys.PowerMgmt'], - dangerous: true, - confirmMsg: Ext.String.format(gettext("Shutdown node '{0}'?"), nodename), - handler: function () { - node_command('shutdown'); - }, - iconCls: 'fa fa-power-off', - }); - - var shellBtn = Ext.create('PVE.button.ConsoleButton', { - disabled: !caps.nodes['Sys.Console'], - text: gettext('Shell'), - consoleType: 'shell', - nodename: nodename, - }); - - me.items = []; - - Ext.apply(me, { - title: gettext('Node') + " '" + nodename + "'", - hstateid: 'nodetab', - defaults: { - statusStore: me.statusStore, - }, - tbar: [restartBtn, shutdownBtn, shellBtn, actionBtn], - }); - - if (caps.nodes['Sys.Audit']) { - me.items.push( - { - xtype: 'pveNodeSummary', - title: gettext('Summary'), - iconCls: 'fa fa-book', - itemId: 'summary', - }, - { - xtype: 'pmxNotesView', - title: gettext('Notes'), - iconCls: 'fa fa-sticky-note-o', - itemId: 'notes', - }, - ); - } - - if (caps.nodes['Sys.Console']) { - me.items.push({ - xtype: 'pveNoVncConsole', - title: gettext('Shell'), - iconCls: 'fa fa-terminal', - itemId: 'jsconsole', - consoleType: 'shell', - xtermjs: true, - nodename: nodename, - }); - } - - if (caps.nodes['Sys.Audit']) { - me.items.push( - { - xtype: 'proxmoxNodeServiceView', - title: gettext('System'), - iconCls: 'fa fa-cogs', - itemId: 'services', - expandedOnInit: true, - restartCommand: 'reload', // avoid disruptions - startOnlyServices: { - pveproxy: true, - pvedaemon: true, - 'pve-cluster': true, - }, - nodename: nodename, - onlineHelp: 'pve_service_daemons', - }, - { - xtype: 'proxmoxNodeNetworkView', - title: gettext('Network'), - iconCls: 'fa fa-exchange', - itemId: 'network', - showApplyBtn: true, - showAltNames: true, - groups: ['services'], - nodename: nodename, - editOptions: { - enableBridgeVlanIds: true, - }, - onlineHelp: 'sysadmin_network_configuration', - }, - { - xtype: 'pveCertificatesView', - title: gettext('Certificates'), - iconCls: 'fa fa-certificate', - itemId: 'certificates', - groups: ['services'], - nodename: nodename, - }, - { - xtype: 'proxmoxNodeDNSView', - title: gettext('DNS'), - iconCls: 'fa fa-globe', - groups: ['services'], - itemId: 'dns', - nodename: nodename, - onlineHelp: 'sysadmin_network_configuration', - }, - { - xtype: 'proxmoxNodeHostsView', - title: gettext('Hosts'), - iconCls: 'fa fa-globe', - groups: ['services'], - itemId: 'hosts', - nodename: nodename, - onlineHelp: 'sysadmin_network_configuration', - }, - { - xtype: 'proxmoxNodeOptionsView', - title: gettext('Options'), - iconCls: 'fa fa-gear', - groups: ['services'], - itemId: 'options', - nodename: nodename, - onlineHelp: 'proxmox_node_management', - }, - { - xtype: 'proxmoxNodeTimeView', - title: gettext('Time'), - itemId: 'time', - groups: ['services'], - nodename: nodename, - iconCls: 'fa fa-clock-o', - }, - ); - } - - if (caps.nodes['Sys.Syslog']) { - me.items.push({ - xtype: 'proxmoxJournalView', - title: gettext('System Log'), - iconCls: 'fa fa-list', - groups: ['services'], - disabled: !caps.nodes['Sys.Syslog'], - itemId: 'syslog', - url: '/api2/extjs/nodes/' + nodename + '/journal', - }); - - if (caps.nodes['Sys.Modify']) { - me.items.push({ - xtype: 'proxmoxNodeAPT', - title: gettext('Updates'), - iconCls: 'fa fa-refresh', - expandedOnInit: true, - disabled: !caps.nodes['Sys.Console'], - // do we want to link to system updates instead? - itemId: 'apt', - upgradeBtn: { - xtype: 'pveConsoleButton', - disabled: Proxmox.UserName !== 'root@pam', - text: gettext('Upgrade'), - consoleType: 'upgrade', - nodename: nodename, - }, - nodename: nodename, - }); - - me.items.push({ - xtype: 'proxmoxNodeAPTRepositories', - title: gettext('Repositories'), - iconCls: 'fa fa-files-o', - itemId: 'aptrepositories', - nodename: nodename, - onlineHelp: 'sysadmin_package_repositories', - groups: ['apt'], - }); - } - } - - if (caps.nodes['Sys.Audit']) { - me.items.push( - { - xtype: 'pveFirewallRules', - iconCls: 'fa fa-shield', - title: gettext('Firewall'), - allow_iface: true, - base_url: '/nodes/' + nodename + '/firewall/rules', - list_refs_url: '/cluster/firewall/refs', - itemId: 'firewall', - firewall_type: 'node', - }, - { - xtype: 'pveFirewallOptions', - title: gettext('Options'), - iconCls: 'fa fa-gear', - onlineHelp: 'pve_firewall_host_specific_configuration', - groups: ['firewall'], - base_url: '/nodes/' + nodename + '/firewall/options', - fwtype: 'node', - itemId: 'firewall-options', - }, - ); - } - - if (caps.nodes['Sys.Audit']) { - me.items.push( - { - xtype: 'pmxDiskList', - title: gettext('Disks'), - itemId: 'storage', - expandedOnInit: true, - iconCls: 'fa fa-hdd-o', - nodename: nodename, - includePartitions: true, - supportsWipeDisk: true, - }, - { - xtype: 'pveLVMList', - title: 'LVM', - itemId: 'lvm', - onlineHelp: 'chapter_lvm', - iconCls: 'fa fa-square', - groups: ['storage'], - }, - { - xtype: 'pveLVMThinList', - title: 'LVM-Thin', - itemId: 'lvmthin', - onlineHelp: 'chapter_lvm', - iconCls: 'fa fa-square-o', - groups: ['storage'], - }, - { - xtype: 'pveDirectoryList', - title: Proxmox.Utils.directoryText, - itemId: 'directory', - onlineHelp: 'chapter_storage', - iconCls: 'fa fa-folder', - groups: ['storage'], - }, - { - title: 'ZFS', - itemId: 'zfs', - onlineHelp: 'chapter_zfs', - iconCls: 'fa fa-th-large', - groups: ['storage'], - xtype: 'pveZFSList', - }, - { - xtype: 'pveNodeCephStatus', - title: 'Ceph', - itemId: 'ceph', - iconCls: 'fa fa-ceph', - }, - { - xtype: 'pveNodeCephConfigCrush', - title: gettext('Configuration'), - iconCls: 'fa fa-gear', - groups: ['ceph'], - itemId: 'ceph-config', - }, - { - xtype: 'pveNodeCephMonMgr', - title: gettext('Monitor'), - iconCls: 'fa fa-tv', - groups: ['ceph'], - itemId: 'ceph-monlist', - }, - { - xtype: 'pveNodeCephOsdTree', - title: 'OSD', - iconCls: 'fa fa-hdd-o', - groups: ['ceph'], - itemId: 'ceph-osdtree', - }, - { - xtype: 'pveNodeCephFSPanel', - title: 'CephFS', - iconCls: 'fa fa-folder', - groups: ['ceph'], - nodename: nodename, - itemId: 'ceph-cephfspanel', - }, - { - xtype: 'pveNodeCephPoolList', - title: gettext('Pools'), - iconCls: 'fa fa-sitemap', - groups: ['ceph'], - itemId: 'ceph-pools', - }, - { - xtype: 'pveReplicaView', - iconCls: 'fa fa-retweet', - title: gettext('Replication'), - itemId: 'replication', - }, - ); - } - - if (caps.nodes['Sys.Syslog']) { - me.items.push( - { - xtype: 'proxmoxLogView', - title: gettext('Log'), - iconCls: 'fa fa-list', - groups: ['firewall'], - onlineHelp: 'chapter_pve_firewall', - url: '/api2/extjs/nodes/' + nodename + '/firewall/log', - itemId: 'firewall-fwlog', - log_select_timespan: true, - submitFormat: 'U', - }, - { - xtype: 'cephLogView', - title: gettext('Log'), - itemId: 'ceph-log', - iconCls: 'fa fa-list', - groups: ['ceph'], - onlineHelp: 'chapter_pveceph', - url: '/api2/extjs/nodes/' + nodename + '/ceph/log', - nodename: nodename, - }, - ); - } - - me.items.push( - { - title: gettext('Task History'), - iconCls: 'fa fa-list-alt', - itemId: 'tasks', - nodename: nodename, - xtype: 'proxmoxNodeTasks', - extraFilter: [ - { - xtype: 'pveGuestIDSelector', - fieldLabel: 'VMID', - allowBlank: true, - name: 'vmid', - }, - ], - }, - { - title: gettext('Subscription'), - iconCls: 'fa fa-support', - itemId: 'support', - xtype: 'pveNodeSubscription', - nodename: nodename, - }, - ); - - me.callParent(); - - me.mon(me.statusStore, 'load', function (store, records, success) { - let uptimerec = store.data.get('uptime'); - let powermgmt = caps.nodes['Sys.PowerMgmt'] && uptimerec && uptimerec.data.value; - - restartBtn.setDisabled(!powermgmt); - shutdownBtn.setDisabled(!powermgmt); - shellBtn.setDisabled(!powermgmt); - }); - - me.on('afterrender', function () { - me.statusStore.startUpdate(); - }); - - me.on('destroy', function () { - me.statusStore.stopUpdate(); - }); - }, -}); -Ext.define('PVE.node.CreateDirectory', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCreateDirectory', - - subject: Proxmox.Utils.directoryText, - - showProgress: true, - - onlineHelp: 'chapter_storage', - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.isCreate = true; - - Ext.applyIf(me, { - url: '/nodes/' + me.nodename + '/disks/directory', - method: 'POST', - items: [ - { - xtype: 'pmxDiskSelector', - name: 'device', - nodename: me.nodename, - diskType: 'unused', - includePartitions: true, - fieldLabel: gettext('Disk'), - allowBlank: false, - }, - { - xtype: 'proxmoxKVComboBox', - comboItems: [ - ['ext4', 'ext4'], - ['xfs', 'xfs'], - ], - fieldLabel: gettext('Filesystem'), - name: 'filesystem', - value: '', - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'name', - fieldLabel: gettext('Name'), - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'add_storage', - fieldLabel: gettext('Add Storage'), - value: '1', - }, - ], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.node.Directorylist', { - extend: 'Ext.grid.Panel', - xtype: 'pveDirectoryList', - - viewModel: { - data: { - path: '', - }, - formulas: { - dirName: (get) => get('path')?.replace('/mnt/pve/', '') || undefined, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - destroyDirectory: function () { - let me = this; - let vm = me.getViewModel(); - let view = me.getView(); - - const dirName = vm.get('dirName'); - - if (!view.nodename) { - throw 'no node name specified'; - } - - if (!dirName) { - throw 'no directory name specified'; - } - - Ext.create('PVE.window.SafeDestroyStorage', { - url: `/nodes/${view.nodename}/disks/directory/${dirName}`, - item: { id: dirName }, - taskName: 'dirremove', - taskDone: () => { - view.reload(); - }, - }).show(); - }, - }, - - stateful: true, - stateId: 'grid-node-directory', - columns: [ - { - text: gettext('Path'), - dataIndex: 'path', - flex: 1, - }, - { - header: gettext('Device'), - flex: 1, - dataIndex: 'device', - }, - { - header: gettext('Type'), - width: 100, - dataIndex: 'type', - }, - { - header: gettext('Options'), - width: 100, - dataIndex: 'options', - }, - { - header: gettext('Unit File'), - hidden: true, - dataIndex: 'unitfile', - }, - ], - - rootVisible: false, - useArrows: true, - - tbar: [ - { - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: function () { - this.up('panel').reload(); - }, - }, - { - text: `${gettext('Create')}: ${gettext('Directory')}`, - handler: function () { - let view = this.up('panel'); - Ext.create('PVE.node.CreateDirectory', { - nodename: view.nodename, - listeners: { - destroy: () => view.reload(), - }, - autoShow: true, - }); - }, - }, - '->', - { - xtype: 'tbtext', - data: { - dirName: undefined, - }, - bind: { - data: { - dirName: '{dirName}', - }, - }, - tpl: [ - '', - gettext('Directory') + ' {dirName}:', - '', - Ext.String.format(gettext('No {0} selected'), gettext('directory')), - '', - ], - }, - { - text: gettext('More'), - iconCls: 'fa fa-bars', - disabled: true, - bind: { - disabled: '{!dirName}', - }, - menu: [ - { - text: gettext('Destroy'), - itemId: 'remove', - iconCls: 'fa fa-fw fa-trash-o', - handler: 'destroyDirectory', - disabled: true, - bind: { - disabled: '{!dirName}', - }, - }, - ], - }, - ], - - reload: function () { - let me = this; - me.store.load(); - me.store.sort(); - }, - - listeners: { - activate: function () { - this.reload(); - }, - selectionchange: function (model, selected) { - let me = this; - let vm = me.getViewModel(); - - vm.set('path', selected[0]?.data.path || ''); - }, - }, - - initComponent: function () { - let me = this; - - me.nodename = me.pveSelNode.data.node; - if (!me.nodename) { - throw 'no node name specified'; - } - - Ext.apply(me, { - store: { - fields: ['path', 'device', 'type', 'options', 'unitfile'], - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/disks/directory`, - }, - sorters: 'path', - }, - }); - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.getStore(), true); - me.reload(); - }, -}); -Ext.define('PVE.node.CreateLVM', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCreateLVM', - - onlineHelp: 'chapter_lvm', - subject: 'LVM Volume Group', - - showProgress: true, - isCreate: true, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.isCreate = true; - - Ext.applyIf(me, { - url: `/nodes/${me.nodename}/disks/lvm`, - method: 'POST', - items: [ - { - xtype: 'pmxDiskSelector', - name: 'device', - nodename: me.nodename, - diskType: 'unused', - includePartitions: true, - fieldLabel: gettext('Disk'), - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'name', - fieldLabel: gettext('Name'), - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'add_storage', - fieldLabel: gettext('Add Storage'), - value: '1', - }, - ], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.node.LVMList', { - extend: 'Ext.tree.Panel', - xtype: 'pveLVMList', - - viewModel: { - data: { - volumeGroup: '', - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - destroyVolumeGroup: function () { - let me = this; - let vm = me.getViewModel(); - let view = me.getView(); - - const volumeGroup = vm.get('volumeGroup'); - - if (!view.nodename) { - throw 'no node name specified'; - } - - if (!volumeGroup) { - throw 'no volume group specified'; - } - - Ext.create('PVE.window.SafeDestroyStorage', { - url: `/nodes/${view.nodename}/disks/lvm/${volumeGroup}`, - item: { id: volumeGroup }, - taskName: 'lvmremove', - taskDone: () => { - view.reload(); - }, - }).show(); - }, - }, - - emptyText: PVE.Utils.renderNotFound('VGs'), - - stateful: true, - stateId: 'grid-node-lvm', - - rootVisible: false, - useArrows: true, - - columns: [ - { - xtype: 'treecolumn', - text: gettext('Name'), - dataIndex: 'name', - flex: 1, - }, - { - text: gettext('Number of LVs'), - dataIndex: 'lvcount', - width: 150, - align: 'right', - }, - { - header: gettext('Assigned to LVs'), - width: 130, - dataIndex: 'usage', - tdCls: 'x-progressbar-default-cell', - xtype: 'widgetcolumn', - widget: { - xtype: 'pveProgressBar', - }, - }, - { - header: gettext('Size'), - width: 100, - align: 'right', - sortable: true, - renderer: Proxmox.Utils.format_size, - dataIndex: 'size', - }, - { - header: gettext('Free'), - width: 100, - align: 'right', - sortable: true, - renderer: Proxmox.Utils.format_size, - dataIndex: 'free', - }, - ], - - tbar: [ - { - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: function () { - this.up('panel').reload(); - }, - }, - { - text: gettext('Create') + ': Volume Group', - handler: function () { - let view = this.up('panel'); - Ext.create('PVE.node.CreateLVM', { - nodename: view.nodename, - taskDone: () => view.reload(), - autoShow: true, - }); - }, - }, - '->', - { - xtype: 'tbtext', - data: { - volumeGroup: undefined, - }, - bind: { - data: { - volumeGroup: '{volumeGroup}', - }, - }, - tpl: [ - '', - 'Volume group {volumeGroup}:', - '', - Ext.String.format(gettext('No {0} selected'), 'volume group'), - '', - ], - }, - { - text: gettext('More'), - iconCls: 'fa fa-bars', - disabled: true, - bind: { - disabled: '{!volumeGroup}', - }, - menu: [ - { - text: gettext('Destroy'), - itemId: 'remove', - iconCls: 'fa fa-fw fa-trash-o', - handler: 'destroyVolumeGroup', - disabled: true, - bind: { - disabled: '{!volumeGroup}', - }, - }, - ], - }, - ], - - reload: function () { - let me = this; - let sm = me.getSelectionModel(); - Proxmox.Utils.API2Request({ - url: `/nodes/${me.nodename}/disks/lvm`, - waitMsgTarget: me, - method: 'GET', - failure: (response, opts) => Proxmox.Utils.setErrorMask(me, response.htmlStatus), - success: function (response, opts) { - sm.deselectAll(); - me.setRootNode(response.result.data); - me.expandAll(); - }, - }); - }, - - listeners: { - activate: function () { - this.reload(); - }, - selectionchange: function (model, selected) { - let me = this; - let vm = me.getViewModel(); - - if (selected.length < 1 || selected[0].data.parentId !== 'root') { - vm.set('volumeGroup', ''); - } else { - vm.set('volumeGroup', selected[0].data.name); - } - }, - }, - - selModel: 'treemodel', - fields: [ - 'name', - 'size', - 'free', - { - type: 'string', - name: 'iconCls', - calculate: (data) => `fa x-fa-tree fa-${data.leaf ? 'hdd-o' : 'object-group'}`, - }, - { - type: 'number', - name: 'usage', - calculate: (data) => (data.size - data.free) / data.size, - }, - ], - sorters: 'name', - - initComponent: function () { - let me = this; - - me.nodename = me.pveSelNode.data.node; - if (!me.nodename) { - throw 'no node name specified'; - } - me.callParent(); - - me.reload(); - }, -}); -Ext.define('PVE.node.CreateLVMThin', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCreateLVMThin', - - onlineHelp: 'chapter_lvm', - subject: 'LVM Thinpool', - - showProgress: true, - isCreate: true, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - Ext.applyIf(me, { - url: `/nodes/${me.nodename}/disks/lvmthin`, - method: 'POST', - items: [ - { - xtype: 'pmxDiskSelector', - name: 'device', - nodename: me.nodename, - diskType: 'unused', - includePartitions: true, - fieldLabel: gettext('Disk'), - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'name', - fieldLabel: gettext('Name'), - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'add_storage', - fieldLabel: gettext('Add Storage'), - value: '1', - }, - ], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.node.LVMThinList', { - extend: 'Ext.grid.Panel', - xtype: 'pveLVMThinList', - - viewModel: { - data: { - thinPool: '', - volumeGroup: '', - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - destroyThinPool: function () { - let me = this; - let vm = me.getViewModel(); - let view = me.getView(); - - const thinPool = vm.get('thinPool'); - const volumeGroup = vm.get('volumeGroup'); - - if (!view.nodename) { - throw 'no node name specified'; - } - - if (!thinPool) { - throw 'no thin pool specified'; - } - - if (!volumeGroup) { - throw 'no volume group specified'; - } - - Ext.create('PVE.window.SafeDestroyStorage', { - url: `/nodes/${view.nodename}/disks/lvmthin/${thinPool}`, - params: { 'volume-group': volumeGroup }, - item: { id: `${volumeGroup}/${thinPool}` }, - taskName: 'lvmthinremove', - taskDone: () => { - view.reload(); - }, - }).show(); - }, - }, - - emptyText: PVE.Utils.renderNotFound('Thin-Pool'), - - stateful: true, - stateId: 'grid-node-lvmthin', - - rootVisible: false, - useArrows: true, - - columns: [ - { - text: gettext('Name'), - dataIndex: 'lv', - flex: 1, - }, - { - header: 'Volume Group', - width: 110, - dataIndex: 'vg', - }, - { - header: gettext('Usage'), - width: 110, - dataIndex: 'usage', - tdCls: 'x-progressbar-default-cell', - xtype: 'widgetcolumn', - widget: { - xtype: 'pveProgressBar', - }, - }, - { - header: gettext('Size'), - width: 100, - align: 'right', - sortable: true, - renderer: Proxmox.Utils.format_size, - dataIndex: 'lv_size', - }, - { - header: gettext('Used'), - width: 100, - align: 'right', - sortable: true, - renderer: Proxmox.Utils.format_size, - dataIndex: 'used', - }, - { - header: gettext('Metadata Usage'), - width: 120, - dataIndex: 'metadata_usage', - tdCls: 'x-progressbar-default-cell', - xtype: 'widgetcolumn', - widget: { - xtype: 'pveProgressBar', - }, - }, - { - header: gettext('Metadata Size'), - width: 120, - align: 'right', - sortable: true, - renderer: Proxmox.Utils.format_size, - dataIndex: 'metadata_size', - }, - { - header: gettext('Metadata Used'), - width: 125, - align: 'right', - sortable: true, - renderer: Proxmox.Utils.format_size, - dataIndex: 'metadata_used', - }, - ], - - tbar: [ - { - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: function () { - this.up('panel').reload(); - }, - }, - { - text: gettext('Create') + ': Thinpool', - handler: function () { - var view = this.up('panel'); - Ext.create('PVE.node.CreateLVMThin', { - nodename: view.nodename, - taskDone: () => view.reload(), - autoShow: true, - }); - }, - }, - '->', - { - xtype: 'tbtext', - data: { - thinPool: undefined, - volumeGroup: undefined, - }, - bind: { - data: { - thinPool: '{thinPool}', - volumeGroup: '{volumeGroup}', - }, - }, - tpl: [ - '', - '', - 'Thinpool {volumeGroup}/{thinPool}:', - '', // volumeGroup - 'Missing volume group (node running old version?)', - '', - '', // thinPool - Ext.String.format(gettext('No {0} selected'), 'thinpool'), - '', - ], - }, - { - text: gettext('More'), - iconCls: 'fa fa-bars', - disabled: true, - bind: { - disabled: '{!volumeGroup || !thinPool}', - }, - menu: [ - { - text: gettext('Destroy'), - itemId: 'remove', - iconCls: 'fa fa-fw fa-trash-o', - handler: 'destroyThinPool', - disabled: true, - bind: { - disabled: '{!volumeGroup || !thinPool}', - }, - }, - ], - }, - ], - - reload: function () { - let me = this; - me.store.load(); - me.store.sort(); - }, - - listeners: { - activate: function () { - this.reload(); - }, - selectionchange: function (model, selected) { - let me = this; - let vm = me.getViewModel(); - - vm.set('volumeGroup', selected[0]?.data.vg || ''); - vm.set('thinPool', selected[0]?.data.lv || ''); - }, - }, - - initComponent: function () { - let me = this; - - me.nodename = me.pveSelNode.data.node; - if (!me.nodename) { - throw 'no node name specified'; - } - - Ext.apply(me, { - store: { - fields: [ - 'lv', - 'lv_size', - 'used', - 'metadata_size', - 'metadata_used', - { - type: 'number', - name: 'usage', - calculate: (data) => data.used / data.lv_size, - }, - { - type: 'number', - name: 'metadata_usage', - calculate: (data) => data.metadata_used / data.metadata_size, - }, - ], - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/disks/lvmthin`, - }, - sorters: 'lv', - }, - }); - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.getStore(), true); - me.reload(); - }, -}); -Ext.define('PVE.mod.TempHelper', { - //singleton: true, - - requires: ['Ext.util.Format'], - - statics: { - CELSIUS: 0, - FAHRENHEIT: 1 - }, - - srcUnit: null, - dstUnit: null, - - isValidUnit: function (unit) { - return ( - Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) - ); - }, - - constructor: function (config) { - this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; - this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; - }, - - toFahrenheit: function (tempCelsius) { - return Ext.isNumber(tempCelsius) - ? tempCelsius * 9 / 5 + 32 - : NaN; - }, - - toCelsius: function (tempFahrenheit) { - return Ext.isNumber(tempFahrenheit) - ? (tempFahrenheit - 32) * 5 / 9 - : NaN; - }, - - getTemp: function (value) { - if (this.srcUnit !== this.dstUnit) { - switch (this.srcUnit) { - case this.self.CELSIUS: - switch (this.dstUnit) { - case this.self.FAHRENHEIT: - return this.toFahrenheit(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - case this.self.FAHRENHEIT: - switch (this.dstUnit) { - case this.self.CELSIUS: - return this.toCelsius(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - default: - Ext.raise({ - msg: 'Unsupported source temperature unit: ' + this.srcUnit, - }); - } - } else { - return value; - } - }, - - getUnit: function(plainText) { - switch (this.dstUnit) { - case this.self.CELSIUS: - return plainText !== true ? '°C' : '\'C'; - - case this.self.FAHRENHEIT: - return plainText !== true ? '°F' : '\'F'; - - default: - Ext.raise({ - msg: 'Unsupported destination temperature unit: ' + this.srcUnit, - }); - } - }, -}); -Ext.define('PVE.node.StatusView', { - extend: 'Proxmox.panel.StatusView', - alias: 'widget.pveNodeStatus', - - minHeight: 360, - flex: 1, - collapsible: true, - titleCollapse: true, - bodyPadding: '20 15 20 15', - - layout: { - type: 'table', - columns: 2, - trAttrs: { valign: 'top' }, - tableAttrs: { - style: { - width: '100%', - }, - }, - }, - - defaults: { - xtype: 'pmxInfoWidget', - padding: '0 10 2 10', - }, - - items: [ - // ========== PRIMARY KPIs (Tier 1) ========== - { - xtype: 'box', - colspan: 2, - padding: '0', - html: '
    Primary Metrics
    ', - }, - { - itemId: 'cpu', - iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', - title: gettext('CPU Usage'), - valueField: 'cpu', - maxField: 'cpuinfo', - renderer: function(value, record) { - let result = Proxmox.Utils.render_node_cpu_usage(value, record); - // Append CPU model if available - if (record && record.cpuinfo && record.cpuinfo.model) { - result += ` (${record.cpuinfo.model})`; - } - return result; - }, - }, - { - iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', - itemId: 'memory', - title: gettext('Memory Usage'), - valueField: 'memory', - maxField: 'memory', - warningThreshold: 0.9, - criticalThreshold: 0.975, - renderer: Proxmox.Utils.render_node_size_usage, - }, - { - itemId: 'ksm', - iconCls: 'fa fa-fw fa-clone', - printBar: false, - title: gettext('KSM sharing'), - textField: 'ksm', - renderer: function (record) { - return Proxmox.Utils.render_size(record.shared); - }, - }, - { - itemId: 'gpu', - iconCls: 'fa fa-fw fa-desktop', - title: gettext('GPU Usage'), - printBar: false, - textField: 'gpuStats', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics) { - return ''; - } - - let hasActiveGPU = false; - let gpuName = ''; - - // Check Intel GPUs - if (gpuStats.Graphics.Intel) { - const keys = Object.keys(gpuStats.Graphics.Intel).sort(); - if (keys.length > 0) { - const gpuData = gpuStats.Graphics.Intel[keys[0]]; - hasActiveGPU = true; - gpuName = gpuData.name; - } - } - - // Check NVIDIA GPUs - if (gpuStats.Graphics.NVIDIA) { - const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); - if (keys.length > 0) { - const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; - hasActiveGPU = true; - gpuName = stats.name; - } - } - - return hasActiveGPU ? gpuName : ''; - }, - }, - { - itemId: 'gpu_usage', - iconCls: 'fa fa-fw fa-desktop', - title: gettext('GPU 0'), - valueField: 'gpuStats', - printBar: false, - textField: 'gpuStats', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics) { - return ''; - } - - // Check Intel GPUs - if (gpuStats.Graphics.Intel) { - const keys = Object.keys(gpuStats.Graphics.Intel).sort(); - if (keys.length > 0) { - const gpuData = gpuStats.Graphics.Intel[keys[0]]; - if (gpuData.stats.engines && gpuData.stats.engines['Render/3D']) { - const usage = gpuData.stats.engines['Render/3D'].busy; - return `${usage}%`; - } - } - } - - // Check NVIDIA GPUs - if (gpuStats.Graphics.NVIDIA) { - const keys = Object.keys(gpuStats.Graphics.NVIDIA).sort(); - if (keys.length > 0) { - const stats = gpuStats.Graphics.NVIDIA[keys[0]].stats; - if (stats.utilization) { - return `${stats.utilization.gpu}%`; - } - } - } - - return ''; - }, - }, - { - iconCls: 'fa fa-fw fa-hdd-o', - itemId: 'rootfs', - title: gettext('Disk (/) Usage'), - valueField: 'rootfs', - maxField: 'rootfs', - renderer: Proxmox.Utils.render_node_size_usage, - }, - { - iconCls: 'fa fa-fw fa-refresh', - itemId: 'swap', - title: gettext('SWAP Usage'), - valueField: 'swap', - maxField: 'swap', - warningThreshold: 0.4, - criticalThreshold: 0.8, - renderer: Proxmox.Utils.render_node_size_usage, - }, - // Fill the remaining cell so the next colspan:2 section header starts on a new row. - { - xtype: 'box', - html: '', - padding: 0, - }, - - // ========== SECONDARY DETAILS (Tier 2) ========== - { - xtype: 'box', - colspan: 2, - padding: '15 0 5 0', - html: '
    Secondary Details
    ', - }, - { - itemId: 'load', - iconCls: 'fa fa-fw fa-tasks', - title: gettext('CPU Load Average'), - printBar: false, - textField: 'loadavg', - }, - { - itemId: 'wait', - iconCls: 'fa fa-fw fa-clock-o', - title: gettext('CPU I/O Delay'), - valueField: 'wait', - }, - { - itemId: 'thermalCpu', - colspan: 2, - printBar: false, - title: gettext('CPU Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value){ - // sensors configuration - const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); - // display configuration - const itemsPerRow = 0; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - objValue = objValue[Object.keys(objValue)[0]] || {}; - } catch(e) { - objValue = {}; - } - - const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort(); - const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort(); - const bINTEL = cpuKeysI.length > 0 ? true : false; - const INTELPackagePrefix = 'Core' == 'Core' ? 'Core ' : 'Package id'; - const INTELPackageCaption = 'Core' == 'Core' ? 'Core' : 'Package'; - let AMDPackagePrefix = 'Tccd'; - let AMDPackageCaption = 'CCD'; - - if (cpuKeysA.length > 0) { - let bTccd = false; - let bTctl = false; - let bTdie = false; - let bCpuCoreTemp = false; - cpuKeysA.forEach((cpuKey, cpuIndex) => { - let items = objValue[cpuKey]; - bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; - bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; - bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; - bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; - }); - if (bTccd && 'Core' == 'Core') { - AMDPackagePrefix = 'Tccd'; - AMDPackageCaption = 'ccd'; - } else if (bCpuCoreTemp && 'Core' == 'Package') { - AMDPackagePrefix = 'CPU Core Temp'; - AMDPackageCaption = 'CPU Core Temp'; - } else if (bTdie) { - AMDPackagePrefix = 'Tdie'; - AMDPackageCaption = 'die'; - } else if (bTctl) { - AMDPackagePrefix = 'Tctl'; - AMDPackageCaption = 'ctl'; - } else { - AMDPackagePrefix = 'temp'; - AMDPackageCaption = 'Temp'; - } - } - - const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; - const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; - const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; - const formatTemp = bINTEL ? '0' : '0.0'; - const cpuCount = cpuKeys.length; - let temps = []; - - cpuKeys.forEach((cpuKey, cpuIndex) => { - let cpuTemps = []; - const items = objValue[cpuKey]; - const cpuModel = items.cpu_model || ''; - - const itemKeys = Object.keys(items).filter(item => { - if ('Core' == 'Core') { - // In Core mode: only show individual cores/CCDs, exclude overall CPU temp - return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); - } else { - // In Package mode: show overall CPU temp and package-level readings - return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; - } - }).sort((a, b) => { - // Sort cores numerically - let numA = parseInt(a.match(/\d+/)?.[0] || '0', 10); - let numB = parseInt(b.match(/\d+/)?.[0] || '0', 10); - return numA - numB; - }); - - itemKeys.forEach((coreKey) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(items[coreKey]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } - }); - - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - - let tempStr = ''; - - // Enhanced parsing for AMD temperatures - if (coreKey.startsWith('Tccd')) { - let tempIndex = coreKey.match(/Tccd(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - // Handle CPU Core Temp (single overall temperature) - else if (coreKey === 'CPU Core Temp') { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) - else { - let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - // fallback for CPUs which do not have a core index - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - - cpuTemps.push(tempStr); - } - } catch (e) { /*_*/ } - }); - - if(cpuTemps.length > 0) { - temps.push({ model: cpuModel, temps: cpuTemps }); - } - }); - - let html = ''; - temps.forEach((cpuData, cpuIndex) => { - const strCoreTemps = cpuData.temps.map((strTemp, index, arr) => { - return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); - }); - if(strCoreTemps.length > 0) { - let cpuLabel = cpuCount > 1 ? `Socket ${cpuIndex + 1}` : 'Socket 1'; - let cpuModelStr = cpuData.model || 'Unknown CPU'; - - html += ''; - html += ``; - html += ``; - html += ''; - } - }); - html += '
    ${cpuModelStr}${strCoreTemps.join('')}
    '; - - return html.indexOf('') > 0 - ? '
    ' + html + '
    ' - : 'N/A'; - } - }, - { - itemId: 'gpu_details', - colspan: 2, - iconCls: 'fa fa-fw fa-desktop', - title: gettext('GPU Details'), - printBar: false, - textField: 'gpuStats', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics) { - return ''; - } - - let html = ''; - - // Intel GPUs - Secondary details - if (gpuStats.Graphics.Intel) { - Object.keys(gpuStats.Graphics.Intel).sort().forEach(key => { - const gpuData = gpuStats.Graphics.Intel[key]; - - let details = []; - - // All engine details - if (gpuData.stats.engines) { - if (gpuData.stats.engines['Render/3D']) { - details.push(`Render/3D: ${gpuData.stats.engines['Render/3D'].busy}%`); - } - if (gpuData.stats.engines['Video']) { - details.push(`Video: ${gpuData.stats.engines['Video'].busy}%`); - } - if (gpuData.stats.engines['Blitter']) { - details.push(`Blitter: ${gpuData.stats.engines['Blitter'].busy}%`); - } - if (gpuData.stats.engines['VideoEnhance']) { - details.push(`VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}%`); - } - } - - // Power - if (gpuData.stats.power) { - details.push(`Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`); - } - - // Frequency - if (gpuData.stats.frequency) { - details.push(`Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.stats.frequency?.unit || 'MHz'}`); - } - - html += ''; - html += ``; - html += ``; - html += ''; - }); - } - - // NVIDIA GPUs - Secondary details - if (gpuStats.Graphics.NVIDIA) { - Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { - const gpuData = gpuStats.Graphics.NVIDIA[key]; - const stats = gpuData.stats; - - let details = []; - - // Memory Utilization - if (stats.utilization && stats.utilization.memory) { - const memUsage = parseInt(stats.utilization.memory); - let memStyle = ''; - if (memUsage >= 90) memStyle = 'color: #d9534f; font-weight: bold;'; - else if (memUsage >= 70) memStyle = 'color: #f0ad4e; font-weight: bold;'; - details.push(`MEM: ${stats.utilization.memory}%`); - } - - // VRAM Usage - if (stats.memory) { - const vramUsedGB = parseInt(stats.memory.used); - const vramTotalGB = parseInt(stats.memory.total); - const vramPercent = (vramUsedGB / vramTotalGB) * 100; - let vramStyle = ''; - if (vramPercent >= 90) vramStyle = 'color: #d9534f; font-weight: bold;'; - else if (vramPercent >= 70) vramStyle = 'color: #f0ad4e; font-weight: bold;'; - details.push(`VRAM: ${stats.memory.used}/${stats.memory.total} ${stats.memory.unit}`); - } - - // Temperature - if (stats.temperature) { - let tempStyle = ''; - if (stats.temperature.gpu >= 80) { - tempStyle = 'color: red; font-weight: bold;'; - } else if (stats.temperature.gpu >= 70) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - details.push(`Temp: ${stats.temperature.gpu}${stats.temperature.unit}`); - } - - // Power - if (stats.power) { - details.push(`Power: ${stats.power.draw}/${stats.power.limit} ${stats.power.unit}`); - } - - html += ''; - html += ``; - html += ``; - html += ''; - }); - } - - html += '
    ${gpuData.name}${details.join(' | ')}
    ${stats.name}${details.join(' | ')}
    '; - return html.indexOf('') > 0 - ? '
    ' + html + '
    ' - : ''; - }, - }, - { - itemId: 'thermalNvme', - colspan: 2, - printBar: false, - title: gettext('NVMe Temperatures'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value) { - // sensors configuration - const addressPrefix = "nvme-pci-"; - const sensorName = "Composite"; - const tempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); - // display configuration - const itemsPerRow = 0; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - objValue = objValue[Object.keys(objValue)[0]] || {}; - } catch(e) { - objValue = {}; - } - const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); - let nvmeData = []; - nvmeKeys.forEach((nvmeKey, index) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN, model = '', serial = ''; - Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } - }); - model = objValue[nvmeKey]['model'] || 'Unknown'; - serial = objValue[nvmeKey]['serial'] || ''; - - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - nvmeData.push({ - model: model, - serial: serial, - temp: tempVal, - tempStyle: tempStyle, - unit: tempHelper.getUnit() - }); - } - } catch(e) { /*_*/ } - }); - - if (nvmeData.length === 0) { - return 'N/A'; - } - - let html = ''; - nvmeData.forEach((data) => { - let deviceName = data.model; - if (data.serial) { - deviceName += ` (${data.serial})`; - } - html += ''; - html += ``; - html += ``; - html += ''; - }); - html += '
    ${deviceName}${Ext.util.Format.number(data.temp, '0.0')}${data.unit}
    '; - return '
    ' + html + '
    '; - } - }, - - // ========== TERTIARY DIAGNOSTICS (Tier 3) ========== - { - xtype: 'box', - colspan: 2, - padding: '15 0 5 0', - html: '
    Diagnostics
    ', - }, - { - itemId: 'speedFan', - colspan: 2, - printBar: false, - title: gettext('System Fans'), - iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'sensorsOutput', - renderer: function(value) { - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - objValue = objValue[Object.keys(objValue)[0]] || {}; - } catch(e) { - objValue = {}; - } - - // Recursive function to find fan keys and values - function findFanKeys(obj, fanKeys, parentKey = null) { - Object.keys(obj).forEach(key => { - const value = obj[key]; - if (typeof value === 'object' && value !== null) { - // If the value is an object, recursively call the function - findFanKeys(value, fanKeys, key); - } else if (/^fan[0-9]+(_input)?$/.test(key)) { - if (true != true && value === 0) { - // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 - return; - } - // If the key matches the pattern, add the parent key and value to the fanKeys array - fanKeys.push({ key: parentKey, value: value }); - } - }); - } - - let speeds = []; - // Loop through the parent keys - Object.keys(objValue).forEach(parentKey => { - const parentObj = objValue[parentKey]; - // Array to store fan keys and values - const fanKeys = []; - // Call the recursive function to find fan keys and values - findFanKeys(parentObj, fanKeys); - // Sort the fan keys - fanKeys.sort((a, b) => { - if (a.key < b.key) return -1; - if (a.key > b.key) return 1; - return 0; - }); - // Process each fan key and value - fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { - try { - const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey - speeds.push(`${fan}: ${fanSpeed} RPM`); - } catch(e) { - console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error - } - }); - }); - return '
    ' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
    '; - } - }, - { - itemId: 'gpuFans', - colspan: 2, - printBar: false, - title: gettext('GPU Fans'), - iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'gpuStats', - renderer: function(gpuStats) { - if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.NVIDIA) { - return 'N/A'; - } - - let rows = []; - - Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { - const gpuData = gpuStats.Graphics.NVIDIA[key]; - const stats = gpuData?.stats; - const fan = stats?.fan; - - if (!fan || fan.speed === undefined || fan.speed === null) { - return; - } - - const gpuName = stats?.name || key; - const unit = fan.unit || '%'; - rows.push( - '' + - `${gpuName}` + - `Fan: ${fan.speed}${unit}` + - '', - ); - }); - - if (rows.length === 0) { - return 'N/A'; - } - - return '
    ' + rows.join('') + '
    '; - }, - }, - { - itemId: 'upsc', - colspan: 2, - printBar: false, - title: gettext('UPS Status'), - iconCls: 'fa fa-fw fa-battery-three-quarters', - textField: 'upsStats', - renderer: function(value) { - let objValue = {}; - try { - // Parse the UPS data - if (typeof value === 'string') { - objValue = JSON.parse(value) || {}; - } else if (typeof value === 'object') { - objValue = value || {}; - } - } catch(e) { - objValue = {}; - } - - // If objValue is null or empty, return N/A - if (!objValue || Object.keys(objValue).length === 0) { - return 'N/A'; - } - - // Helper function to get status color - function getStatusColor(status) { - if (!status) return '#999'; - const statusUpper = status.toUpperCase(); - if (statusUpper.includes('OL')) return null; - if (statusUpper.includes('OB')) return '#d9534f'; - if (statusUpper.includes('LB')) return '#d9534f'; - return '#f0ad4e'; - } - - // Helper function to get load/charge color - function getPercentageColor(value, isLoad = false) { - if (!value || isNaN(value)) return '#999'; - const num = parseFloat(value); - if (isLoad) { - if (num >= 80) return '#d9534f'; - if (num >= 60) return '#f0ad4e'; - return null; - } else { - if (num <= 20) return '#d9534f'; - if (num <= 50) return '#f0ad4e'; - return null; - } - } - - // Helper function to format runtime - function formatRuntime(seconds) { - if (!seconds || isNaN(seconds)) return 'N/A'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}m ${secs}s`; - } - - // Process each UPS in the data - let allDisplayItems = []; - - Object.keys(objValue).forEach(upsKey => { - const upsData = objValue[upsKey]; - - // Extract key UPS information - const batteryCharge = upsData['battery.charge']; - const batteryRuntime = upsData['battery.runtime']; - const inputVoltage = upsData['input.voltage']; - const upsLoad = upsData['ups.load']; - const upsStatus = upsData['ups.status']; - const upsModel = upsData['ups.model'] || upsData['device.model']; - const testResult = upsData['ups.test.result']; - const batteryChargeLow = upsData['battery.charge.low']; - const batteryRuntimeLow = upsData['battery.runtime.low']; - const upsRealPowerNominal = upsData['ups.realpower.nominal']; - const batteryMfrDate = upsData['battery.mfr.date']; - - // Main status line with all metrics - let statusLine = ''; - - // Status - if (upsStatus) { - const statusUpper = upsStatus.toUpperCase(); - let statusText = 'Unknown'; - let statusColor = '#f0ad4e'; - - if (statusUpper.includes('OL')) { - statusText = 'Online'; - statusColor = null; - } else if (statusUpper.includes('OB')) { - statusText = 'On Battery'; - statusColor = '#d9534f'; - } else if (statusUpper.includes('LB')) { - statusText = 'Low Battery'; - statusColor = '#d9534f'; - } else { - statusText = upsStatus; - statusColor = '#f0ad4e'; - } - - let statusStyle = statusColor ? ('color: ' + statusColor + ';') : ''; - statusLine += 'Status: ' + statusText + ''; - } else { - statusLine += 'Status: N/A'; - } - - // Battery charge - if (statusLine) statusLine += ' | '; - if (batteryCharge) { - const chargeColor = getPercentageColor(batteryCharge, false); - let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; - statusLine += 'Battery: ' + batteryCharge + '%'; - } else { - statusLine += 'Battery: N/A'; - } - - // Load percentage - if (statusLine) statusLine += ' | '; - if (upsLoad) { - const loadColor = getPercentageColor(upsLoad, true); - let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; - statusLine += 'Load: ' + upsLoad + '%'; - } else { - statusLine += 'Load: N/A'; - } - - // Runtime - if (statusLine) statusLine += ' | '; - if (batteryRuntime) { - const runtime = parseInt(batteryRuntime); - const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600; - let runtimeColor = null; - if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; - else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; - let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : ''; - statusLine += 'Runtime: ' + formatRuntime(runtime) + ''; - } else { - statusLine += 'Runtime: N/A'; - } - - // Input voltage - if (statusLine) statusLine += ' | '; - if (inputVoltage) { - statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; - } else { - statusLine += 'Input: N/A'; - } - - // Calculate actual watt usage - if (statusLine) statusLine += ' | '; - let actualWattage = null; - if (upsLoad && upsRealPowerNominal) { - const load = parseFloat(upsLoad); - const nominal = parseFloat(upsRealPowerNominal); - if (!isNaN(load) && !isNaN(nominal)) { - actualWattage = Math.round((load / 100) * nominal); - } - } - - // Real power (calculated watt usage) - if (actualWattage !== null) { - statusLine += 'Output: ' + actualWattage + 'W'; - } else { - statusLine += 'Output: N/A'; - } - - // Append battery MFD + last test to the same line (single-line UPS summary) - statusLine += ' | Battery MFD: ' + (batteryMfrDate || 'N/A'); - if (testResult && !testResult.toLowerCase().includes('no test')) { - const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; - let testStyle = testColor ? ('color: ' + testColor + ';') : ''; - statusLine += ' | Test: ' + testResult + ''; - } else { - statusLine += ' | Test: N/A'; - } - - // Build UPS display with model on left, details on right - let upsHtml = ''; - upsHtml += '' + (upsModel || upsKey) + ''; - upsHtml += '' + statusLine + ''; - upsHtml += ''; - - allDisplayItems.push(upsHtml); - }); - - // Format the final output for all UPS devices - return '
    ' + allDisplayItems.join('') + '
    '; - } - }, - { - xtype: 'box', - colspan: 2, - padding: '15 0 5 0', - html: '
    System
    ', - }, - { - colspan: 2, - title: gettext('Kernel Version'), - printBar: false, - // TODO: remove with next major and only use newish current-kernel textfield - multiField: true, - //textField: 'current-kernel', - renderer: ({ data }) => { - if (!data['current-kernel']) { - return data.kversion; - } - let kernel = data['current-kernel']; - let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown'; - return `${kernel.sysname} ${kernel.release} (${buildDate})`; - }, - value: '', - }, - { - colspan: 2, - title: gettext('Boot Mode'), - printBar: false, - textField: 'boot-info', - renderer: (boot) => { - if (boot.mode === 'legacy-bios') { - return 'Legacy BIOS'; - } else if (boot.mode === 'efi') { - return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`; - } - return Proxmox.Utils.unknownText; - }, - value: '', - }, - { - itemId: 'version', - colspan: 2, - printBar: false, - title: gettext('Manager Version'), - textField: 'pveversion', - value: '', - }, - { - itemId: 'pve_mod_version', - colspan: 2, - printBar: false, - title: gettext('Sensor Mod Version'), - textField: 'pveModVersion', - value: '', - }, - ], - - updateTitle: function () { - var me = this; - var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); - me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); - }, - - initComponent: function () { - let me = this; - - let stateProvider = Ext.state.Manager.getProvider(); - let repoLink = stateProvider.encodeHToken({ - view: 'server', - rid: `node/${me.pveSelNode.data.node}`, - ltab: 'tasks', - nodetab: 'aptrepositories', - }); - - me.items.push({ - xtype: 'pmxNodeInfoRepoStatus', - itemId: 'repositoryStatus', - product: 'Proxmox VE', - repoLink: `#${repoLink}`, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.node.SubscriptionKeyEdit', { - extend: 'Proxmox.window.Edit', - - title: gettext('Upload Subscription Key'), - width: 350, - - items: { - xtype: 'textfield', - name: 'key', - value: '', - fieldLabel: gettext('Subscription Key'), - labelWidth: 120, - getSubmitValue: function () { - return this.processRawValue(this.getRawValue())?.trim(); - }, - }, - - initComponent: function () { - var me = this; - - me.callParent(); - - me.load(); - }, -}); - -Ext.define('PVE.node.Subscription', { - extend: 'Proxmox.grid.ObjectGrid', - - alias: ['widget.pveNodeSubscription'], - - onlineHelp: 'getting_help', - - viewConfig: { - enableTextSelection: true, - }, - - showReport: function () { - var me = this; - - var getReportFileName = function () { - var now = Ext.Date.format(new Date(), 'D-d-F-Y-G-i'); - return `${me.nodename}-pve-report-${now}.txt`; - }; - - var view = Ext.createWidget('component', { - itemId: 'system-report-view', - scrollable: true, - style: { - 'white-space': 'pre', - 'font-family': 'monospace', - padding: '5px', - }, - }); - - var reportWindow = Ext.create('Ext.window.Window', { - title: gettext('System Report'), - width: 1024, - height: 600, - layout: 'fit', - modal: true, - buttons: [ - '->', - { - text: gettext('Download'), - handler: function () { - var fileContent = Ext.String.htmlDecode( - reportWindow.getComponent('system-report-view').html, - ); - var fileName = getReportFileName(); - - // Internet Explorer - if (window.navigator.msSaveOrOpenBlob) { - navigator.msSaveOrOpenBlob(new Blob([fileContent]), fileName); - } else { - let element = document.createElement('a'); - element.setAttribute( - 'href', - 'data:text/plain;charset=utf-8,' + encodeURIComponent(fileContent), - ); - element.setAttribute('download', fileName); - element.style.display = 'none'; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); - } - }, - }, - ], - items: view, - }); - - Proxmox.Utils.API2Request({ - url: '/api2/extjs/nodes/' + me.nodename + '/report', - method: 'GET', - waitMsgTarget: me, - failure: function (response) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response) { - var report = Ext.htmlEncode(response.result.data); - reportWindow.show(); - view.update(report); - }, - }); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - let rows = { - productname: { - header: gettext('Type'), - }, - key: { - header: gettext('Subscription Key'), - }, - status: { - header: gettext('Status'), - renderer: (v) => { - let message = me.getObjectValue('message'); - return message ? `${v}: ${message}` : v; - }, - }, - message: { - visible: false, - }, - serverid: { - header: gettext('Server ID'), - }, - sockets: { - header: gettext('Sockets'), - }, - checktime: { - header: gettext('Last checked'), - renderer: Proxmox.Utils.render_timestamp, - }, - nextduedate: { - header: gettext('Next due date'), - }, - signature: { - header: gettext('Signed/Offline'), - renderer: (v) => (v ? gettext('Yes') : gettext('No')), - }, - }; - - Ext.apply(me, { - url: `/api2/json/nodes/${me.nodename}/subscription`, - cwidth1: 170, - tbar: [ - { - text: gettext('Upload Subscription Key'), - handler: () => - Ext.create('PVE.node.SubscriptionKeyEdit', { - autoShow: true, - url: `/api2/extjs/nodes/${me.nodename}/subscription`, - listeners: { - destroy: () => me.rstore.load(), - }, - }), - }, - { - text: gettext('Check'), - handler: () => - Proxmox.Utils.API2Request({ - params: { force: 1 }, - url: `/nodes/${me.nodename}/subscription`, - method: 'POST', - waitMsgTarget: me, - failure: (response) => - Ext.Msg.alert(gettext('Error'), response.htmlStatus), - callback: () => me.rstore.load(), - }), - }, - { - text: gettext('Remove Subscription'), - xtype: 'proxmoxStdRemoveButton', - confirmMsg: gettext('Are you sure you want to remove the subscription key?'), - baseurl: `/nodes/${me.nodename}/subscription`, - dangerous: true, - selModel: false, - callback: () => me.rstore.load(), - }, - '-', - { - text: gettext('System Report'), - handler: function () { - Proxmox.Utils.checked_command(function () { - me.showReport(); - }); - }, - }, - ], - rows: rows, - listeners: { - activate: () => me.rstore.load(), - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.node.Summary', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveNodeSummary', - - scrollable: true, - bodyPadding: 5, - - showVersions: function () { - var me = this; - - // Note: we use simply text/html here, because ExtJS grid has problems - // with cut&paste - - var nodename = me.pveSelNode.data.node; - - var view = Ext.createWidget('component', { - autoScroll: true, - id: 'pkgversions', - padding: 5, - style: { - 'white-space': 'pre', - 'font-family': 'monospace', - }, - }); - - var win = Ext.create('Ext.window.Window', { - title: gettext('Package versions'), - width: 600, - height: 600, - layout: 'fit', - modal: true, - items: [view], - buttons: [ - { - xtype: 'button', - iconCls: 'fa fa-clipboard', - handler: function (button) { - window - .getSelection() - .selectAllChildren(document.getElementById('pkgversions')); - document.execCommand('copy'); - }, - text: gettext('Copy'), - }, - { - text: gettext('Ok'), - handler: function () { - this.up('window').close(); - }, - }, - ], - }); - - Proxmox.Utils.API2Request({ - waitMsgTarget: me, - url: `/nodes/${nodename}/apt/versions`, - method: 'GET', - failure: function (response, opts) { - win.close(); - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, opts) { - win.show(); - let text = ''; - Ext.Array.each(response.result.data, function (rec) { - let version = 'not correctly installed'; - let pkg = rec.Package; - if (rec.OldVersion && rec.CurrentState === 'Installed') { - version = rec.OldVersion; - } - if (rec.RunningKernel) { - text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; - } else if (rec.ManagerVersion) { - text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; - } else { - text += `${pkg}: ${version}\n`; - } - }); - - view.update(Ext.htmlEncode(text)); - }, - }); - }, - - updateRepositoryStatus: function () { - let me = this; - let repoStatus = me.nodeStatus.down('#repositoryStatus'); - - let nodename = me.pveSelNode.data.node; - - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/apt/repositories`, - method: 'GET', - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: (response) => - repoStatus.setRepositoryInfo(response.result.data['standard-repos']), - }); - - Proxmox.Utils.API2Request({ - url: `/nodes/${nodename}/subscription`, - method: 'GET', - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - success: function (response, opts) { - const res = response.result; - const subscription = res?.data?.status.toLowerCase() === 'active'; - repoStatus.setSubscriptionStatus(subscription); - }, - }); - }, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - if (!me.statusStore) { - throw 'no status storage specified'; - } - - var rstore = me.statusStore; - - var version_btn = new Ext.Button({ - text: gettext('Package versions'), - handler: function () { - Proxmox.Utils.checked_command(function () { - me.showVersions(); - }); - }, - }); - - var rrdstore = Ext.create('Proxmox.data.RRDStore', { - rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', - model: 'pve-rrd-node', - }); - - let nodeStatus = Ext.create('PVE.node.StatusView', { - xtype: 'pveNodeStatus', - rstore: rstore, - width: 770, - pveSelNode: me.pveSelNode, - }); - - Ext.apply(me, { - tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], - nodeStatus: nodeStatus, - items: [ -{ - xtype: 'container', - itemId: 'summarycontainer', - layout: 'column', - minWidth: 700, - defaults: { - minHeight: 350, - padding: 5, - columnWidth: 1, - }, - items: [ - nodeStatus, - ] -}, - { - xtype: 'container', - itemId: 'itemcontainer', - layout: 'column', - minWidth: 700, - defaults: { - minHeight: 360, - padding: 5, - columnWidth: 1, - }, - items: [ - //nodeStatus, - { - xtype: 'proxmoxRRDChart', - title: gettext('CPU Usage'), - fields: ['cpu', 'iowait'], - fieldTitles: [gettext('CPU usage'), gettext('IO delay')], - unit: 'percent', - store: rrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Server Load'), - fields: ['loadavg'], - fieldTitles: [gettext('Load average')], - store: rrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Memory usage'), - fields: [ - { - yField: 'memtotal', - title: gettext('Total'), - tooltip: { - trackMouse: true, - renderer: function (toolTip, record, item) { - let value = record.get('memtotal'); - - if (value === null) { - toolTip.setHtml(gettext('No Data')); - } else { - let total = Proxmox.Utils.format_size(value); - let time = new Date(record.get('time')); - - let avail = record.get('memavailable'); - let availText = ''; - if (Ext.isNumeric(avail)) { - let v = Proxmox.Utils.format_size(avail); - availText = ` (${gettext('Available')}: ${v})`; - } - - toolTip.setHtml( - `${gettext('Total')}: ${total}${availText}
    ${time}`, - ); - } - }, - }, - }, - { - yField: 'memused', - title: gettext('Used'), - tooltip: { - trackMouse: true, - renderer: function (toolTip, record, item) { - let value = record.get('memused'); - - if (value === null) { - toolTip.setHtml(gettext('No Data')); - } else { - let total = Proxmox.Utils.format_size(value); - let time = new Date(record.get('time')); - - let arc = record.get('arcsize'); - let arcText = ''; - if (Ext.isNumeric(arc) && arc > 1024 * 1024) { - let v = Proxmox.Utils.format_size(value - arc); - arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; - } - - toolTip.setHtml( - `${gettext('Used')}: ${total}${arcText}
    ${time}`, - ); - } - }, - }, - }, - 'arcsize', - { - type: 'line', - fill: false, - yField: 'memavailable', - title: gettext('Available'), - style: { - lineWidth: 2.5, - opacity: 1, - }, - }, - ], - fieldTitles: [ - gettext('Total'), - gettext('Used'), - gettext('ZFS ARC'), - gettext('Available'), - ], - colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], - unit: 'bytes', - powerOfTwo: true, - store: rrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Network Traffic'), - fields: ['netin', 'netout'], - fieldTitles: [gettext('Incoming'), gettext('Outgoing')], - store: rrdstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('CPU Pressure Stall'), - fieldTitles: ['Some'], - fields: ['pressurecpusome'], - colors: ['#FFD13E', '#A61120'], - store: rrdstore, - unit: 'percent', - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('IO Pressure Stall'), - fieldTitles: ['Some', 'Full'], - fields: ['pressureiosome', 'pressureiofull'], - colors: ['#FFD13E', '#A61120'], - store: rrdstore, - unit: 'percent', - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Memory Pressure Stall'), - fieldTitles: ['Some', 'Full'], - fields: ['pressurememorysome', 'pressurememoryfull'], - colors: ['#FFD13E', '#A61120'], - store: rrdstore, - unit: 'percent', - }, - { - // todo find me - xtype: 'proxmoxRRDChart', - title: gettext('Graphics Intel'), - fields: ['netin', 'netout'], - fieldTitles: [gettext('Incoming'), gettext('Outgoing')], - store: rrdstore, - }, - ], - listeners: { - resize: function (panel) { - Proxmox.Utils.updateColumns(panel); - }, - }, - }, - ], - listeners: { - activate: function () { - rstore.setInterval(1000); - rstore.startUpdate(); // just to be sure - rrdstore.startUpdate(); - }, - destroy: function () { - rstore.setInterval(5000); // don't stop it, it's not ours! - rrdstore.stopUpdate(); - }, - }, - }); - - me.updateRepositoryStatus(); - - me.callParent(); - - let sp = Ext.state.Manager.getProvider(); - me.mon(sp, 'statechange', function (provider, key, value) { - if (key !== 'summarycolumns') { - return; - } - Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); - }); - }, -}); -Ext.define('PVE.node.CreateZFS', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCreateZFS', - - onlineHelp: 'chapter_zfs', - subject: 'ZFS', - - showProgress: true, - isCreate: true, - width: 800, - - viewModel: { - data: { - raidLevel: 'single', - }, - formulas: { - isDraid: (get) => get('raidLevel')?.startsWith('draid'), - }, - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - Ext.apply(me, { - url: `/nodes/${me.nodename}/disks/zfs`, - method: 'POST', - items: [ - { - xtype: 'inputpanel', - onGetValues: function (values) { - if (values.draidData || values.draidSpares) { - let opt = { data: values.draidData, spares: values.draidSpares }; - values['draid-config'] = PVE.Parser.printPropertyString(opt); - } - delete values.draidData; - delete values.draidSpares; - return values; - }, - column1: [ - { - xtype: 'proxmoxtextfield', - name: 'name', - fieldLabel: gettext('Name'), - allowBlank: false, - maxLength: 128, // ZFS_MAX_DATASET_NAME_LEN is (256 - some edge case) - validator: (v) => { - // see zpool_name_valid function in libzfs_zpool.c - if (v.match(/^(mirror|raidz|draid|spare)/) || v === 'log') { - return gettext('Cannot use reserved pool name'); - } else if (!v.match(/^[a-zA-Z][a-zA-Z0-9\-_.]*$/)) { - // note: zfs would support also : and whitespace, but we don't - return gettext('Invalid characters in pool name'); - } - return true; - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'add_storage', - fieldLabel: gettext('Add Storage'), - value: '1', - }, - ], - column2: [ - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('RAID Level'), - name: 'raidlevel', - value: 'single', - comboItems: [ - ['single', gettext('Single Disk')], - ['mirror', 'Mirror'], - ['raid10', 'RAID10'], - ['raidz', 'RAIDZ'], - ['raidz2', 'RAIDZ2'], - ['raidz3', 'RAIDZ3'], - ['draid', 'dRAID'], - ['draid2', 'dRAID2'], - ['draid3', 'dRAID3'], - ], - bind: { - value: '{raidLevel}', - }, - }, - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('Compression'), - name: 'compression', - value: 'on', - comboItems: [ - ['on', 'on'], - ['off', 'off'], - ['gzip', 'gzip'], - ['lz4', 'lz4'], - ['lzjb', 'lzjb'], - ['zle', 'zle'], - ['zstd', 'zstd'], - ], - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: gettext('ashift'), - minValue: 9, - maxValue: 16, - value: '12', - name: 'ashift', - }, - ], - columnB: [ - { - xtype: 'fieldset', - title: gettext('dRAID Config'), - collapsible: false, - bind: { - hidden: '{!isDraid}', - }, - layout: 'hbox', - padding: '5px 10px', - defaults: { - flex: 1, - layout: 'anchor', - }, - items: [ - { - xtype: 'proxmoxintegerfield', - name: 'draidData', - fieldLabel: gettext('Data Devs'), - minValue: 1, - allowBlank: false, - disabled: true, - hidden: true, - bind: { - disabled: '{!isDraid}', - hidden: '{!isDraid}', - }, - padding: '0 10 0 0', - }, - { - xtype: 'proxmoxintegerfield', - name: 'draidSpares', - fieldLabel: gettext('Spares'), - minValue: 0, - allowBlank: false, - disabled: true, - hidden: true, - bind: { - disabled: '{!isDraid}', - hidden: '{!isDraid}', - }, - padding: '0 0 0 10', - }, - ], - }, - { - xtype: 'pmxMultiDiskSelector', - name: 'devices', - nodename: me.nodename, - diskType: 'unused', - includePartitions: true, - height: 200, - emptyText: gettext('No Disks unused'), - itemId: 'disklist', - }, - ], - }, - { - xtype: 'displayfield', - padding: '5 0 0 0', - userCls: 'pmx-hint', - value: - 'Note: ZFS is not compatible with disks backed by a hardware ' + - 'RAID controller. For details see the reference documentation.', - }, - ], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.node.ZFSList', { - extend: 'Ext.grid.Panel', - xtype: 'pveZFSList', - - viewModel: { - data: { - pool: '', - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - destroyPool: function () { - let me = this; - let vm = me.getViewModel(); - let view = me.getView(); - - const pool = vm.get('pool'); - - if (!view.nodename) { - throw 'no node name specified'; - } - - if (!pool) { - throw 'no pool specified'; - } - - Ext.create('PVE.window.SafeDestroyStorage', { - url: `/nodes/${view.nodename}/disks/zfs/${pool}`, - item: { id: pool }, - taskName: 'zfsremove', - taskDone: () => { - view.reload(); - }, - }).show(); - }, - }, - - stateful: true, - stateId: 'grid-node-zfs', - columns: [ - { - text: gettext('Name'), - dataIndex: 'name', - flex: 1, - }, - { - header: gettext('Size'), - renderer: Proxmox.Utils.format_size, - dataIndex: 'size', - }, - { - header: gettext('Free'), - renderer: Proxmox.Utils.format_size, - dataIndex: 'free', - }, - { - header: gettext('Allocated'), - renderer: Proxmox.Utils.format_size, - dataIndex: 'alloc', - }, - { - header: gettext('Fragmentation'), - renderer: function (value) { - return value.toString() + '%'; - }, - dataIndex: 'frag', - }, - { - header: gettext('Health'), - renderer: PVE.Utils.render_zfs_health, - dataIndex: 'health', - }, - { - header: gettext('Deduplication'), - hidden: true, - renderer: function (value) { - return value.toFixed(2).toString() + 'x'; - }, - dataIndex: 'dedup', - }, - ], - - rootVisible: false, - useArrows: true, - - tbar: [ - { - text: gettext('Reload'), - iconCls: 'fa fa-refresh', - handler: function () { - this.up('panel').reload(); - }, - }, - { - text: gettext('Create') + ': ZFS', - handler: function () { - let view = this.up('panel'); - Ext.create('PVE.node.CreateZFS', { - nodename: view.nodename, - listeners: { - destroy: () => view.reload(), - }, - autoShow: true, - }); - }, - }, - { - text: gettext('Detail'), - itemId: 'detailbtn', - disabled: true, - handler: function () { - let view = this.up('panel'); - let selection = view.getSelection(); - if (selection.length) { - view.show_detail(selection[0].get('name')); - } - }, - }, - '->', - { - xtype: 'tbtext', - data: { - pool: undefined, - }, - bind: { - data: { - pool: '{pool}', - }, - }, - tpl: [ - '', - 'Pool {pool}:', - '', - Ext.String.format(gettext('No {0} selected'), 'pool'), - '', - ], - }, - { - text: gettext('More'), - iconCls: 'fa fa-bars', - disabled: true, - bind: { - disabled: '{!pool}', - }, - menu: [ - { - text: gettext('Destroy'), - itemId: 'remove', - iconCls: 'fa fa-fw fa-trash-o', - handler: 'destroyPool', - disabled: true, - bind: { - disabled: '{!pool}', - }, - }, - ], - }, - ], - - show_detail: function (zpool) { - let me = this; - - Ext.create('Proxmox.window.ZFSDetail', { - zpool, - nodename: me.nodename, - }).show(); - }, - - set_button_status: function () { - var _me = this; - }, - - reload: function () { - var me = this; - me.store.load(); - me.store.sort(); - }, - - listeners: { - activate: function () { - this.reload(); - }, - selectionchange: function (model, selected) { - let me = this; - let vm = me.getViewModel(); - - me.down('#detailbtn').setDisabled(selected.length === 0); - vm.set('pool', selected[0]?.data.name || ''); - }, - itemdblclick: function (grid, record) { - this.show_detail(record.get('name')); - }, - }, - - initComponent: function () { - let me = this; - - me.nodename = me.pveSelNode.data.node; - if (!me.nodename) { - throw 'no node name specified'; - } - - Ext.apply(me, { - store: { - fields: ['name', 'size', 'free', 'alloc', 'dedup', 'frag', 'health'], - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/disks/zfs`, - }, - sorters: 'name', - }, - }); - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.getStore(), true); - me.reload(); - }, -}); -Ext.define('Proxmox.node.NodeOptionsView', { - extend: 'Proxmox.grid.ObjectGrid', - alias: ['widget.proxmoxNodeOptionsView'], - mixins: ['Proxmox.Mixin.CBind'], - - cwidth1: 250, - - cbindData: function (_initialconfig) { - let me = this; - - let baseUrl = `/nodes/${me.nodename}/config`; - me.url = `/api2/json${baseUrl}`; - me.editorConfig = { - url: `/api2/extjs/${baseUrl}`, - }; - - return {}; - }, - - listeners: { - itemdblclick: function () { - this.run_editor(); - }, - activate: function () { - this.rstore.startUpdate(); - }, - destroy: function () { - this.rstore.stopUpdate(); - }, - deactivate: function () { - this.rstore.stopUpdate(); - }, - }, - - tbar: [ - { - text: gettext('Edit'), - xtype: 'proxmoxButton', - disabled: true, - handler: (btn) => btn.up('grid').run_editor(), - }, - ], - - gridRows: [ - { - xtype: 'integer', - name: 'startall-onboot-delay', - text: gettext('Start on boot delay'), - minValue: 0, - maxValue: 300, - labelWidth: 130, - deleteEmpty: true, - renderer: function (value) { - if (value === undefined) { - return Proxmox.Utils.defaultText; - } - - // TODO: simplify once we can use ngetext - let secString = value === '1' ? gettext('Second') : gettext('Seconds'); - return `${value} ${secString}`; - }, - }, - { - xtype: 'text', - name: 'wakeonlan', - text: gettext('MAC address for Wake on LAN'), - vtype: 'MacAddress', - labelWidth: 150, - deleteEmpty: true, - renderer: (value) => (value !== undefined ? value : Proxmox.Utils.NoneText), - }, - { - xtype: 'integer', - name: 'ballooning-target', - text: gettext('RAM usage target for ballooning'), - minValue: 0, - maxValue: 100, - deleteEmpty: true, - onlineHelp: 'qm_memory', - renderer: (value) => (value !== undefined ? `${value}%` : gettext('Default (80%)')), - }, - ], -}); -Ext.define('PVE.pool.Config', { - extend: 'PVE.panel.Config', - alias: 'widget.pvePoolConfig', - - onlineHelp: 'pveum_pools', - - initComponent: function () { - var me = this; - - var pool = me.pveSelNode.data.pool; - if (!pool) { - throw 'no pool specified'; - } - - Ext.apply(me, { - title: Ext.String.format(gettext('Resource Pool') + ': ' + pool), - hstateid: 'pooltab', - items: [ - { - title: gettext('Summary'), - iconCls: 'fa fa-book', - xtype: 'pvePoolSummary', - itemId: 'summary', - }, - { - title: gettext('Members'), - xtype: 'pvePoolMembers', - iconCls: 'fa fa-th', - pool: pool, - itemId: 'members', - }, - { - xtype: 'pveACLView', - title: gettext('Permissions'), - iconCls: 'fa fa-unlock', - itemId: 'permissions', - path: '/pool/' + pool, - }, - ], - }); - - me.callParent(); - }, -}); -Ext.define('PVE.pool.StatusView', { - extend: 'Proxmox.grid.ObjectGrid', - alias: ['widget.pvePoolStatusView'], - disabled: true, - - title: gettext('Status'), - cwidth1: 150, - interval: 30000, - //height: 195, - initComponent: function () { - var me = this; - - var pool = me.pveSelNode.data.pool; - if (!pool) { - throw 'no pool specified'; - } - - var rows = { - comment: { - header: gettext('Comment'), - renderer: Ext.String.htmlEncode, - required: true, - }, - }; - - Ext.apply(me, { - url: '/api2/json/pools/?poolid=' + pool, - rows: rows, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.pool.Summary', { - extend: 'Ext.panel.Panel', - alias: 'widget.pvePoolSummary', - - initComponent: function () { - var me = this; - - var pool = me.pveSelNode.data.pool; - if (!pool) { - throw 'no pool specified'; - } - - var statusview = Ext.create('PVE.pool.StatusView', { - pveSelNode: me.pveSelNode, - style: 'padding-top:0px', - }); - - var rstore = statusview.rstore; - - Ext.apply(me, { - autoScroll: true, - bodyStyle: 'padding:10px', - defaults: { - style: 'padding-top:10px', - width: 800, - }, - items: [statusview], - }); - - me.on('activate', rstore.startUpdate); - me.on('destroy', rstore.stopUpdate); - - me.callParent(); - }, -}); -Ext.define('PVE.qemu.AudioInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveAudioInputPanel', - - onlineHelp: 'qm_audio_device', - - onGetValues: function (values) { - var ret = PVE.Parser.printPropertyString(values); - if (ret === '') { - return { - delete: 'audio0', - }; - } - return { - audio0: ret, - }; - }, - - items: [ - { - name: 'device', - xtype: 'proxmoxKVComboBox', - value: 'ich9-intel-hda', - fieldLabel: gettext('Audio Device'), - comboItems: [ - ['ich9-intel-hda', 'ich9-intel-hda'], - ['intel-hda', 'intel-hda'], - ['AC97', 'AC97'], - ], - }, - { - name: 'driver', - xtype: 'proxmoxKVComboBox', - value: 'spice', - fieldLabel: gettext('Backend Driver'), - comboItems: [ - ['spice', 'SPICE'], - ['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`], - ], - }, - ], -}); - -Ext.define('PVE.qemu.AudioEdit', { - extend: 'Proxmox.window.Edit', - - vmconfig: undefined, - - subject: gettext('Audio Device'), - - items: [ - { - xtype: 'pveAudioInputPanel', - }, - ], - - initComponent: function () { - var me = this; - - me.callParent(); - - me.load({ - success: function (response) { - me.vmconfig = response.result.data; - - var audio0 = me.vmconfig.audio0; - if (audio0) { - me.setValues(PVE.Parser.parsePropertyString(audio0)); - } - }, - }); - }, -}); -Ext.define('pve-boot-order-entry', { - extend: 'Ext.data.Model', - fields: [ - { name: 'name', type: 'string' }, - { name: 'enabled', type: 'bool' }, - { name: 'desc', type: 'string' }, - ], -}); - -Ext.define('PVE.qemu.BootOrderPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveQemuBootOrderPanel', - - onlineHelp: 'qm_bootorder', - - vmconfig: {}, // store loaded vm config - store: undefined, - - inUpdate: false, - controller: { - xclass: 'Ext.app.ViewController', - - init: function (view) { - let me = this; - - let grid = me.lookup('grid'); - let marker = me.lookup('marker'); - let emptyWarning = me.lookup('emptyWarning'); - - marker.originalValue = undefined; - - view.store = Ext.create('Ext.data.Store', { - model: 'pve-boot-order-entry', - listeners: { - update: function () { - this.commitChanges(); - let val = view.calculateValue(); - if (marker.originalValue === undefined) { - marker.originalValue = val; - } - view.inUpdate = true; - marker.setValue(val); - view.inUpdate = false; - marker.checkDirty(); - emptyWarning.setHidden(val !== ''); - grid.getView().refresh(); - }, - }, - }); - grid.setStore(view.store); - }, - }, - - isCloudinit: (v) => v.match(/media=cdrom/) && v.match(/[:/]vm-\d+-cloudinit/), - - isDisk: function (value) { - return PVE.Utils.bus_match.test(value); - }, - - isBootdev: function (dev, value) { - return ( - (this.isDisk(dev) && !this.isCloudinit(value)) || - /^net\d+/.test(dev) || - /^hostpci\d+/.test(dev) || - (/^usb\d+/.test(dev) && !/spice/.test(value)) - ); - }, - - setVMConfig: function (vmconfig) { - let me = this; - me.vmconfig = vmconfig; - - me.store.removeAll(); - - let boot = PVE.Parser.parsePropertyString(me.vmconfig.boot, 'legacy'); - - let bootorder = []; - if (boot.order) { - bootorder = boot.order.split(';').map((dev) => ({ name: dev, enabled: true })); - } else if (!/^\s*$/.test(me.vmconfig.boot)) { - // legacy style, transform to new bootorder - let order = boot.legacy || 'cdn'; - let bootdisk = me.vmconfig.bootdisk || undefined; - - // get the first 4 characters (acdn) - // ignore the rest (there should never be more than 4) - let orderList = order.split('').slice(0, 4); - - // build bootdev list - for (let i = 0; i < orderList.length; i++) { - let list = []; - if (orderList[i] === 'c') { - if (bootdisk !== undefined && me.vmconfig[bootdisk]) { - list.push(bootdisk); - } - } else if (orderList[i] === 'd') { - Ext.Object.each(me.vmconfig, function (key, value) { - if ( - me.isDisk(key) && - value.match(/media=cdrom/) && - !me.isCloudinit(value) - ) { - list.push(key); - } - }); - } else if (orderList[i] === 'n') { - Ext.Object.each(me.vmconfig, function (key, value) { - if (/^net\d+/.test(key)) { - list.push(key); - } - }); - } - - // Object.each iterates in random order, sort alphabetically - list.sort(); - list.forEach((dev) => bootorder.push({ name: dev, enabled: true })); - } - } - - // add disabled devices as well - let disabled = []; - Ext.Object.each(me.vmconfig, function (key, value) { - if (me.isBootdev(key, value) && !Ext.Array.some(bootorder, (x) => x.name === key)) { - disabled.push(key); - } - }); - disabled.sort(); - disabled.forEach((dev) => bootorder.push({ name: dev, enabled: false })); - - // add descriptions - bootorder.forEach((entry) => { - entry.desc = me.vmconfig[entry.name]; - }); - - me.store.insert(0, bootorder); - me.store.fireEvent('update'); - }, - - calculateValue: function () { - let me = this; - return me.store - .getData() - .items.filter((x) => x.data.enabled) - .map((x) => x.data.name) - .join(';'); - }, - - onGetValues: function () { - let me = this; - // Note: we allow an empty value, so no 'delete' option - let val = { order: me.calculateValue() }; - let res = { boot: PVE.Parser.printPropertyString(val) }; - return res; - }, - - items: [ - { - xtype: 'grid', - reference: 'grid', - margin: '0 0 5 0', - minHeight: 150, - defaults: { - sortable: false, - hideable: false, - draggable: false, - }, - columns: [ - { - header: '#', - flex: 4, - renderer: (value, metaData, record, rowIndex) => { - let dragHandle = - ""; - let idx = (rowIndex + 1).toString(); - if (record.get('enabled')) { - return dragHandle + idx; - } else { - return dragHandle + "" + idx + ''; - } - }, - }, - { - xtype: 'checkcolumn', - header: gettext('Enabled'), - dataIndex: 'enabled', - flex: 4, - }, - { - header: gettext('Device'), - dataIndex: 'name', - flex: 6, - renderer: (value, metaData, record, rowIndex) => { - let desc = record.get('desc'); - - let icon = '', - iconCls; - if (value.match(/^net\d+$/)) { - iconCls = 'exchange'; - } else if (desc.match(/media=cdrom/)) { - metaData.tdCls = 'pve-itype-icon-cdrom'; - } else { - iconCls = 'hdd-o'; - } - if (iconCls !== undefined) { - metaData.tdCls += 'pve-itype-fa'; - icon = ``; - } - - return icon + value; - }, - }, - { - header: gettext('Description'), - dataIndex: 'desc', - flex: 20, - }, - ], - viewConfig: { - plugins: { - ptype: 'gridviewdragdrop', - dragText: gettext('Drag and drop to reorder'), - }, - }, - listeners: { - drop: function () { - // doesn't fire automatically on reorder - this.getStore().fireEvent('update'); - }, - }, - }, - { - xtype: 'component', - html: gettext('Drag and drop to reorder'), - }, - { - xtype: 'displayfield', - reference: 'emptyWarning', - userCls: 'pmx-hint', - value: gettext('Warning: No devices selected, the VM will probably not boot!'), - }, - { - // for dirty marking and 'reset' function - xtype: 'field', - reference: 'marker', - hidden: true, - setValue: function (val) { - let me = this; - let panel = me.up('pveQemuBootOrderPanel'); - - // on form reset, go back to original state - if (!panel.inUpdate) { - panel.setVMConfig(panel.vmconfig); - } - - // not a subclass, so no callParent; just do it manually - me.setRawValue(me.valueToRaw(val)); - return me.mixins.field.setValue.call(me, val); - }, - }, - ], -}); - -Ext.define('PVE.qemu.BootOrderEdit', { - extend: 'Proxmox.window.Edit', - - items: [ - { - xtype: 'pveQemuBootOrderPanel', - itemId: 'inputpanel', - }, - ], - - subject: gettext('Boot Order'), - width: 640, - - initComponent: function () { - let me = this; - me.callParent(); - me.load({ - success: ({ result }) => me.down('#inputpanel').setVMConfig(result.data), - }); - }, -}); -Ext.define('PVE.qemu.CDInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveQemuCDInputPanel', - - insideWizard: false, - - onGetValues: function (values) { - var me = this; - - var confid = me.confid || values.controller + values.deviceid; - - me.drive.media = 'cdrom'; - if (values.mediaType === 'iso') { - me.drive.file = values.cdimage; - } else if (values.mediaType === 'cdrom') { - me.drive.file = 'cdrom'; - } else { - me.drive.file = 'none'; - } - - var params = {}; - - params[confid] = PVE.Parser.printQemuDrive(me.drive); - - return params; - }, - - setVMConfig: function (vmconfig) { - var me = this; - - if (me.bussel) { - me.bussel.setVMConfig(vmconfig, 'cdrom'); - } - }, - - setDrive: function (drive) { - var me = this; - - var values = {}; - if (drive.file === 'cdrom') { - values.mediaType = 'cdrom'; - } else if (drive.file === 'none') { - values.mediaType = 'none'; - } else { - values.mediaType = 'iso'; - values.cdimage = drive.file; - } - - me.drive = drive; - - me.setValues(values); - }, - - setNodename: function (nodename) { - var me = this; - - me.isosel.setNodename(nodename); - }, - - initComponent: function () { - var me = this; - - me.drive = {}; - - var items = []; - - if (!me.confid) { - me.bussel = Ext.create('PVE.form.ControllerSelector', { - withVirtIO: false, - }); - items.push(me.bussel); - } - - items.push({ - xtype: 'radiofield', - name: 'mediaType', - inputValue: 'iso', - boxLabel: gettext('Use CD/DVD disc image file (iso)'), - checked: true, - listeners: { - change: function (f, value) { - if (!me.rendered) { - return; - } - var cdImageField = me.down('pveIsoSelector'); - cdImageField.setDisabled(!value); - if (value) { - cdImageField.validate(); - } else { - cdImageField.reset(); - } - }, - }, - }); - - me.isosel = Ext.create('PVE.form.IsoSelector', { - nodename: me.nodename, - insideWizard: me.insideWizard, - name: 'cdimage', - }); - - items.push(me.isosel); - - items.push({ - xtype: 'radiofield', - name: 'mediaType', - inputValue: 'cdrom', - boxLabel: gettext('Use physical CD/DVD Drive'), - }); - - items.push({ - xtype: 'radiofield', - name: 'mediaType', - inputValue: 'none', - boxLabel: gettext('Do not use any media'), - }); - - me.items = items; - - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.CDEdit', { - extend: 'Proxmox.window.Edit', - - width: 400, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.isCreate = !me.confid; - - var ipanel = Ext.create('PVE.qemu.CDInputPanel', { - confid: me.confid, - nodename: nodename, - }); - - Ext.applyIf(me, { - subject: 'CD/DVD Drive', - items: [ipanel], - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - ipanel.setVMConfig(response.result.data); - if (me.confid) { - let value = response.result.data[me.confid]; - let drive = PVE.Parser.parseQemuDrive(me.confid, value); - if (!drive) { - Ext.Msg.alert('Error', 'Unable to parse drive options'); - me.close(); - return; - } - ipanel.setDrive(drive); - } - }, - }); - }, -}); -Ext.define('PVE.qemu.CIDriveInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveCIDriveInputPanel', - - insideWizard: false, - - vmconfig: {}, // used to select usused disks - - onGetValues: function (values) { - var _me = this; - - var drive = {}; - var params = {}; - drive.file = values.hdstorage + ':cloudinit'; - drive.format = values.diskformat; - params[values.controller + values.deviceid] = PVE.Parser.printQemuDrive(drive); - return params; - }, - - setNodename: function (nodename) { - var me = this; - me.down('#hdstorage').setNodename(nodename); - me.down('#hdimage').setStorage(undefined, nodename); - }, - - setVMConfig: function (config) { - var me = this; - me.down('#drive').setVMConfig(config, 'cdrom'); - }, - - initComponent: function () { - var me = this; - - me.drive = {}; - - me.items = [ - { - xtype: 'pveControllerSelector', - withVirtIO: false, - itemId: 'drive', - fieldLabel: gettext('CloudInit Drive'), - name: 'drive', - }, - { - xtype: 'pveDiskStorageSelector', - itemId: 'storselector', - storageContent: 'images', - nodename: me.nodename, - hideSize: true, - }, - ]; - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.CIDriveEdit', { - extend: 'Proxmox.window.Edit', - xtype: 'pveCIDriveEdit', - - isCreate: true, - subject: gettext('CloudInit Drive'), - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.items = [ - { - xtype: 'pveCIDriveInputPanel', - itemId: 'cipanel', - nodename: nodename, - }, - ]; - - me.callParent(); - - me.load({ - success: function (response, opts) { - me.down('#cipanel').setVMConfig(response.result.data); - }, - }); - }, -}); -Ext.define('PVE.qemu.CloudInit', { - extend: 'Proxmox.grid.PendingObjectGrid', - xtype: 'pveCiPanel', - - onlineHelp: 'qm_cloud_init', - - tbar: [ - { - xtype: 'proxmoxButton', - disabled: true, - dangerous: true, - confirmMsg: function (rec) { - let view = this.up('grid'); - var warn = gettext('Are you sure you want to remove entry {0}'); - - var entry = rec.data.key; - var msg = Ext.String.format(warn, "'" + view.renderKey(entry, {}, rec) + "'"); - - return msg; - }, - enableFn: function (record) { - let view = this.up('grid'); - var caps = Ext.state.Manager.get('GuiCap'); - let caps_ci = caps.vms['VM.Config.Network'] || caps.vms['VM.Config.Cloudinit']; - if (view.rows[record.data.key].never_delete || !caps_ci) { - return false; - } - - if (record.data.key === 'cipassword' && !record.data.value) { - return false; - } - return true; - }, - handler: function () { - let view = this.up('grid'); - let records = view.getSelection(); - if (!records || !records.length) { - return; - } - - var id = records[0].data.key; - var match = id.match(/^net(\d+)$/); - if (match) { - id = 'ipconfig' + match[1]; - } - - var params = {}; - params.delete = id; - Proxmox.Utils.API2Request({ - url: view.baseurl + '/config', - waitMsgTarget: view, - method: 'PUT', - params: params, - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - callback: function () { - view.reload(); - }, - }); - }, - text: gettext('Remove'), - }, - { - xtype: 'proxmoxButton', - disabled: true, - enableFn: function (rec) { - let view = this.up('pveCiPanel'); - return !!view.rows[rec.data.key].editor; - }, - handler: function () { - let view = this.up('grid'); - view.run_editor(); - }, - text: gettext('Edit'), - }, - '-', - { - xtype: 'button', - itemId: 'savebtn', - text: gettext('Regenerate Image'), - handler: function () { - let view = this.up('grid'); - - Proxmox.Utils.API2Request({ - url: view.baseurl + '/cloudinit', - waitMsgTarget: view, - method: 'PUT', - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - callback: function () { - view.reload(); - }, - }); - }, - }, - ], - - border: false, - - set_button_status: function (rstore, records, success) { - if (!success || records.length < 1) { - return; - } - var me = this; - var found; - records.forEach(function (record) { - if (found) { - return; - } - var id = record.data.key; - var value = record.data.value; - var ciregex = new RegExp('vm-' + me.pveSelNode.data.vmid + '-cloudinit'); - if (id.match(/^(ide|scsi|sata)\d+$/) && ciregex.test(value)) { - found = id; - me.ciDriveId = found; - me.ciDrive = value; - } - }); - - let caps = Ext.state.Manager.get('GuiCap'); - let canRegenerateImage = !!caps.vms['VM.Config.Cloudinit']; - me.down('#savebtn').setDisabled(!found || !canRegenerateImage); - - me.setDisabled(!found); - if (!found) { - me.getView().mask(gettext('No CloudInit Drive found'), ['pve-static-mask']); - } else { - me.getView().unmask(); - } - }, - - renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { - var me = this; - var rows = me.rows; - var rowdef = rows[key] || {}; - - var icon = ''; - if (rowdef.iconCls) { - icon = ' '; - } - return icon + (rowdef.header || key); - }, - - listeners: { - activate: function () { - var me = this; - me.rstore.startUpdate(); - }, - itemdblclick: function () { - var me = this; - me.run_editor(); - }, - }, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - var caps = Ext.state.Manager.get('GuiCap'); - me.baseurl = '/api2/extjs/nodes/' + nodename + '/qemu/' + vmid; - me.url = me.baseurl + '/pending'; - me.editorConfig.url = me.baseurl + '/config'; - me.editorConfig.pveSelNode = me.pveSelNode; - - let caps_ci = caps.vms['VM.Config.Cloudinit'] || caps.vms['VM.Config.Network']; - /* editor is string and object */ - me.rows = { - ciuser: { - header: gettext('User'), - iconCls: 'fa fa-user', - never_delete: true, - defaultValue: '', - editor: caps_ci - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('User'), - items: [ - { - xtype: 'proxmoxtextfield', - deleteEmpty: true, - emptyText: Proxmox.Utils.defaultText, - fieldLabel: gettext('User'), - name: 'ciuser', - }, - ], - } - : undefined, - renderer: function (value) { - return Ext.String.htmlEncode(value || Proxmox.Utils.defaultText); - }, - }, - cipassword: { - header: gettext('Password'), - iconCls: 'fa fa-unlock', - defaultValue: '', - editor: caps_ci - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Password'), - items: [ - { - xtype: 'proxmoxtextfield', - inputType: 'password', - deleteEmpty: true, - emptyText: Proxmox.Utils.noneText, - fieldLabel: gettext('Password'), - name: 'cipassword', - }, - ], - } - : undefined, - renderer: function (value) { - return Ext.String.htmlEncode(value || Proxmox.Utils.noneText); - }, - }, - searchdomain: { - header: gettext('DNS domain'), - iconCls: 'fa fa-globe', - editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined, - never_delete: true, - defaultValue: gettext('use host settings'), - }, - nameserver: { - header: gettext('DNS servers'), - iconCls: 'fa fa-globe', - editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined, - never_delete: true, - defaultValue: gettext('use host settings'), - }, - sshkeys: { - header: gettext('SSH public key'), - iconCls: 'fa fa-key', - editor: caps_ci ? 'PVE.qemu.SSHKeyEdit' : undefined, - never_delete: true, - renderer: function (value) { - value = decodeURIComponent(value); - var keys = value.split('\n'); - var text = []; - keys.forEach(function (key) { - if (key.length) { - let res = PVE.Parser.parseSSHKey(key); - if (res) { - key = Ext.String.htmlEncode(res.comment); - if (res.options) { - key += - ' (' + - gettext('with options') + - ')'; - } - text.push(key); - return; - } - // Most likely invalid at this point, so just stick to - // the old value. - text.push(Ext.String.htmlEncode(key)); - } - }); - if (text.length) { - return text.join('
    '); - } else { - return Proxmox.Utils.noneText; - } - }, - defaultValue: '', - }, - ciupgrade: { - header: gettext('Upgrade packages'), - iconCls: 'fa fa-archive', - renderer: Proxmox.Utils.format_boolean, - defaultValue: 1, - editor: { - xtype: 'proxmoxWindowEdit', - subject: gettext('Upgrade packages on boot'), - items: { - xtype: 'proxmoxcheckbox', - name: 'ciupgrade', - uncheckedValue: 0, - value: 1, // serves as default value, using defaultValue is not enough - fieldLabel: gettext('Upgrade packages'), - labelWidth: 140, - }, - }, - }, - }; - var i; - var ipconfig_renderer = function (value, md, record, ri, ci, store, pending) { - var id = record.data.key; - var match = id.match(/^net(\d+)$/); - var val = ''; - if (match) { - val = me.getObjectValue('ipconfig' + match[1], '', pending); - } - return val; - }; - for (i = 0; i < 32; i++) { - // we want to show an entry for every network device - // even if it is empty - me.rows['net' + i.toString()] = { - multiKey: ['ipconfig' + i.toString(), 'net' + i.toString()], - header: gettext('IP Config') + ' (net' + i.toString() + ')', - editor: caps_ci ? 'PVE.qemu.IPConfigEdit' : undefined, - iconCls: 'fa fa-exchange', - renderer: ipconfig_renderer, - }; - me.rows['ipconfig' + i.toString()] = { - visible: false, - }; - } - - PVE.Utils.forEachBus(['ide', 'scsi', 'sata'], function (type, id) { - me.rows[type + id] = { - visible: false, - }; - }); - me.callParent(); - me.mon(me.rstore, 'load', me.set_button_status, me); - }, -}); -Ext.define('PVE.qemu.CmdMenu', { - extend: 'Ext.menu.Menu', - - showSeparator: false, - initComponent: function () { - let me = this; - - let info = me.pveSelNode.data; - if (!info.node) { - throw 'no node name specified'; - } - if (!info.vmid) { - throw 'no VM ID specified'; - } - - let vm_command = function (cmd, params) { - Proxmox.Utils.API2Request({ - params: params, - url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`, - method: 'POST', - failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }; - let confirmedVMCommand = (cmd, params, confirmTask) => { - let task = confirmTask || `qm${cmd}`; - let msg = PVE.Utils.formatGuestTaskConfirmation(task, info.vmid, info.name); - Ext.Msg.confirm(gettext('Confirm'), msg, (btn) => { - if (btn === 'yes') { - vm_command(cmd, params); - } - }); - }; - - let caps = Ext.state.Manager.get('GuiCap'); - let standalone = PVE.Utils.isStandaloneNode(); - - let running = false, - stopped = true, - suspended = false; - switch (info.status) { - case 'running': - running = true; - stopped = false; - break; - case 'suspended': - stopped = false; - suspended = true; - break; - case 'paused': - stopped = false; - suspended = true; - break; - default: - break; - } - - me.title = 'VM ' + info.vmid; - - me.items = [ - { - text: gettext('Start'), - iconCls: 'fa fa-fw fa-play', - hidden: running || suspended, - disabled: running || suspended, - handler: () => vm_command('start'), - }, - { - text: gettext('Pause'), - iconCls: 'fa fa-fw fa-pause', - hidden: stopped || suspended, - disabled: stopped || suspended, - handler: () => confirmedVMCommand('suspend', undefined, 'qmpause'), - }, - { - text: gettext('Hibernate'), - iconCls: 'fa fa-fw fa-download', - hidden: stopped || suspended, - disabled: stopped || suspended, - tooltip: gettext('Suspend to disk'), - handler: () => confirmedVMCommand('suspend', { todisk: 1 }), - }, - { - text: gettext('Resume'), - iconCls: 'fa fa-fw fa-play', - hidden: !suspended, - handler: () => vm_command('resume'), - }, - { - text: gettext('Shutdown'), - iconCls: 'fa fa-fw fa-power-off', - disabled: stopped || suspended, - handler: () => confirmedVMCommand('shutdown'), - }, - { - text: gettext('Stop'), - iconCls: 'fa fa-fw fa-stop', - disabled: stopped, - tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'), - handler: () => { - Ext.create('PVE.GuestStop', { - nodename: info.node, - vm: info, - autoShow: true, - }); - }, - }, - { - text: gettext('Reboot'), - iconCls: 'fa fa-fw fa-refresh', - disabled: stopped, - tooltip: Ext.String.format(gettext('Reboot {0}'), 'VM'), - handler: () => confirmedVMCommand('reboot'), - }, - { - text: gettext('Reset'), - iconCls: 'fa fa-fw fa-bolt', - disabled: stopped, - tooltip: Ext.String.format(gettext('Reset {0}'), 'VM'), - handler: () => confirmedVMCommand('reset'), - }, - { - xtype: 'menuseparator', - hidden: - (standalone || !caps.vms['VM.Migrate']) && - !caps.vms['VM.Allocate'] && - !caps.vms['VM.Clone'], - }, - { - text: gettext('Migrate'), - iconCls: 'fa fa-fw fa-send-o', - hidden: standalone || !caps.vms['VM.Migrate'], - handler: function () { - Ext.create('PVE.window.Migrate', { - vmtype: 'qemu', - nodename: info.node, - vmid: info.vmid, - vmname: info.name, - autoShow: true, - }); - }, - }, - { - text: gettext('Clone'), - iconCls: 'fa fa-fw fa-clone', - hidden: !caps.vms['VM.Clone'], - handler: () => - PVE.window.Clone.wrap(info.node, info.vmid, info.name, me.isTemplate, 'qemu'), - }, - { - text: gettext('Convert to template'), - iconCls: 'fa fa-fw fa-file-o', - hidden: !caps.vms['VM.Allocate'], - handler: function () { - let msg = PVE.Utils.formatGuestTaskConfirmation( - 'qmtemplate', - info.vmid, - info.name, - ); - Ext.Msg.confirm(gettext('Confirm'), msg, (btn) => { - if (btn === 'yes') { - Proxmox.Utils.API2Request({ - url: `/nodes/${info.node}/qemu/${info.vmid}/template`, - method: 'POST', - failure: (response, opts) => - Ext.Msg.alert('Error', response.htmlStatus), - }); - } - }); - }, - }, - { xtype: 'menuseparator' }, - { - text: gettext('Take Snapshot'), - iconCls: 'fa fa-fw fa-history', - itemId: 'takeSnapshotBtn', - disabled: true, - handler: () => { - Ext.create('PVE.window.Snapshot', { - nodename: info.node, - vmid: info.vmid, - vmname: info.name, - viewonly: false, - type: info.type, - isCreate: true, - submitText: gettext('Take Snapshot'), - autoShow: true, - running: running, - }); - }, - }, - { - text: gettext('Backup now'), - iconCls: 'fa fa-fw fa-floppy-o', - disabled: !caps.vms['VM.Backup'], - handler: () => { - Ext.create('PVE.window.Backup', { - nodename: info.node, - vmid: info.vmid, - vmtype: info.type, - vmname: info.name, - autoShow: true, - }); - }, - }, - { xtype: 'menuseparator' }, - { - text: gettext('Console'), - iconCls: 'fa fa-fw fa-terminal', - handler: function () { - Proxmox.Utils.API2Request({ - url: `/nodes/${info.node}/qemu/${info.vmid}/status/current`, - failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus), - success: function ({ result: { data } }, opts) { - PVE.Utils.openDefaultConsoleWindow( - { - spice: data.spice, - xtermjs: data.serial, - }, - 'kvm', - info.vmid, - info.node, - info.name, - ); - }, - }); - }, - }, - ]; - - me.callParent(); - - if (caps.vms['VM.Snapshot']) { - Proxmox.Utils.API2Request({ - url: `/nodes/${info.node}/${info.type}/${info.vmid}/feature`, - params: { feature: 'snapshot' }, - method: 'GET', - success: (response) => { - let hasFeature = response.result.data.hasFeature; - let btn = me.down('#takeSnapshotBtn'); - if (btn) { - btn.setDisabled(!hasFeature); - } - }, - }); - } - }, -}); -Ext.define('PVE.qemu.Config', { - extend: 'PVE.panel.Config', - alias: 'widget.PVE.qemu.Config', - - onlineHelp: 'chapter_virtual_machines', - userCls: 'proxmox-tags-full', - - initComponent: function () { - var me = this; - var vm = me.pveSelNode.data; - - var nodename = vm.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = vm.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var template = !!vm.template; - - var running = !!vm.uptime; - - var caps = Ext.state.Manager.get('GuiCap'); - - var base_url = '/nodes/' + nodename + '/qemu/' + vmid; - - me.statusStore = Ext.create('Proxmox.data.ObjectStore', { - url: '/api2/json' + base_url + '/status/current', - interval: 1000, - }); - - var vm_command = function (cmd, params) { - Proxmox.Utils.API2Request({ - params: params, - url: base_url + '/status/' + cmd, - waitMsgTarget: me, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - }); - }; - - var resumeBtn = Ext.create('Ext.Button', { - text: gettext('Resume'), - disabled: !caps.vms['VM.PowerMgmt'], - hidden: true, - handler: function () { - vm_command('resume'); - }, - iconCls: 'fa fa-play', - }); - - var startBtn = Ext.create('Ext.Button', { - text: gettext('Start'), - disabled: !caps.vms['VM.PowerMgmt'] || running, - hidden: template, - handler: function () { - vm_command('start'); - }, - iconCls: 'fa fa-play', - }); - - var migrateBtn = Ext.create('Ext.Button', { - text: gettext('Migrate'), - disabled: !caps.vms['VM.Migrate'], - hidden: PVE.Utils.isStandaloneNode(), - handler: function () { - var win = Ext.create('PVE.window.Migrate', { - vmtype: 'qemu', - nodename: nodename, - vmid: vmid, - vmname: vm.name, - }); - win.show(); - }, - iconCls: 'fa fa-send-o', - }); - - var moreBtn = Ext.create('Proxmox.button.Button', { - text: gettext('More'), - menu: { - items: [ - { - text: gettext('Clone'), - iconCls: 'fa fa-fw fa-clone', - hidden: !caps.vms['VM.Clone'], - handler: function () { - PVE.window.Clone.wrap(nodename, vmid, vm.name, template, 'qemu'); - }, - }, - { - text: gettext('Convert to template'), - disabled: template, - xtype: 'pveMenuItem', - iconCls: 'fa fa-fw fa-file-o', - hidden: !caps.vms['VM.Allocate'], - confirmMsg: PVE.Utils.formatGuestTaskConfirmation( - 'qmtemplate', - vmid, - vm.name, - ), - handler: function () { - Proxmox.Utils.API2Request({ - url: base_url + '/template', - waitMsgTarget: me, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - }); - }, - }, - { - iconCls: 'fa fa-heartbeat ', - hidden: !caps.nodes['Sys.Console'], - text: gettext('Manage HA'), - handler: function () { - var ha = vm.hastate; - Ext.create('PVE.ha.VMResourceEdit', { - vmid: vmid, - isCreate: !ha || ha === 'unmanaged', - }).show(); - }, - }, - { - text: gettext('Remove'), - itemId: 'removeBtn', - disabled: !caps.vms['VM.Allocate'], - handler: function () { - Ext.create('PVE.window.SafeDestroyGuest', { - url: base_url, - item: { - type: 'VM', - id: vmid, - formattedIdentifier: PVE.Utils.getFormattedGuestIdentifier( - vmid, - vm.name, - ), - }, - taskName: 'qmdestroy', - }).show(); - }, - iconCls: 'fa fa-trash-o', - }, - ], - }, - }); - - var shutdownBtn = Ext.create('PVE.button.Split', { - text: gettext('Shutdown'), - disabled: !caps.vms['VM.PowerMgmt'] || !running, - hidden: template, - confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmshutdown', vmid, vm.name), - handler: function () { - vm_command('shutdown'); - }, - menu: { - items: [ - { - text: gettext('Reboot'), - disabled: !caps.vms['VM.PowerMgmt'], - tooltip: Ext.String.format( - gettext('Shutdown, apply pending changes and reboot {0}'), - 'VM', - ), - confirmMsg: PVE.Utils.formatGuestTaskConfirmation( - 'qmreboot', - vmid, - vm.name, - ), - handler: function () { - vm_command('reboot'); - }, - iconCls: 'fa fa-refresh', - }, - { - text: gettext('Pause'), - disabled: !caps.vms['VM.PowerMgmt'], - confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmpause', vmid, vm.name), - handler: function () { - vm_command('suspend'); - }, - iconCls: 'fa fa-pause', - }, - { - text: gettext('Hibernate'), - disabled: !caps.vms['VM.PowerMgmt'], - confirmMsg: PVE.Utils.formatGuestTaskConfirmation( - 'qmsuspend', - vmid, - vm.name, - ), - tooltip: gettext('Suspend to disk'), - handler: function () { - vm_command('suspend', { todisk: 1 }); - }, - iconCls: 'fa fa-download', - }, - { - text: gettext('Stop'), - disabled: !caps.vms['VM.PowerMgmt'], - tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'), - handler: function () { - Ext.create('PVE.GuestStop', { - nodename: nodename, - vm: vm, - autoShow: true, - }); - }, - iconCls: 'fa fa-stop', - }, - { - text: gettext('Reset'), - disabled: !caps.vms['VM.PowerMgmt'], - tooltip: Ext.String.format(gettext('Reset {0} immediately'), 'VM'), - confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmreset', vmid, vm.name), - handler: function () { - vm_command('reset'); - }, - iconCls: 'fa fa-bolt', - }, - ], - }, - iconCls: 'fa fa-power-off', - }); - - var consoleBtn = Ext.create('PVE.button.ConsoleButton', { - disabled: !caps.vms['VM.Console'], - hidden: template, - consoleType: 'kvm', - // disable spice/xterm for default action until status api call succeeded - enableSpice: false, - enableXtermjs: false, - consoleName: vm.name, - nodename: nodename, - vmid: vmid, - }); - - var statusTxt = Ext.create('Ext.toolbar.TextItem', { - data: { - lock: undefined, - }, - tpl: ['', ' ({lock})', ''], - }); - - let tagsContainer = Ext.create('PVE.panel.TagEditContainer', { - tags: vm.tags, - canEdit: !!caps.vms['VM.Config.Options'], - listeners: { - change: function (tags) { - Proxmox.Utils.API2Request({ - url: base_url + '/config', - method: 'PUT', - params: { - tags, - }, - success: function () { - me.statusStore.load(); - }, - failure: function (response) { - Ext.Msg.alert('Error', response.htmlStatus); - me.statusStore.load(); - }, - }); - }, - }, - }); - - let vm_text = `${vm.vmid} (${vm.name})`; - - Ext.apply(me, { - title: Ext.String.format( - gettext("Virtual Machine {0} on node '{1}'"), - vm_text, - nodename, - ), - hstateid: 'kvmtab', - tbarSpacing: false, - tbar: [ - statusTxt, - tagsContainer, - '->', - resumeBtn, - startBtn, - shutdownBtn, - migrateBtn, - consoleBtn, - moreBtn, - ], - defaults: { statusStore: me.statusStore }, - items: [ - { - title: gettext('Summary'), - xtype: 'pveGuestSummary', - iconCls: 'fa fa-book', - itemId: 'summary', - }, - ], - }); - - if (caps.vms['VM.Console'] && !template) { - me.items.push({ - title: gettext('Console'), - itemId: 'console', - iconCls: 'fa fa-terminal', - xtype: 'pveNoVncConsole', - vmid: vmid, - consoleType: 'kvm', - nodename: nodename, - }); - } - - me.items.push( - { - title: gettext('Hardware'), - itemId: 'hardware', - iconCls: 'fa fa-desktop', - xtype: 'PVE.qemu.HardwareView', - }, - { - title: 'Cloud-Init', - itemId: 'cloudinit', - iconCls: 'fa fa-cloud', - xtype: 'pveCiPanel', - }, - { - title: gettext('Options'), - iconCls: 'fa fa-gear', - itemId: 'options', - xtype: 'PVE.qemu.Options', - }, - { - title: gettext('Task History'), - itemId: 'tasks', - xtype: 'proxmoxNodeTasks', - iconCls: 'fa fa-list-alt', - nodename: nodename, - preFilter: { - vmid, - }, - }, - ); - - if (caps.nodes['Sys.Audit'] && !template) { - me.items.push({ - title: gettext('Monitor'), - iconCls: 'fa fa-eye', - itemId: 'monitor', - xtype: 'pveQemuMonitor', - }); - } - - if (caps.vms['VM.Backup']) { - me.items.push( - { - title: gettext('Backup'), - iconCls: 'fa fa-floppy-o', - xtype: 'pveBackupView', - itemId: 'backup', - }, - { - title: gettext('Replication'), - iconCls: 'fa fa-retweet', - xtype: 'pveReplicaView', - itemId: 'replication', - }, - ); - } - - if ( - (caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] || caps.vms['VM.Audit']) && - !template - ) { - me.items.push({ - title: gettext('Snapshots'), - iconCls: 'fa fa-history', - type: 'qemu', - xtype: 'pveGuestSnapshotTree', - itemId: 'snapshot', - }); - } - - if (caps.vms['VM.Audit']) { - me.items.push( - { - xtype: 'pveFirewallRules', - title: gettext('Firewall'), - iconCls: 'fa fa-shield', - allow_iface: true, - base_url: base_url + '/firewall/rules', - list_refs_url: base_url + '/firewall/refs', - itemId: 'firewall', - firewall_type: 'vm', - }, - { - xtype: 'pveFirewallOptions', - groups: ['firewall'], - iconCls: 'fa fa-gear', - onlineHelp: 'pve_firewall_vm_container_configuration', - title: gettext('Options'), - base_url: base_url + '/firewall/options', - fwtype: 'vm', - itemId: 'firewall-options', - }, - { - xtype: 'pveFirewallAliases', - title: gettext('Alias'), - groups: ['firewall'], - iconCls: 'fa fa-external-link', - base_url: base_url + '/firewall/aliases', - itemId: 'firewall-aliases', - }, - { - xtype: 'pveIPSet', - title: gettext('IPSet'), - groups: ['firewall'], - iconCls: 'fa fa-list-ol', - base_url: base_url + '/firewall/ipset', - list_refs_url: base_url + '/firewall/refs', - itemId: 'firewall-ipset', - }, - ); - } - - if (caps.vms['VM.Console']) { - me.items.push({ - title: gettext('Log'), - groups: ['firewall'], - iconCls: 'fa fa-list', - onlineHelp: 'chapter_pve_firewall', - itemId: 'firewall-fwlog', - xtype: 'proxmoxLogView', - url: '/api2/extjs' + base_url + '/firewall/log', - log_select_timespan: true, - submitFormat: 'U', - }); - } - - if (caps.vms['Permissions.Modify']) { - me.items.push({ - xtype: 'pveACLView', - title: gettext('Permissions'), - iconCls: 'fa fa-unlock', - itemId: 'permissions', - path: '/vms/' + vmid, - }); - } - - me.callParent(); - - var prevQMPStatus = 'unknown'; - me.mon(me.statusStore, 'load', function (s, records, success) { - var status; - var qmpstatus; - var spice = false; - var xtermjs = false; - var lock; - var rec; - - if (!success) { - status = qmpstatus = 'unknown'; - } else { - rec = s.data.get('status'); - status = rec ? rec.data.value : 'unknown'; - rec = s.data.get('qmpstatus'); - qmpstatus = rec ? rec.data.value : 'unknown'; - rec = s.data.get('template'); - template = rec ? rec.data.value : false; - rec = s.data.get('lock'); - lock = rec ? rec.data.value : undefined; - - spice = !!s.data.get('spice'); - xtermjs = !!s.data.get('serial'); - } - - rec = s.data.get('tags'); - tagsContainer.loadTags(rec?.data?.value); - - if (template) { - return; - } - - var resume = ['prelaunch', 'paused', 'suspended'].indexOf(qmpstatus) !== -1; - - if (resume || lock === 'suspended') { - startBtn.setVisible(false); - resumeBtn.setVisible(true); - } else { - startBtn.setVisible(true); - resumeBtn.setVisible(false); - } - - consoleBtn.setEnableSpice(spice); - consoleBtn.setEnableXtermJS(xtermjs); - - statusTxt.update({ lock: lock }); - - let guest_running = - status === 'running' && !(qmpstatus === 'shutdown' || qmpstatus === 'prelaunch'); - startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || template || guest_running); - - shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running'); - me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped'); - consoleBtn.setDisabled(template); - - let wasStopped = ['prelaunch', 'stopped', 'suspended'].indexOf(prevQMPStatus) !== -1; - if (wasStopped && qmpstatus === 'running') { - let con = me.down('#console'); - if (con) { - con.reload(); - } - } - - prevQMPStatus = qmpstatus; - }); - - me.on('afterrender', function () { - me.statusStore.startUpdate(); - }); - - me.on('destroy', function () { - me.statusStore.stopUpdate(); - }); - }, -}); -Ext.define('PVE.qemu.CreateWizard', { - extend: 'PVE.window.Wizard', - alias: 'widget.pveQemuCreateWizard', - mixins: ['Proxmox.Mixin.CBind'], - - viewModel: { - data: { - nodename: '', - current: { - scsihw: '', - }, - }, - formulas: { - cgroupMode: function (get) { - const nodeInfo = PVE.data.ResourceStore.getNodes().find( - (node) => node.node === get('nodename'), - ); - return nodeInfo ? nodeInfo['cgroup-mode'] : 2; - }, - }, - }, - - cbindData: { - nodename: undefined, - }, - - subject: gettext('Virtual Machine'), - - // fot the special case that we have 2 cdrom drives - // - // emulates part of the backend bootorder logic, but includes all cdrom drives since the backend - // cannot know which one is a bootable iso and hardcodes the known values (ide0/2, net0) - calculateBootOrder: function (values) { - // user selected windows + second cdrom - if (values.ide0 && values.ide0.match(/media=cdrom/)) { - let disk; - PVE.Utils.forEachBus(['ide', 'scsi', 'virtio', 'sata'], (type, id) => { - let confId = type + id; - if (!values[confId]) { - return undefined; - } - if (values[confId].match(/media=cdrom/)) { - return undefined; - } - disk = confId; - return false; // abort loop - }); - - let order = []; - if (disk) { - order.push(disk); - } - order.push('ide2', 'ide0'); // ide2 is the install ISO and should be first - if (values.net0) { - order.push('net0'); - } - - return `order=${order.join(';')}`; - } - return undefined; - }, - - items: [ - { - xtype: 'inputpanel', - title: gettext('General'), - onlineHelp: 'qm_general_settings', - column1: [ - { - xtype: 'pveNodeSelector', - name: 'nodename', - cbind: { - selectCurNode: '{!nodename}', - preferredValue: '{nodename}', - }, - bind: { - value: '{nodename}', - }, - fieldLabel: gettext('Node'), - allowBlank: false, - onlineValidator: true, - }, - { - xtype: 'pveGuestIDSelector', - name: 'vmid', - guestType: 'qemu', - value: '', - loadNextFreeID: true, - validateExists: false, - }, - { - xtype: 'textfield', - name: 'name', - vtype: 'DnsName', - value: '', - fieldLabel: gettext('Name'), - allowBlank: true, - }, - { - xtype: 'proxmoxcheckbox', - name: 'ha-managed', - // only submit value of checkbox if checked - uncheckedValue: undefined, - fieldLabel: gettext('Add to HA'), - }, - ], - column2: [ - { - xtype: 'pvePoolSelector', - fieldLabel: gettext('Resource Pool'), - name: 'pool', - value: '', - allowBlank: true, - }, - ], - advancedColumn1: [ - { - xtype: 'proxmoxcheckbox', - name: 'onboot', - uncheckedValue: 0, - defaultValue: 0, - deleteDefaultValue: true, - fieldLabel: gettext('Start at boot'), - }, - ], - advancedColumn2: [ - { - xtype: 'textfield', - name: 'order', - defaultValue: '', - emptyText: 'any', - labelWidth: 120, - fieldLabel: gettext('Start/Shutdown order'), - }, - { - xtype: 'textfield', - name: 'up', - defaultValue: '', - emptyText: 'default', - labelWidth: 120, - fieldLabel: gettext('Startup delay'), - }, - { - xtype: 'textfield', - name: 'down', - defaultValue: '', - emptyText: 'default', - labelWidth: 120, - fieldLabel: gettext('Shutdown timeout'), - }, - ], - - advancedColumnB: [ - { - xtype: 'pveTagFieldSet', - name: 'tags', - maxHeight: 150, - }, - ], - - onGetValues: function (values) { - ['name', 'pool', 'onboot', 'agent'].forEach(function (field) { - if (!values[field]) { - delete values[field]; - } - }); - - var res = PVE.Parser.printStartup({ - order: values.order, - up: values.up, - down: values.down, - }); - - if (res) { - values.startup = res; - } - - delete values.order; - delete values.up; - delete values.down; - - return values; - }, - }, - { - xtype: 'container', - layout: 'hbox', - defaults: { - flex: 1, - padding: '0 10', - }, - title: gettext('OS'), - items: [ - { - xtype: 'pveQemuCDInputPanel', - bind: { - nodename: '{nodename}', - }, - confid: 'ide2', - insideWizard: true, - }, - { - xtype: 'pveQemuOSTypePanel', - insideWizard: true, - bind: { - nodename: '{nodename}', - }, - }, - ], - }, - { - xtype: 'pveQemuSystemPanel', - title: gettext('System'), - isCreate: true, - insideWizard: true, - }, - { - xtype: 'pveMultiHDPanel', - bind: { - nodename: '{nodename}', - }, - title: gettext('Disks'), - }, - { - xtype: 'pveQemuProcessorPanel', - insideWizard: true, - title: gettext('CPU'), - }, - { - xtype: 'pveQemuMemoryPanel', - insideWizard: true, - title: gettext('Memory'), - }, - { - xtype: 'pveQemuNetworkInputPanel', - bind: { - nodename: '{nodename}', - }, - title: gettext('Network'), - insideWizard: true, - }, - { - title: gettext('Confirm'), - layout: 'fit', - items: [ - { - xtype: 'grid', - store: { - model: 'KeyValue', - sorters: [ - { - property: 'key', - direction: 'ASC', - }, - ], - }, - columns: [ - { header: 'Key', width: 150, dataIndex: 'key' }, - { header: 'Value', flex: 1, dataIndex: 'value', renderer: Ext.htmlEncode }, - ], - }, - ], - dockedItems: [ - { - xtype: 'proxmoxcheckbox', - name: 'start', - dock: 'bottom', - margin: '5 0 0 0', - boxLabel: gettext('Start after created'), - }, - ], - listeners: { - show: function (panel) { - let wizard = this.up('window'); - var kv = wizard.getValues(); - var data = []; - - let boot = wizard.calculateBootOrder(kv); - if (boot) { - kv.boot = boot; - } - - Ext.Object.each(kv, function (key, value) { - if (key === 'delete') { - // ignore - return; - } - data.push({ key: key, value: value }); - }); - - var summarystore = panel.down('grid').getStore(); - summarystore.suspendEvents(); - summarystore.removeAll(); - summarystore.add(data); - summarystore.sort(); - summarystore.resumeEvents(); - summarystore.fireEvent('refresh'); - }, - }, - onSubmit: function () { - var wizard = this.up('window'); - var kv = wizard.getValues(); - delete kv.delete; - - var nodename = kv.nodename; - delete kv.nodename; - - let boot = wizard.calculateBootOrder(kv); - if (boot) { - kv.boot = boot; - } - - Proxmox.Utils.API2Request({ - url: '/nodes/' + nodename + '/qemu', - waitMsgTarget: wizard, - method: 'POST', - params: kv, - success: function (response) { - wizard.close(); - }, - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - }); - }, - }, - ], -}); -Ext.define('PVE.qemu.DisplayInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveDisplayInputPanel', - onlineHelp: 'qm_display', - - onGetValues: function (values) { - let ret = PVE.Parser.printPropertyString(values, 'type'); - if (ret === '') { - return { delete: 'vga' }; - } - return { vga: ret }; - }, - - viewModel: { - data: { - type: '__default__', - clipboard: '__default__', - }, - formulas: { - matchNonGUIOption: function (get) { - return get('type').match(/^(serial\d|none)$/); - }, - memoryEmptyText: function (get) { - let val = get('type'); - if (val === 'cirrus') { - return '4'; - } else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') { - return '16'; - } else if (val.match(/^virtio/)) { - return '256'; - } else if (get('matchNonGUIOption')) { - return 'N/A'; - } else { - console.debug('unexpected display type', val); - return Proxmox.Utils.defaultText; - } - }, - isVNC: (get) => get('clipboard') === 'vnc', - hideDefaultHint: (get) => get('isVNC') || get('matchNonGUIOption'), - hideVNCHint: (get) => !get('isVNC') || get('matchNonGUIOption'), - }, - }, - - items: [ - { - name: 'type', - xtype: 'proxmoxKVComboBox', - value: '__default__', - deleteEmpty: false, - fieldLabel: gettext('Graphic card'), - comboItems: Object.entries(PVE.Utils.kvm_vga_drivers), - validator: function (v) { - let cfg = this.up('proxmoxWindowEdit').vmconfig || {}; - - if (v.match(/^serial\d+$/) && (!cfg[v] || cfg[v] !== 'socket')) { - let fmt = gettext("Serial interface '{0}' is not correctly configured."); - return Ext.String.format(fmt, v); - } - return true; - }, - bind: { - value: '{type}', - }, - }, - { - xtype: 'proxmoxintegerfield', - emptyText: Proxmox.Utils.defaultText, - fieldLabel: gettext('Memory') + ' (MiB)', - minValue: 4, - maxValue: 512, - step: 4, - name: 'memory', - bind: { - emptyText: '{memoryEmptyText}', - disabled: '{matchNonGUIOption}', - }, - }, - ], - - advancedItems: [ - { - xtype: 'proxmoxKVComboBox', - name: 'clipboard', - deleteEmpty: false, - value: '__default__', - fieldLabel: gettext('Clipboard'), - comboItems: [ - ['__default__', Proxmox.Utils.defaultText], - ['vnc', 'VNC'], - ], - bind: { - value: '{clipboard}', - disabled: '{matchNonGUIOption}', - }, - }, - { - xtype: 'displayfield', - name: 'vncHint', - userCls: 'pmx-hint', - value: - gettext( - 'You cannot use the default SPICE clipboard if the VNC clipboard is selected.', - ) + - ' ' + - gettext('VNC clipboard requires spice-tools installed in the Guest-VM.'), - bind: { - hidden: '{hideVNCHint}', - }, - }, - { - xtype: 'displayfield', - name: 'vncMigration', - userCls: 'pmx-hint', - value: gettext('You cannot live-migrate while using the VNC clipboard.'), - bind: { - hidden: '{hideVNCHint}', - }, - }, - { - xtype: 'displayfield', - name: 'defaultHint', - userCls: 'pmx-hint', - value: - gettext('This option depends on your display type.') + - ' ' + - gettext( - 'If the display type uses SPICE you are able to use the default SPICE clipboard.', - ), - bind: { - hidden: '{hideDefaultHint}', - }, - }, - ], -}); - -Ext.define('PVE.qemu.DisplayEdit', { - extend: 'Proxmox.window.Edit', - - vmconfig: undefined, - - subject: gettext('Display'), - width: 350, - - items: [ - { - xtype: 'pveDisplayInputPanel', - }, - ], - - initComponent: function () { - let me = this; - - me.callParent(); - - me.load({ - success: function (response) { - me.vmconfig = response.result.data; - let vga = me.vmconfig.vga || '__default__'; - me.setValues(PVE.Parser.parsePropertyString(vga, 'type')); - }, - }); - }, -}); -/* 'change' property is assigned a string and then a function */ -Ext.define('PVE.qemu.HDInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveQemuHDInputPanel', - onlineHelp: 'qm_hard_disk', - - insideWizard: false, - - unused: false, // ADD usused disk imaged - - importDisk: false, // use import options - importSelection: undefined, // preselect a disk to import - - vmconfig: {}, // used to select usused disks - - viewModel: { - data: { - isSCSI: false, - isVirtIO: false, - isSCSISingle: false, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - - onControllerChange: function (field) { - let me = this; - let vm = this.getViewModel(); - - let value = field.getValue(); - vm.set('isSCSI', value.match(/^scsi/)); - vm.set('isVirtIO', value.match(/^virtio/)); - - me.fireIdChange(); - }, - - fireIdChange: function () { - let view = this.getView(); - view.fireEvent('diskidchange', view, view.bussel.getConfId()); - }, - - control: { - 'field[name=controller]': { - change: 'onControllerChange', - afterrender: 'onControllerChange', - }, - 'field[name=deviceid]': { - change: 'fireIdChange', - }, - 'field[name=scsiController]': { - change: function (f, value) { - let vm = this.getViewModel(); - vm.set('isSCSISingle', value === 'virtio-scsi-single'); - }, - }, - }, - - init: function (view) { - var vm = this.getViewModel(); - if (view.isCreate) { - vm.set('isIncludedInBackup', true); - } - if (view.confid) { - vm.set('isSCSI', view.confid.match(/^scsi/)); - vm.set('isVirtIO', view.confid.match(/^virtio/)); - } - }, - }, - - onGetValues: function (values) { - var me = this; - - var params = {}; - var confid = me.confid || values.controller + values.deviceid; - - if (me.unused) { - me.drive.file = me.vmconfig[values.unusedId]; - confid = values.controller + values.deviceid; - } else if (me.isCreate) { - if (values.hdimage) { - me.drive.file = values.hdimage; - } else { - let disksize = values['import-from'] ? 0 : values.disksize; - me.drive.file = `${values.hdstorage}:${disksize}`; - PVE.Utils.propertyStringSet(me.drive, values['import-from'], 'import-from'); - } - me.drive.format = values.diskformat; - } - - PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0'); - PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no'); - PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on'); - PVE.Utils.propertyStringSet(me.drive, values.ssd, 'ssd', 'on'); - PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on'); - PVE.Utils.propertyStringSet(me.drive, values.readOnly, 'ro', 'on'); - PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache'); - PVE.Utils.propertyStringSet(me.drive, values.aio, 'aio'); - - ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'].forEach((name) => { - let burst_name = `${name}_max`; - PVE.Utils.propertyStringSet(me.drive, values[name], name); - PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name); - }); - - params[confid] = PVE.Parser.printQemuDrive(me.drive); - - return params; - }, - - updateVMConfig: function (vmconfig) { - var me = this; - me.vmconfig = vmconfig; - me.bussel?.updateVMConfig(vmconfig); - }, - - setVMConfig: function (vmconfig) { - var me = this; - - me.vmconfig = vmconfig; - - if (me.bussel) { - me.bussel.setVMConfig(vmconfig); - me.scsiController.setValue(vmconfig.scsihw); - } - if (me.unusedDisks) { - let disklist = []; - Ext.Object.each(vmconfig, function (key, value) { - if (key.match(/^unused\d+$/)) { - disklist.push([key, value]); - } - }); - me.unusedDisks.store.loadData(disklist); - me.unusedDisks.setValue(me.confid); - } - }, - - setDrive: function (drive) { - var me = this; - - me.drive = drive; - - var values = {}; - var match = drive.file.match(/^([^:]+):/); - if (match) { - values.hdstorage = match[1]; - } - - values.hdimage = drive.file; - values.backup = PVE.Parser.parseBoolean(drive.backup, 1); - values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1); - values.diskformat = drive.format || 'raw'; - values.cache = drive.cache || '__default__'; - values.discard = drive.discard === 'on'; - values.ssd = PVE.Parser.parseBoolean(drive.ssd); - values.iothread = PVE.Parser.parseBoolean(drive.iothread); - values.readOnly = PVE.Parser.parseBoolean(drive.ro); - values.aio = drive.aio || '__default__'; - - values.mbps_rd = drive.mbps_rd; - values.mbps_wr = drive.mbps_wr; - values.iops_rd = drive.iops_rd; - values.iops_wr = drive.iops_wr; - values.mbps_rd_max = drive.mbps_rd_max; - values.mbps_wr_max = drive.mbps_wr_max; - values.iops_rd_max = drive.iops_rd_max; - values.iops_wr_max = drive.iops_wr_max; - - me.setValues(values); - }, - - setNodename: function (nodename) { - var me = this; - me.down('#hdstorage').setNodename(nodename); - me.down('#hdimage').setStorage(undefined, nodename); - - me.lookup('new-disk')?.setNodename(nodename); - me.lookup('import-source')?.setNodename(nodename); - me.lookup('import-source-file')?.setNodename(nodename); - me.lookup('import-target')?.setNodename(nodename); - }, - - hasAdvanced: true, - - initComponent: function () { - var me = this; - - me.drive = {}; - - let column1 = []; - let column2 = []; - - let advancedColumn1 = []; - let advancedColumn2 = []; - - if (!me.confid || me.unused) { - me.bussel = Ext.create('PVE.form.ControllerSelector', { - vmconfig: me.vmconfig, - selectFree: true, - }); - column1.push(me.bussel); - - me.scsiController = Ext.create('Ext.form.field.Display', { - fieldLabel: gettext('SCSI Controller'), - reference: 'scsiController', - name: 'scsiController', - bind: me.insideWizard - ? { - value: '{current.scsihw}', - visible: '{isSCSI}', - } - : { - visible: '{isSCSI}', - }, - renderer: PVE.Utils.render_scsihw, - submitValue: false, - hidden: true, - }); - column1.push(me.scsiController); - } - - if (me.unused) { - me.unusedDisks = Ext.create('Proxmox.form.KVComboBox', { - name: 'unusedId', - fieldLabel: gettext('Disk image'), - matchFieldWidth: false, - listConfig: { - width: 350, - }, - data: [], - allowBlank: false, - }); - column1.push(me.unusedDisks); - } else if (me.isCreate) { - if (!me.importDisk) { - column1.push({ - reference: 'new-disk', - xtype: 'pveDiskStorageSelector', - storageContent: 'images', - name: 'disk', - nodename: me.nodename, - autoSelect: me.insideWizard, - }); - } else { - if (me.importSelection) { - column1.push({ - xtype: 'displayfield', - fieldLabel: gettext('Selected Image'), - value: me.importSelection, - }); - column1.push({ - xtype: 'hiddenfield', - name: 'import-from', - value: me.importSelection, - }); - } else { - column1.push({ - xtype: 'pveStorageSelector', - reference: 'import-source', - fieldLabel: gettext('Import Storage'), - name: 'import-source-storage', - storageContent: 'import', - nodename: me.nodename, - autoSelect: me.insideWizard, - disabled: false, - listeners: { - change: function (_selector, storage) { - me.lookup('import-source-file').setStorage(storage); - me.lookup('import-source-file').setDisabled(!storage); - }, - }, - }); - column1.push({ - xtype: 'pveFileSelector', - reference: 'import-source-file', - fieldLabel: gettext('Select Image'), - storageContent: 'import', - name: 'import-from', - filter: (rec) => ['qcow2', 'vmdk', 'raw'].indexOf(rec?.data?.format) !== -1, - nodename: me.nodename, - }); - } - column1.push({ - xtype: 'pveDiskStorageSelector', - reference: 'import-target', - storageLabel: gettext('Target Storage'), - hideSize: true, - storageContent: 'images', - name: 'disk', - nodename: me.nodename, - autoSelect: me.insideWizard, - }); - } - } else { - column1.push({ - xtype: 'textfield', - disabled: true, - submitValue: false, - fieldLabel: gettext('Disk image'), - name: 'hdimage', - }); - } - - column2.push( - { - xtype: 'CacheTypeSelector', - name: 'cache', - value: '__default__', - fieldLabel: gettext('Cache'), - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Discard'), - reference: 'discard', - name: 'discard', - }, - { - xtype: 'proxmoxcheckbox', - name: 'iothread', - fieldLabel: 'IO thread', - clearOnDisable: true, - bind: - me.insideWizard || me.isCreate - ? { - disabled: '{!isVirtIO && !isSCSI}', - // Checkbox.setValue handles Arrays in a different way, therefore cast to bool - value: '{!!isVirtIO || (isSCSI && isSCSISingle)}', - } - : { - disabled: '{!isVirtIO && !isSCSI}', - }, - }, - ); - - advancedColumn1.push( - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('SSD emulation'), - name: 'ssd', - clearOnDisable: true, - bind: { - disabled: '{isVirtIO}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'readOnly', // `ro` in the config, we map in get/set values - defaultValue: 0, - fieldLabel: gettext('Read-only'), - clearOnDisable: true, - bind: { - disabled: '{!isVirtIO && !isSCSI}', - }, - }, - ); - - advancedColumn2.push( - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Backup'), - autoEl: { - tag: 'div', - 'data-qtip': gettext('Include volume in backup job'), - }, - name: 'backup', - bind: { - value: '{isIncludedInBackup}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Skip replication'), - name: 'noreplicate', - }, - { - xtype: 'proxmoxKVComboBox', - name: 'aio', - fieldLabel: gettext('Async IO'), - allowBlank: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (io_uring)'], - ['io_uring', 'io_uring'], - ['native', 'native'], - ['threads', 'threads'], - ], - }, - ); - - let labelWidth = 140; - - let bwColumn1 = [ - { - xtype: 'numberfield', - name: 'mbps_rd', - minValue: 1, - step: 1, - fieldLabel: gettext('Read limit') + ' (MB/s)', - labelWidth: labelWidth, - emptyText: gettext('unlimited'), - }, - { - xtype: 'numberfield', - name: 'mbps_wr', - minValue: 1, - step: 1, - fieldLabel: gettext('Write limit') + ' (MB/s)', - labelWidth: labelWidth, - emptyText: gettext('unlimited'), - }, - { - xtype: 'proxmoxintegerfield', - name: 'iops_rd', - minValue: 10, - step: 10, - fieldLabel: gettext('Read limit') + ' (ops/s)', - labelWidth: labelWidth, - emptyText: gettext('unlimited'), - }, - { - xtype: 'proxmoxintegerfield', - name: 'iops_wr', - minValue: 10, - step: 10, - fieldLabel: gettext('Write limit') + ' (ops/s)', - labelWidth: labelWidth, - emptyText: gettext('unlimited'), - }, - ]; - - let bwColumn2 = [ - { - xtype: 'numberfield', - name: 'mbps_rd_max', - minValue: 1, - step: 1, - fieldLabel: gettext('Read max burst') + ' (MB)', - labelWidth: labelWidth, - emptyText: gettext('default'), - }, - { - xtype: 'numberfield', - name: 'mbps_wr_max', - minValue: 1, - step: 1, - fieldLabel: gettext('Write max burst') + ' (MB)', - labelWidth: labelWidth, - emptyText: gettext('default'), - }, - { - xtype: 'proxmoxintegerfield', - name: 'iops_rd_max', - minValue: 10, - step: 10, - fieldLabel: gettext('Read max burst') + ' (ops)', - labelWidth: labelWidth, - emptyText: gettext('default'), - }, - { - xtype: 'proxmoxintegerfield', - name: 'iops_wr_max', - minValue: 10, - step: 10, - fieldLabel: gettext('Write max burst') + ' (ops)', - labelWidth: labelWidth, - emptyText: gettext('default'), - }, - ]; - - me.items = [ - { - xtype: 'tabpanel', - plain: true, - bodyPadding: 10, - border: 0, - items: [ - { - title: gettext('Disk'), - xtype: 'inputpanel', - reference: 'diskpanel', - column1, - column2, - advancedColumn1, - advancedColumn2, - showAdvanced: me.showAdvanced, - getValues: () => ({}), - }, - { - title: gettext('Bandwidth'), - xtype: 'inputpanel', - reference: 'bwpanel', - column1: bwColumn1, - column2: bwColumn2, - showAdvanced: me.showAdvanced, - getValues: () => ({}), - }, - ], - }, - ]; - - me.callParent(); - }, - - setAdvancedVisible: function (visible) { - this.lookup('diskpanel').setAdvancedVisible(visible); - this.lookup('bwpanel').setAdvancedVisible(visible); - }, -}); - -Ext.define('PVE.qemu.HDEdit', { - extend: 'Proxmox.window.Edit', - - isAdd: true, - - backgroundDelay: 5, - - width: 600, - bodyPadding: 0, - - importDisk: false, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var unused = me.confid && me.confid.match(/^unused\d+$/); - - me.isCreate = me.confid ? unused : true; - - var ipanel = Ext.create('PVE.qemu.HDInputPanel', { - confid: me.confid, - nodename: nodename, - unused: unused, - isCreate: me.isCreate, - importDisk: me.importDisk, - }); - - if (unused) { - me.subject = gettext('Unused Disk'); - } else if (me.isCreate) { - me.subject = gettext('Hard Disk'); - } else { - me.subject = gettext('Hard Disk') + ' (' + me.confid + ')'; - } - - me.items = [ipanel]; - - me.callParent(); - /* 'data' is assigned an empty array in same file, and here we - * use it like an object - */ - me.load({ - success: function (response, options) { - ipanel.setVMConfig(response.result.data); - if (me.confid) { - let value = response.result.data[me.confid]; - let drive = PVE.Parser.parseQemuDrive(me.confid, value); - if (!drive) { - Ext.Msg.alert(gettext('Error'), 'Unable to parse drive options'); - me.close(); - return; - } - ipanel.setDrive(drive); - me.isValid(); // trigger validation - } - }, - }); - }, -}); - -Ext.define('PVE.qemu.HDImportEdit', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - isAdd: true, - isCreate: true, - - backgroundDelay: 5, - - width: 600, - bodyPadding: 0, - - title: gettext('Import Hard Disk'), - - url: 'dummy', // will be set on vmid change - - cbindData: function () { - let me = this; - - if (!me.nodename) { - throw 'no nodename given'; - } - - if (!me.selection) { - throw 'no image preselected'; - } - - return { - nodename: me.nodename, - selection: me.selection, - }; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - onVmidChange: function (_selector, value) { - let me = this; - let view = me.getView(); - let ipanel = me.lookup('ipanel'); - ipanel.setDisabled(true); - ipanel.setVisible(!!value); - let validation = me.lookup('validationProxy'); - validation.setValue(false); - view.url = `/api2/extjs/nodes/${view.nodename}/qemu/${value}/config`; - Proxmox.Utils.setErrorMask(ipanel, true); - - Proxmox.Utils.API2Request({ - url: view.url, - method: 'GET', - success: function (response, opts) { - ipanel.setVMConfig(response.result.data); - - validation.setValue(true); - - ipanel.setDisabled(false); - Proxmox.Utils.setErrorMask(ipanel, false); - }, - failure: function (response, _opts) { - Proxmox.Utils.setErrorMask(ipanel, response.htmlStatus); - }, - }); - }, - }, - - items: [ - { - xtype: 'vmComboSelector', - padding: 10, - allowBlank: false, - fieldLabel: gettext('Target Guest'), - submitValue: false, - cbind: {}, // for nested cbinds - store: { - model: 'PVEResources', - autoLoad: true, - sorters: 'vmid', - cbind: {}, // for nested cbinds - filters: [ - { - property: 'type', - value: 'qemu', - }, - { - property: 'node', - cbind: { - value: '{nodename}', - }, - }, - ], - }, - listeners: { - change: 'onVmidChange', - }, - }, - { - // used to prevent submitting while vm config is being loaded or that returns an error - xtype: 'textfield', - reference: 'validationProxy', - submitValue: false, - hidden: true, - validator: (val) => !!val, - }, - { - xtype: 'pveQemuHDInputPanel', - reference: 'ipanel', - hidden: true, - disabled: true, - isCreate: true, - importDisk: true, - cbind: { - importSelection: '{selection}', - nodename: '{nodename}', - }, - }, - ], -}); -Ext.define('PVE.qemu.EFIDiskInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveEFIDiskInputPanel', - - insideWizard: false, - - unused: false, // ADD usused disk imaged - - vmconfig: {}, // used to select usused disks - - onGetValues: function (values) { - var me = this; - - if (me.disabled) { - return {}; - } - - var confid = 'efidisk0'; - - if (values.hdimage) { - me.drive.file = values.hdimage; - } else { - // we use 1 here, because for efi the size gets overridden from the backend - me.drive.file = values.hdstorage + ':1'; - } - - // always default to newer 4m type with secure boot support, if we're - // adding a new EFI disk there can't be any old state anyway - me.drive.efitype = '4m'; - me.drive['pre-enrolled-keys'] = values.preEnrolledKeys; - delete values.preEnrolledKeys; - - me.drive.format = values.diskformat; - let params = {}; - params[confid] = PVE.Parser.printQemuDrive(me.drive); - return params; - }, - - setNodename: function (nodename) { - var me = this; - me.down('#hdstorage').setNodename(nodename); - me.down('#hdimage').setStorage(undefined, nodename); - }, - - setDisabled: function (disabled) { - let me = this; - me.down('pveDiskStorageSelector').setDisabled(disabled); - me.down('proxmoxcheckbox[name=preEnrolledKeys]').setDisabled(disabled); - me.callParent(arguments); - }, - - initComponent: function () { - var me = this; - - me.drive = {}; - - me.items = [ - { - xtype: 'pveDiskStorageSelector', - name: 'efidisk0', - storageLabel: gettext('EFI Storage'), - storageContent: 'images', - nodename: me.nodename, - disabled: me.disabled, - hideSize: true, - }, - { - xtype: 'proxmoxcheckbox', - name: 'preEnrolledKeys', - checked: true, - fieldLabel: gettext('Pre-Enroll keys'), - disabled: me.disabled, - //boxLabel: '(e.g., Microsoft secure-boot keys')', - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Use EFIvars image with standard distribution and Microsoft secure boot keys enrolled.', - ), - }, - }, - { - xtype: 'label', - text: gettext("Warning: The VM currently does not uses 'OVMF (UEFI)' as BIOS."), - userCls: 'pmx-hint', - hidden: me.usesEFI, - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.EFIDiskEdit', { - extend: 'Proxmox.window.Edit', - - isAdd: true, - subject: gettext('EFI Disk'), - - width: 450, - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.items = [ - { - xtype: 'pveEFIDiskInputPanel', - onlineHelp: 'qm_bios_and_uefi', - confid: me.confid, - nodename: nodename, - usesEFI: me.usesEFI, - isCreate: true, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.qemu.TPMDiskInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveTPMDiskInputPanel', - - unused: false, - vmconfig: {}, - - onGetValues: function (values) { - var me = this; - - if (me.disabled) { - return {}; - } - - var confid = 'tpmstate0'; - - if (values.hdimage) { - me.drive.file = values.hdimage; - } else { - // size is constant, so just use 1 - me.drive.file = values.hdstorage + ':1'; - } - - me.drive.format = values.diskformat; - me.drive.version = values.version; - var params = {}; - params[confid] = PVE.Parser.printQemuDrive(me.drive); - return params; - }, - - setNodename: function (nodename) { - var me = this; - me.down('#hdstorage').setNodename(nodename); - me.down('#hdimage').setStorage(undefined, nodename); - }, - - setDisabled: function (disabled) { - let me = this; - me.down('pveDiskStorageSelector').setDisabled(disabled); - me.down('proxmoxKVComboBox[name=version]').setDisabled(disabled); - me.callParent(arguments); - }, - - initComponent: function () { - var me = this; - - me.drive = {}; - - me.items = [ - { - xtype: 'pveDiskStorageSelector', - name: me.disktype + '0', - storageLabel: gettext('TPM Storage'), - storageContent: 'images', - nodename: me.nodename, - disabled: me.disabled, - hideSize: true, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'version', - value: 'v2.0', - fieldLabel: gettext('Version'), - deleteEmpty: false, - disabled: me.disabled, - comboItems: [ - ['v1.2', 'v1.2'], - ['v2.0', 'v2.0'], - ], - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.TPMDiskEdit', { - extend: 'Proxmox.window.Edit', - - isAdd: true, - subject: gettext('TPM State'), - - width: 450, - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.items = [ - { - xtype: 'pveTPMDiskInputPanel', - onlineHelp: 'qm_tpm', - confid: me.confid, - nodename: nodename, - isCreate: true, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.window.HDMove', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - resizable: false, - modal: true, - width: 350, - border: false, - layout: 'fit', - showReset: false, - showTaskViewer: true, - method: 'POST', - - cbindData: function () { - let me = this; - return { - disk: me.disk, - isQemu: me.type === 'qemu', - nodename: me.nodename, - url: () => { - let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume'; - return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`; - }, - }; - }, - - cbind: { - title: (get) => (get('isQemu') ? gettext('Move disk') : gettext('Move Volume')), - submitText: (get) => get('title'), - qemu: '{isQemu}', - url: '{url}', - }, - - getValues: function () { - let me = this; - let values = me.formPanel.getForm().getValues(); - - let params = { - storage: values.hdstorage, - }; - params[me.qemu ? 'disk' : 'volume'] = me.disk; - - if (values.diskformat && me.qemu) { - params.format = values.diskformat; - } - - if (values.deleteDisk) { - params.delete = 1; - } - return params; - }, - - items: [ - { - xtype: 'form', - reference: 'moveFormPanel', - border: false, - fieldDefaults: { - labelWidth: 100, - anchor: '100%', - }, - items: [ - { - xtype: 'displayfield', - cbind: { - name: (get) => (get('isQemu') ? 'disk' : 'volume'), - fieldLabel: (get) => - get('isQemu') ? gettext('Disk') : gettext('Mount Point'), - value: '{disk}', - }, - allowBlank: false, - }, - { - xtype: 'pveDiskStorageSelector', - storageLabel: gettext('Target Storage'), - cbind: { - nodename: '{nodename}', - storageContent: (get) => (get('isQemu') ? 'images' : 'rootdir'), - }, - hideSize: true, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Delete source'), - name: 'deleteDisk', - uncheckedValue: 0, - checked: false, - }, - ], - }, - ], - - initComponent: function () { - let me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - if (!me.type) { - throw 'no type specified'; - } - - me.callParent(); - }, -}); -Ext.define('PVE.window.HDResize', { - extend: 'Ext.window.Window', - - resizable: false, - - resize_disk: function (disk, size) { - var me = this; - var params = { disk: disk, size: '+' + size + 'G' }; - - Proxmox.Utils.API2Request({ - params: params, - url: '/nodes/' + me.nodename + '/qemu/' + me.vmid + '/resize', - waitMsgTarget: me, - method: 'PUT', - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, options) { - Ext.create('Proxmox.window.TaskProgress', { - autoShow: true, - upid: response.result.data, - }); - me.close(); - }, - }); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.vmid) { - throw 'no VM ID specified'; - } - - var items = [ - { - xtype: 'displayfield', - name: 'disk', - value: me.disk, - fieldLabel: gettext('Disk'), - vtype: 'StorageId', - allowBlank: false, - }, - ]; - - me.hdsizesel = Ext.createWidget('numberfield', { - name: 'size', - minValue: 0, - maxValue: 128 * 1024, - decimalPrecision: 3, - value: '0', - fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`, - allowBlank: false, - }); - - items.push(me.hdsizesel); - - me.formPanel = Ext.create('Ext.form.Panel', { - bodyPadding: 10, - border: false, - fieldDefaults: { - labelWidth: 140, - anchor: '100%', - }, - items: items, - }); - - var form = me.formPanel.getForm(); - - var submitBtn; - - me.title = gettext('Resize disk'); - submitBtn = Ext.create('Ext.Button', { - text: gettext('Resize disk'), - handler: function () { - if (form.isValid()) { - let values = form.getValues(); - me.resize_disk(me.disk, values.size); - } - }, - }); - - Ext.apply(me, { - modal: true, - width: 250, - height: 150, - border: false, - layout: 'fit', - buttons: [submitBtn], - items: [me.formPanel], - }); - - me.callParent(); - }, -}); -Ext.define('PVE.qemu.HardwareView', { - extend: 'Proxmox.grid.PendingObjectGrid', - alias: ['widget.PVE.qemu.HardwareView'], - - onlineHelp: 'qm_virtual_machines_settings', - - renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { - var me = this; - var rows = me.rows; - var rowdef = rows[key] || {}; - var iconCls = rowdef.iconCls; - var icon = ''; - var txt = rowdef.header || key; - - metaData.tdAttr = 'valign=middle'; - - if (rowdef.isOnStorageBus) { - let value = me.getObjectValue(key, '', false); - if (value === '') { - value = me.getObjectValue(key, '', true); - } - if (value.match(/vm-.*-cloudinit/)) { - iconCls = 'cloud'; - txt = rowdef.cloudheader; - } else if (value.match(/media=cdrom/)) { - metaData.tdCls = 'pve-itype-icon-cdrom'; - return rowdef.cdheader; - } - } - - if (rowdef.tdCls) { - metaData.tdCls = rowdef.tdCls; - } else if (iconCls) { - icon = ""; - metaData.tdCls += ' pve-itype-fa'; - } - - // only return icons in grid but not remove dialog - if (rowIndex !== undefined) { - return icon + txt; - } else { - return txt; - } - }, - - initComponent: function () { - var me = this; - - const { node: nodename, vmid } = me.pveSelNode.data; - if (!nodename) { - throw 'no node name specified'; - } else if (!vmid) { - throw 'no VM ID specified'; - } - - const caps = Ext.state.Manager.get('GuiCap'); - const diskCap = caps.vms['VM.Config.Disk']; - const cdromCap = caps.vms['VM.Config.CDROM']; - - let isCloudInitKey = (v) => v && v.toString().match(/vm-.*-cloudinit/); - - const nodeInfo = PVE.data.ResourceStore.getNodes().find((node) => node.node === nodename); - let processorEditor = { - xtype: 'pveQemuProcessorEdit', - cgroupMode: nodeInfo['cgroup-mode'], - }; - - let rows = { - memory: { - header: gettext('Memory'), - editor: caps.vms['VM.Config.Memory'] ? 'PVE.qemu.MemoryEdit' : undefined, - never_delete: true, - defaultValue: '512', - tdCls: 'pve-itype-icon-memory', - group: 2, - multiKey: ['memory', 'balloon', 'shares', 'allow-ksm'], - renderer: function (value, metaData, record, ri, ci, store, pending) { - var res = ''; - - var max = me.getObjectValue('memory', 512, pending); - var balloon = me.getObjectValue('balloon', undefined, pending); - var shares = me.getObjectValue('shares', undefined, pending); - - res = Proxmox.Utils.format_size(max * 1024 * 1024); - - if (balloon !== undefined && balloon > 0) { - res = Proxmox.Utils.format_size(balloon * 1024 * 1024) + '/' + res; - - if (shares) { - res += ' [shares=' + shares + ']'; - } - } else if (balloon === 0) { - res += ' [balloon=0]'; - } - - let allowKsm = me.getObjectValue('allow-ksm', undefined, pending); - if (allowKsm !== undefined) { - res += ' [allow-ksm=' + allowKsm + ']'; - } - - return res; - }, - }, - sockets: { - header: gettext('Processors'), - never_delete: true, - editor: - caps.vms['VM.Config.CPU'] || caps.vms['VM.Config.HWType'] - ? processorEditor - : undefined, - tdCls: 'pve-itype-icon-cpu', - group: 3, - defaultValue: '1', - multiKey: [ - 'sockets', - 'cpu', - 'cores', - 'numa', - 'vcpus', - 'cpulimit', - 'cpuunits', - 'affinity', - ], - renderer: function (value, metaData, record, rowIndex, colIndex, store, pending) { - var sockets = me.getObjectValue('sockets', 1, pending); - var model = me.getObjectValue('cpu', undefined, pending); - var cores = me.getObjectValue('cores', 1, pending); - var numa = me.getObjectValue('numa', undefined, pending); - var vcpus = me.getObjectValue('vcpus', undefined, pending); - var cpulimit = me.getObjectValue('cpulimit', undefined, pending); - var cpuunits = me.getObjectValue('cpuunits', undefined, pending); - var cpuaffinity = me.getObjectValue('affinity', undefined, pending); - - let res = Ext.String.format( - '{0} ({1} sockets, {2} cores)', - sockets * cores, - sockets, - cores, - ); - - if (model) { - res += ' [' + model + ']'; - } - if (numa) { - res += ' [numa=' + numa + ']'; - } - if (vcpus) { - res += ' [vcpus=' + vcpus + ']'; - } - if (cpulimit) { - res += ' [cpulimit=' + cpulimit + ']'; - } - if (cpuunits) { - res += ' [cpuunits=' + cpuunits + ']'; - } - if (cpuaffinity) { - res += ' [cpuaffinity=' + cpuaffinity + ']'; - } - - return res; - }, - }, - bios: { - header: 'BIOS', - group: 4, - never_delete: true, - editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.BiosEdit' : undefined, - defaultValue: '', - iconCls: 'microchip', - renderer: PVE.Utils.render_qemu_bios, - }, - vga: { - header: gettext('Display'), - editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.DisplayEdit' : undefined, - never_delete: true, - iconCls: 'desktop', - group: 5, - defaultValue: '', - renderer: PVE.Utils.render_kvm_vga_driver, - }, - machine: { - header: gettext('Machine'), - editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.MachineEdit' : undefined, - iconCls: 'cogs', - never_delete: true, - group: 6, - defaultValue: '', - renderer: function (value, metaData, record, rowIndex, colIndex, store, pending) { - let ostype = me.getObjectValue('ostype', undefined, pending); - if ( - PVE.Utils.is_windows(ostype) && - (!value || value === 'pc' || value === 'q35') - ) { - return value === 'q35' ? 'pc-q35-5.1' : 'pc-i440fx-5.1'; - } - return PVE.Utils.render_qemu_machine(value); - }, - }, - scsihw: { - header: gettext('SCSI Controller'), - iconCls: 'database', - editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.ScsiHwEdit' : undefined, - renderer: PVE.Utils.render_scsihw, - group: 7, - never_delete: true, - defaultValue: '', - }, - vmstate: { - header: gettext('Hibernation VM State'), - iconCls: 'download', - del_extra_msg: gettext('The saved VM state will be permanently lost.'), - group: 100, - }, - cores: { - visible: false, - }, - cpu: { - visible: false, - }, - numa: { - visible: false, - }, - 'allow-ksm': { - visible: false, - }, - balloon: { - visible: false, - }, - hotplug: { - visible: false, - }, - vcpus: { - visible: false, - }, - cpuunits: { - visible: false, - }, - cpulimit: { - visible: false, - }, - shares: { - visible: false, - }, - ostype: { - visible: false, - }, - affinity: { - visible: false, - }, - }; - - PVE.Utils.forEachBus(undefined, function (type, id) { - let confid = type + id; - rows[confid] = { - group: 10, - iconCls: 'hdd-o', - editor: 'PVE.qemu.HDEdit', - isOnStorageBus: true, - header: gettext('Hard Disk') + ' (' + confid + ')', - cdheader: gettext('CD/DVD Drive') + ' (' + confid + ')', - cloudheader: gettext('CloudInit Drive') + ' (' + confid + ')', - renderer: Ext.htmlEncode, - }; - }); - for (let i = 0; i < PVE.Utils.hardware_counts.net; i++) { - let confid = 'net' + i.toString(); - rows[confid] = { - group: 15, - order: i, - iconCls: 'exchange', - editor: caps.vms['VM.Config.Network'] ? 'PVE.qemu.NetworkEdit' : undefined, - never_delete: !caps.vms['VM.Config.Network'], - header: gettext('Network Device') + ' (' + confid + ')', - }; - } - rows.efidisk0 = { - group: 20, - iconCls: 'hdd-o', - editor: null, - never_delete: !caps.vms['VM.Config.Disk'], - header: gettext('EFI Disk'), - renderer: Ext.htmlEncode, - }; - rows.tpmstate0 = { - group: 22, - iconCls: 'hdd-o', - editor: null, - never_delete: !caps.vms['VM.Config.Disk'], - header: gettext('TPM State'), - renderer: Ext.htmlEncode, - }; - for (let i = 0; i < PVE.Utils.hardware_counts.usb; i++) { - let confid = 'usb' + i.toString(); - rows[confid] = { - group: 25, - order: i, - iconCls: 'usb', - editor: - caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] - ? 'PVE.qemu.USBEdit' - : undefined, - never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], - header: gettext('USB Device') + ' (' + confid + ')', - }; - } - for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) { - let confid = 'hostpci' + i.toString(); - rows[confid] = { - group: 30, - order: i, - tdCls: 'pve-itype-icon-pci', - never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], - editor: - caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] - ? 'PVE.qemu.PCIEdit' - : undefined, - header: gettext('PCI Device') + ' (' + confid + ')', - }; - } - for (let i = 0; i < PVE.Utils.hardware_counts.serial; i++) { - let confid = 'serial' + i.toString(); - rows[confid] = { - group: 35, - order: i, - tdCls: 'pve-itype-icon-serial', - never_delete: !caps.nodes['Sys.Console'], - header: gettext('Serial Port') + ' (' + confid + ')', - }; - } - rows.audio0 = { - group: 40, - iconCls: 'volume-up', - editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.AudioEdit' : undefined, - never_delete: !caps.vms['VM.Config.HWType'], - header: gettext('Audio Device'), - }; - for (let i = 0; i < 256; i++) { - rows['unused' + i.toString()] = { - group: 99, - order: i, - iconCls: 'hdd-o', - del_extra_msg: gettext('This will permanently erase all data.'), - editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.HDEdit' : undefined, - header: gettext('Unused Disk') + ' ' + i.toString(), - renderer: Ext.htmlEncode, - }; - } - rows.rng0 = { - group: 45, - tdCls: 'pve-itype-icon-die', - editor: - caps.vms['VM.Config.HWType'] || caps.mapping['Mapping.Use'] - ? 'PVE.qemu.RNGEdit' - : undefined, - never_delete: !caps.vms['VM.Config.HWType'] && !caps.mapping['Mapping.Use'], - header: gettext('VirtIO RNG'), - }; - for (let i = 0; i < PVE.Utils.hardware_counts.virtiofs; i++) { - let confid = 'virtiofs' + i.toString(); - rows[confid] = { - group: 50, - order: i, - iconCls: 'folder', - editor: 'PVE.qemu.VirtiofsEdit', - header: gettext('Virtiofs') + ' (' + confid + ')', - }; - } - - var sorterFn = function (rec1, rec2) { - var v1 = rec1.data.key; - var v2 = rec2.data.key; - var g1 = rows[v1].group || 0; - var g2 = rows[v2].group || 0; - var order1 = rows[v1].order || 0; - var order2 = rows[v2].order || 0; - - if (g1 - g2 !== 0) { - return g1 - g2; - } - - if (order1 - order2 !== 0) { - return order1 - order2; - } - - if (v1 > v2) { - return 1; - } else if (v1 < v2) { - return -1; - } else { - return 0; - } - }; - - let baseurl = `nodes/${nodename}/qemu/${vmid}/config`; - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec || !rows[rec.data.key]?.editor) { - return; - } - let rowdef = rows[rec.data.key]; - let editor = rowdef.editor; - - if (rowdef.isOnStorageBus) { - let value = me.getObjectValue(rec.data.key, '', true); - if (isCloudInitKey(value)) { - return; - } else if (value.match(/media=cdrom/)) { - editor = 'PVE.qemu.CDEdit'; - } else if (!diskCap) { - return; - } - } - - let commonOpts = { - autoShow: true, - pveSelNode: me.pveSelNode, - confid: rec.data.key, - url: `/api2/extjs/${baseurl}`, - listeners: { - destroy: () => me.reload(), - }, - }; - - if (Ext.isString(editor)) { - Ext.create(editor, commonOpts); - } else { - let win = Ext.createWidget( - rowdef.editor.xtype, - Ext.apply(commonOpts, rowdef.editor), - ); - win.load(); - } - }; - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - selModel: sm, - disabled: true, - handler: run_editor, - }); - - let move_menuitem = new Ext.menu.Item({ - text: gettext('Move Storage'), - tooltip: gettext('Move disk to another storage'), - iconCls: 'fa fa-database', - selModel: sm, - handler: () => { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - Ext.create('PVE.window.HDMove', { - autoShow: true, - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - type: 'qemu', - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }); - - let reassign_menuitem = new Ext.menu.Item({ - text: gettext('Reassign Owner'), - tooltip: gettext('Reassign disk to another VM'), - iconCls: 'fa fa-desktop', - selModel: sm, - handler: () => { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - Ext.create('PVE.window.GuestDiskReassign', { - autoShow: true, - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - type: 'qemu', - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }); - - let resize_menuitem = new Ext.menu.Item({ - text: gettext('Resize'), - iconCls: 'fa fa-plus', - selModel: sm, - handler: () => { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - Ext.create('PVE.window.HDResize', { - autoShow: true, - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }); - - let diskaction_btn = new Proxmox.button.Button({ - text: gettext('Disk Action'), - disabled: true, - menu: { - items: [move_menuitem, reassign_menuitem, resize_menuitem], - }, - }); - - let remove_btn = new Proxmox.button.Button({ - text: gettext('Remove'), - defaultText: gettext('Remove'), - altText: gettext('Detach'), - selModel: sm, - disabled: true, - dangerous: true, - RESTMethod: 'PUT', - confirmMsg: function (rec) { - let warn = gettext('Are you sure you want to remove entry {0}'); - if (this.text === this.altText) { - warn = gettext('Are you sure you want to detach entry {0}'); - } - let rendered = me.renderKey(rec.data.key, {}, rec); - let msg = Ext.String.format(warn, `'${rendered}'`); - - if (rows[rec.data.key].del_extra_msg) { - msg += '
    ' + rows[rec.data.key].del_extra_msg; - } - return msg; - }, - handler: function (btn, e, rec) { - let params = { delete: rec.data.key }; - if (btn.RESTMethod === 'POST') { - params.background_delay = 5; - } - Proxmox.Utils.API2Request({ - url: '/api2/extjs/' + baseurl, - waitMsgTarget: me, - method: btn.RESTMethod, - params: params, - callback: () => me.reload(), - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - success: function (response, options) { - if (btn.RESTMethod === 'POST' && response.result.data !== null) { - Ext.create('Proxmox.window.TaskProgress', { - autoShow: true, - upid: response.result.data, - listeners: { - destroy: () => me.reload(), - }, - }); - } - }, - }); - }, - listeners: { - render: function (btn) { - // hack: calculate the max button width on first display to prevent the whole - // toolbar to move when we switch between the "Remove" and "Detach" labels - var def = btn.getSize().width; - - btn.setText(btn.altText); - var alt = btn.getSize().width; - - btn.setText(btn.defaultText); - - var optimal = alt > def ? alt : def; - btn.setSize({ width: optimal }); - }, - }, - }); - - let revert_btn = new PVE.button.PendingRevert({ - apiurl: '/api2/extjs/' + baseurl, - }); - - let efidisk_menuitem = Ext.create('Ext.menu.Item', { - text: gettext('EFI Disk'), - iconCls: 'fa fa-fw fa-hdd-o black', - disabled: !caps.vms['VM.Config.Disk'], - handler: function () { - let { data: bios } = me.rstore.getData().map.bios || {}; - - Ext.create('PVE.qemu.EFIDiskEdit', { - autoShow: true, - url: '/api2/extjs/' + baseurl, - pveSelNode: me.pveSelNode, - usesEFI: bios?.value === 'ovmf' || bios?.pending === 'ovmf', - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }); - - let counts = {}; - let isAtLimit = (type) => counts[type] >= PVE.Utils.hardware_counts[type]; - let isAtUsbLimit = () => { - let ostype = me.getObjectValue('ostype'); - let machine = me.getObjectValue('machine'); - return counts.usb >= PVE.Utils.get_max_usb_count(ostype, machine); - }; - - let set_button_status = function () { - let selection_model = me.getSelectionModel(); - let rec = selection_model.getSelection()[0]; - - counts = {}; // en/disable hardwarebuttons - let hasCloudInit = false; - me.rstore.getData().items.forEach(function ({ id, data }) { - if (!hasCloudInit && (isCloudInitKey(data.value) || isCloudInitKey(data.pending))) { - hasCloudInit = true; - return; - } - - let match = id.match(/^([^\d]+)\d+$/); - if (match && PVE.Utils.hardware_counts[match[1]] !== undefined) { - let type = match[1]; - counts[type] = (counts[type] || 0) + 1; - } - }); - - // heuristic only for disabling some stuff, the backend has the final word. - const noVMConfigHWTypePerm = !caps.vms['VM.Config.HWType']; - const noVMConfigNetPerm = !caps.vms['VM.Config.Network']; - const noVMConfigDiskPerm = !caps.vms['VM.Config.Disk']; - const noVMConfigCDROMPerm = !caps.vms['VM.Config.CDROM']; - const noVMConfigCloudinitPerm = !caps.vms['VM.Config.Cloudinit']; - const noVMConfigOptionsPerm = !caps.vms['VM.Config.Options']; - - me.down('#addUsb').setDisabled(noVMConfigHWTypePerm || isAtUsbLimit()); - me.down('#addPci').setDisabled(noVMConfigHWTypePerm || isAtLimit('hostpci')); - me.down('#addAudio').setDisabled(noVMConfigHWTypePerm || isAtLimit('audio')); - me.down('#addSerial').setDisabled(noVMConfigHWTypePerm || isAtLimit('serial')); - me.down('#addNet').setDisabled(noVMConfigNetPerm || isAtLimit('net')); - me.down('#addRng').setDisabled(noVMConfigHWTypePerm || isAtLimit('rng')); - efidisk_menuitem.setDisabled(noVMConfigDiskPerm || isAtLimit('efidisk')); - me.down('#addTpmState').setDisabled(noVMConfigDiskPerm || isAtLimit('tpmstate')); - me.down('#addVirtiofs').setDisabled(noVMConfigOptionsPerm || isAtLimit('virtiofs')); - me.down('#addCloudinitDrive').setDisabled( - noVMConfigCDROMPerm || noVMConfigCloudinitPerm || hasCloudInit, - ); - - if (!rec) { - remove_btn.disable(); - edit_btn.disable(); - diskaction_btn.disable(); - revert_btn.disable(); - return; - } - const { key, value } = rec.data; - const row = rows[key]; - - const deleted = !!rec.data.delete; - const pending = deleted || me.hasPendingChanges(key); - const isRunning = me.pveSelNode.data.running; - - const isCloudInit = isCloudInitKey(value); - const isCDRom = value && !!value.toString().match(/media=cdrom/); - - const isUnusedDisk = key.match(/^unused\d+/); - const isUsedDisk = !isUnusedDisk && row.isOnStorageBus && !isCDRom; - const isDisk = isUnusedDisk || isUsedDisk; - const isEfi = key === 'efidisk0'; - const tpmMoveable = key === 'tpmstate0' && !isRunning; - - let cannotDelete = deleted || row.never_delete; - cannotDelete ||= isCDRom && !cdromCap; - cannotDelete ||= isDisk && !diskCap; - cannotDelete ||= isCloudInit && noVMConfigCloudinitPerm; - remove_btn.setDisabled(cannotDelete); - - remove_btn.setText( - isUsedDisk && !isCloudInit ? remove_btn.altText : remove_btn.defaultText, - ); - remove_btn.RESTMethod = isUnusedDisk || (isDisk && isRunning) ? 'POST' : 'PUT'; - - edit_btn.setDisabled( - deleted || - !row.editor || - isCloudInit || - (isCDRom && !cdromCap) || - (isDisk && !diskCap), - ); - - diskaction_btn.setDisabled( - pending || !diskCap || isCloudInit || !(isDisk || isEfi || tpmMoveable), - ); - reassign_menuitem.setDisabled(pending || isEfi || tpmMoveable); - resize_menuitem.setDisabled(pending || !isUsedDisk); - - revert_btn.setDisabled(!pending); - }; - - let editorFactory = (classPath, extraOptions) => { - extraOptions = extraOptions || {}; - return () => - Ext.create(`PVE.qemu.${classPath}`, { - autoShow: true, - url: `/api2/extjs/${baseurl}`, - pveSelNode: me.pveSelNode, - listeners: { - destroy: () => me.reload(), - }, - isAdd: true, - isCreate: true, - ...extraOptions, - }); - }; - - Ext.apply(me, { - url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`, - interval: 5000, - selModel: sm, - run_editor: run_editor, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - cls: 'pve-add-hw-menu', - items: [ - { - text: gettext('Hard Disk'), - iconCls: 'fa fa-fw fa-hdd-o black', - disabled: !caps.vms['VM.Config.Disk'], - handler: editorFactory('HDEdit'), - }, - { - text: gettext('Import Hard Disk'), - iconCls: 'fa fa-fw fa-cloud-download', - disabled: !caps.vms['VM.Config.Disk'], - handler: editorFactory('HDEdit', { importDisk: true }), - }, - { - text: gettext('CD/DVD Drive'), - iconCls: 'pve-itype-icon-cdrom', - disabled: !caps.vms['VM.Config.CDROM'], - handler: editorFactory('CDEdit'), - }, - { - text: gettext('Network Device'), - itemId: 'addNet', - iconCls: 'fa fa-fw fa-exchange black', - disabled: !caps.vms['VM.Config.Network'], - handler: editorFactory('NetworkEdit'), - }, - efidisk_menuitem, - { - text: gettext('TPM State'), - itemId: 'addTpmState', - iconCls: 'fa fa-fw fa-hdd-o black', - disabled: !caps.vms['VM.Config.Disk'], - handler: editorFactory('TPMDiskEdit'), - }, - { - text: gettext('USB Device'), - itemId: 'addUsb', - iconCls: 'fa fa-fw fa-usb black', - disabled: - !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], - handler: editorFactory('USBEdit'), - }, - { - text: gettext('PCI Device'), - itemId: 'addPci', - iconCls: 'pve-itype-icon-pci', - disabled: - !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], - handler: editorFactory('PCIEdit'), - }, - { - text: gettext('Serial Port'), - itemId: 'addSerial', - iconCls: 'pve-itype-icon-serial', - disabled: !caps.vms['VM.Config.Options'], - handler: editorFactory('SerialEdit'), - }, - { - text: gettext('CloudInit Drive'), - itemId: 'addCloudinitDrive', - iconCls: 'fa fa-fw fa-cloud black', - disabled: - !caps.vms['VM.Config.CDROM'] || - !caps.vms['VM.Config.Cloudinit'], - handler: editorFactory('CIDriveEdit'), - }, - { - text: gettext('Audio Device'), - itemId: 'addAudio', - iconCls: 'fa fa-fw fa-volume-up black', - disabled: !caps.vms['VM.Config.HWType'], - handler: editorFactory('AudioEdit'), - }, - { - text: gettext('VirtIO RNG'), - itemId: 'addRng', - iconCls: 'pve-itype-icon-die', - disabled: - !caps.vms['VM.Config.HWType'] && !caps.mapping['Mapping.Use'], - handler: editorFactory('RNGEdit'), - }, - { - text: gettext('Virtiofs'), - itemId: 'addVirtiofs', - iconCls: 'fa fa-folder', - disabled: !caps.nodes['Sys.Console'], - handler: editorFactory('VirtiofsEdit'), - }, - ], - }), - }, - remove_btn, - edit_btn, - diskaction_btn, - revert_btn, - ], - rows: rows, - sorterFn: sorterFn, - listeners: { - itemdblclick: run_editor, - selectionchange: set_button_status, - }, - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate, me.rstore); - me.on('destroy', me.rstore.stopUpdate, me.rstore); - - me.mon(me.getStore(), 'datachanged', set_button_status, me); - }, -}); -Ext.define('PVE.qemu.IPConfigPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveIPConfigPanel', - - insideWizard: false, - - vmconfig: {}, - - onGetValues: function (values) { - var me = this; - - if (values.ipv4mode !== 'static') { - values.ip = values.ipv4mode; - } - - if (values.ipv6mode !== 'static') { - values.ip6 = values.ipv6mode; - } - - var params = {}; - - var cfg = PVE.Parser.printIPConfig(values); - if (cfg === '') { - params.delete = [me.confid]; - } else { - params[me.confid] = cfg; - } - return params; - }, - - setVMConfig: function (config) { - var me = this; - me.vmconfig = config; - }, - - setIPConfig: function (confid, data) { - var me = this; - - me.confid = confid; - - if (data.ip === 'dhcp') { - data.ipv4mode = data.ip; - data.ip = ''; - } else { - data.ipv4mode = 'static'; - } - if (data.ip6 === 'dhcp' || data.ip6 === 'auto') { - data.ipv6mode = data.ip6; - data.ip6 = ''; - } else { - data.ipv6mode = 'static'; - } - - me.ipconfig = data; - me.setValues(me.ipconfig); - }, - - initComponent: function () { - var me = this; - - me.ipconfig = {}; - - me.column1 = [ - { - xtype: 'displayfield', - fieldLabel: gettext('Network Device'), - value: me.netid, - }, - { - layout: { - type: 'hbox', - align: 'middle', - }, - border: false, - margin: '0 0 5 0', - items: [ - { - xtype: 'label', - text: gettext('IPv4') + ':', - }, - { - xtype: 'radiofield', - boxLabel: gettext('Static'), - name: 'ipv4mode', - inputValue: 'static', - checked: false, - margin: '0 0 0 10', - listeners: { - change: function (cb, value) { - me.down('field[name=ip]').setDisabled(!value); - me.down('field[name=gw]').setDisabled(!value); - }, - }, - }, - { - xtype: 'radiofield', - boxLabel: gettext('DHCP'), - name: 'ipv4mode', - inputValue: 'dhcp', - checked: false, - margin: '0 0 0 10', - }, - ], - }, - { - xtype: 'textfield', - name: 'ip', - vtype: 'IPCIDRAddress', - value: '', - disabled: true, - fieldLabel: gettext('IPv4/CIDR'), - }, - { - xtype: 'textfield', - name: 'gw', - value: '', - vtype: 'IPAddress', - disabled: true, - fieldLabel: gettext('Gateway') + ' (' + gettext('IPv4') + ')', - }, - ]; - - me.column2 = [ - { - xtype: 'displayfield', - }, - { - layout: { - type: 'hbox', - align: 'middle', - }, - border: false, - margin: '0 0 5 0', - items: [ - { - xtype: 'label', - text: gettext('IPv6') + ':', - }, - { - xtype: 'radiofield', - boxLabel: gettext('Static'), - name: 'ipv6mode', - inputValue: 'static', - checked: false, - margin: '0 0 0 10', - listeners: { - change: function (cb, value) { - me.down('field[name=ip6]').setDisabled(!value); - me.down('field[name=gw6]').setDisabled(!value); - }, - }, - }, - { - xtype: 'radiofield', - boxLabel: gettext('DHCP'), - name: 'ipv6mode', - inputValue: 'dhcp', - checked: false, - margin: '0 0 0 10', - }, - { - xtype: 'radiofield', - boxLabel: gettext('SLAAC'), - name: 'ipv6mode', - inputValue: 'auto', - checked: false, - margin: '0 0 0 10', - }, - ], - }, - { - xtype: 'textfield', - name: 'ip6', - value: '', - vtype: 'IP6CIDRAddress', - disabled: true, - fieldLabel: gettext('IPv6/CIDR'), - }, - { - xtype: 'textfield', - name: 'gw6', - vtype: 'IP6Address', - value: '', - disabled: true, - fieldLabel: gettext('Gateway') + ' (' + gettext('IPv6') + ')', - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.IPConfigEdit', { - extend: 'Proxmox.window.Edit', - - isAdd: true, - - initComponent: function () { - var me = this; - - // convert confid from netX to ipconfigX - var match = me.confid.match(/^net(\d+)$/); - if (match) { - me.netid = me.confid; - me.confid = 'ipconfig' + match[1]; - } - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.isCreate = !me.confid; - - var ipanel = Ext.create('PVE.qemu.IPConfigPanel', { - confid: me.confid, - netid: me.netid, - nodename: nodename, - }); - - Ext.applyIf(me, { - subject: gettext('Network Config'), - items: ipanel, - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - me.vmconfig = response.result.data; - var ipconfig = {}; - var value = me.vmconfig[me.confid]; - if (value) { - ipconfig = PVE.Parser.parseIPConfig(me.confid, value); - if (!ipconfig) { - Ext.Msg.alert( - gettext('Error'), - gettext('Unable to parse network configuration'), - ); - me.close(); - return; - } - } - ipanel.setIPConfig(me.confid, ipconfig); - ipanel.setVMConfig(me.vmconfig); - }, - }); - }, -}); -Ext.define('PVE.qemu.KeyboardEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - Ext.applyIf(me, { - subject: gettext('Keyboard Layout'), - items: { - xtype: 'VNCKeyboardSelector', - name: 'keyboard', - value: '__default__', - fieldLabel: gettext('Keyboard Layout'), - }, - }); - - me.callParent(); - - me.load(); - }, -}); -Ext.define('PVE.qemu.MachineInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveMachineInputPanel', - onlineHelp: 'qm_machine_type', - - viewModel: { - data: { - type: '__default__', - }, - formulas: { - q35: (get) => get('type') === 'q35', - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - 'combobox[name=machine]': { - change: 'onMachineChange', - }, - }, - onMachineChange: function (field, value) { - let me = this; - let version = me.lookup('version'); - let store = version.getStore(); - let oldRec = store.findRecord('id', version.getValue(), 0, false, false, true); - let type = value === 'q35' ? 'q35' : 'i440fx'; - store.clearFilter(); - store.addFilter((val) => val.data.id === 'latest' || val.data.type === type); - if (!me.getView().isWindows) { - version.setValue('latest'); - } else { - store.isWindows = true; - if (!oldRec) { - return; - } - let oldVers = oldRec.data.version; - // we already filtered by correct type, so just check version property - let rec = store.findRecord('version', oldVers, 0, false, false, true); - if (rec) { - version.select(rec); - } - } - }, - }, - - onGetValues: function (values) { - if (values.delete === 'machine' && values.viommu) { - delete values.delete; - values.machine = 'pc'; - } - if (values.version && values.version !== 'latest') { - values.machine = values.version; - delete values.delete; - } - delete values.version; - if (values.delete === 'machine' && !values.viommu) { - return values; - } - let ret = {}; - ret.machine = PVE.Parser.printPropertyString(values, 'machine'); - return ret; - }, - - setValues: function (values) { - let me = this; - - let machineConf = PVE.Parser.parsePropertyString(values.machine, 'type'); - values.machine = machineConf.type; - - me.isWindows = values.isWindows; - if (values.machine === 'pc') { - values.machine = '__default__'; - } - - if (me.isWindows) { - if (values.machine === '__default__') { - values.version = 'pc-i440fx-5.1'; - } else if (values.machine === 'q35') { - values.version = 'pc-q35-5.1'; - } - } - - values.viommu = machineConf.viommu || '__default__'; - - if (values.machine !== '__default__' && values.machine !== 'q35') { - values.version = values.machine; - values.machine = values.version.match(/q35/) ? 'q35' : '__default__'; - - // avoid hiding a pinned version - me.setAdvancedVisible(true); - } - - this.callParent(arguments); - }, - - items: { - xtype: 'proxmoxKVComboBox', - name: 'machine', - reference: 'machine', - fieldLabel: gettext('Machine'), - comboItems: [ - ['__default__', PVE.Utils.render_qemu_machine('')], - ['q35', 'q35'], - ], - bind: { - value: '{type}', - }, - }, - - advancedItems: [ - { - xtype: 'combobox', - name: 'version', - reference: 'version', - fieldLabel: gettext('Version'), - emptyText: gettext('Latest'), - value: 'latest', - editable: false, - valueField: 'id', - displayField: 'version', - queryParam: false, - store: { - autoLoad: true, - fields: ['id', 'type', 'version'], - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/localhost/capabilities/qemu/machines', - }, - listeners: { - load: function (records) { - if (!this.isWindows) { - this.insert(0, { - id: 'latest', - type: 'any', - version: gettext('Latest'), - }); - } - }, - }, - }, - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Note'), - value: gettext( - 'Machine version change may affect hardware layout and settings in the guest OS.', - ), - }, - { - xtype: 'proxmoxKVComboBox', - name: 'viommu', - fieldLabel: gettext('vIOMMU'), - reference: 'viommu-q35', - deleteEmpty: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (None)'], - ['intel', gettext('Intel (AMD Compatible)')], - ['virtio', 'VirtIO'], - ], - bind: { - hidden: '{!q35}', - disabled: '{!q35}', - }, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'viommu', - fieldLabel: gettext('vIOMMU'), - reference: 'viommu-i440fx', - deleteEmpty: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (None)'], - ['virtio', 'VirtIO'], - ], - bind: { - hidden: '{q35}', - disabled: '{q35}', - }, - }, - ], -}); - -Ext.define('PVE.qemu.MachineEdit', { - extend: 'Proxmox.window.Edit', - - subject: gettext('Machine'), - - items: { - xtype: 'pveMachineInputPanel', - }, - - width: 400, - - initComponent: function () { - let me = this; - - me.callParent(); - - me.load({ - success: function (response) { - let conf = response.result.data; - let values = { - machine: conf.machine || '__default__', - }; - values.isWindows = PVE.Utils.is_windows(conf.ostype); - me.setValues(values); - }, - }); - }, -}); -Ext.define('PVE.qemu.MemoryInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveQemuMemoryPanel', - onlineHelp: 'qm_memory', - - insideWizard: false, - - viewModel: {}, // inherit data from createWizard if insideWizard - - controller: { - xclass: 'Ext.app.ViewController', - - control: { - '#': { - afterrender: 'setMemory', - }, - }, - - setMemory: function () { - let me = this; - let view = me.getView(), - viewModel = me.getViewModel(); - if (view.insideWizard) { - let memory = view.down('pveMemoryField[name=memory]'); - // NOTE: we only set memory but that then sets balloon in its change handler - if (viewModel.get('current.ostype') === 'win11') { - memory.setValue('4096'); - } else { - memory.setValue('2048'); - } - } - }, - }, - - onGetValues: function (values) { - let res = {}; - - let deleteSet = new Set([]); - - // properties that can be passed as-is - let propagate = ['allow-ksm', 'memory']; - - propagate.forEach(function (prop) { - if (values.delete?.split(',').includes(prop)) { - deleteSet.add(prop); - } - if (prop in values) { - res[prop] = values[prop]; - } - }); - - res.balloon = values.balloon; - - if (!values.ballooning) { - res.balloon = 0; - deleteSet.add('shares'); - } else if (values.memory === values.balloon) { - delete res.balloon; - deleteSet.add('balloon'); - deleteSet.add('shares'); - } else if (Ext.isDefined(values.shares) && values.shares !== '') { - res.shares = values.shares; - } else { - deleteSet.add('shares'); - } - - if (deleteSet.size > 0) { - res.delete = deleteSet.keys().toArray().join(','); - } - - return res; - }, - - initComponent: function () { - var me = this; - var labelWidth = 160; - - me.items = [ - { - xtype: 'pveMemoryField', - labelWidth: labelWidth, - fieldLabel: gettext('Memory') + ' (MiB)', - name: 'memory', - value: '512', // better defaults get set via the view controllers afterrender - minValue: 1, - step: 32, - hotplug: me.hotplug, - listeners: { - change: function (f, value, old) { - var bf = me.down('field[name=balloon]'); - var balloon = bf.getValue(); - bf.setMaxValue(value); - if (balloon === old) { - bf.setValue(value); - } - bf.validate(); - }, - }, - }, - ]; - - me.advancedItems = [ - { - xtype: 'pveMemoryField', - name: 'balloon', - minValue: 1, - maxValue: me.insideWizard ? 2048 : 512, - value: '512', // better defaults get set (indirectly) via the view controllers afterrender - step: 32, - fieldLabel: gettext('Minimum memory') + ' (MiB)', - hotplug: me.hotplug, - labelWidth: labelWidth, - allowBlank: false, - listeners: { - change: function (f, value) { - var memory = me.down('field[name=memory]').getValue(); - var shares = me.down('field[name=shares]'); - shares.setDisabled(value === memory); - }, - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'shares', - disabled: true, - minValue: 0, - maxValue: 50000, - value: '', - step: 10, - fieldLabel: gettext('Shares'), - labelWidth: labelWidth, - allowBlank: true, - emptyText: Proxmox.Utils.defaultText + ' (1000)', - submitEmptyText: false, - }, - { - xtype: 'proxmoxcheckbox', - labelWidth: labelWidth, - value: '1', - name: 'ballooning', - fieldLabel: gettext('Ballooning Device'), - listeners: { - change: function (f, value) { - var bf = me.down('field[name=balloon]'); - var shares = me.down('field[name=shares]'); - var memory = me.down('field[name=memory]'); - bf.setDisabled(!value); - shares.setDisabled(!value || bf.getValue() === memory.getValue()); - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'allow-ksm', - labelWidth: labelWidth, - fieldLabel: gettext('Allow KSM'), - checked: true, - uncheckedValue: '0', - defaultValue: '1', - deleteDefaultValue: true, - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Allow the Kernel Samepage Merging daemon to merge memory pages of this VM.', - ), - }, - }, - ]; - - if (me.insideWizard) { - me.column1 = me.items; - me.items = undefined; - me.advancedColumn1 = me.advancedItems; - me.advancedItems = undefined; - } - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.MemoryEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - var memoryhotplug; - if (me.hotplug) { - Ext.each(me.hotplug.split(','), function (el) { - if (el === 'memory') { - memoryhotplug = 1; - } - }); - } - - var ipanel = Ext.create('PVE.qemu.MemoryInputPanel', { - hotplug: memoryhotplug, - }); - - Ext.apply(me, { - subject: gettext('Memory'), - items: [ipanel], - // uncomment the following to use the async configiguration API - // backgroundDelay: 5, - width: 400, - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - var data = response.result.data; - - var values = { - ballooning: data.balloon === 0 ? '0' : '1', - shares: data.shares, - memory: data.memory || '512', - balloon: data.balloon > 0 ? data.balloon : data.memory || '512', - 'allow-ksm': data['allow-ksm'] ?? true, - }; - - ipanel.setValues(values); - }, - }); - }, -}); -Ext.define('PVE.qemu.Monitor', { - extend: 'Ext.panel.Panel', - - alias: 'widget.pveQemuMonitor', - - // start to trim saved command output once there are *both*, more than `commandLimit` commands - // executed and the total of saved in+output is over `lineLimit` lines; repeat by dropping one - // full command output until either condition is false again - commandLimit: 10, - lineLimit: 5000, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var history = []; - var histNum = -1; - let commands = []; - - var textbox = Ext.createWidget('panel', { - region: 'center', - xtype: 'panel', - autoScroll: true, - border: true, - margins: '5 5 5 5', - bodyStyle: 'font-family: monospace;', - }); - - var scrollToEnd = function () { - var el = textbox.getTargetEl(); - var dom = Ext.getDom(el); - - var clientHeight = dom.clientHeight; - // BrowserBug: clientHeight reports 0 in IE9 StrictMode - // Instead we are using offsetHeight and hardcoding borders - if (Ext.isIE9 && Ext.isStrict) { - clientHeight = dom.offsetHeight + 2; - } - dom.scrollTop = dom.scrollHeight - clientHeight; - }; - - var refresh = function () { - textbox.update(`
    ${commands.flat(2).join('\n')}
    `); - scrollToEnd(); - }; - - let recordInput = (line) => { - commands.push([line]); - - // drop oldest commands and their output until we're not over both limits anymore - while (commands.length > me.commandLimit && commands.flat(2).length > me.lineLimit) { - commands.shift(); - } - }; - - let addResponse = (lines) => commands[commands.length - 1].push(lines); - - var executeCmd = function (cmd) { - recordInput('# ' + Ext.htmlEncode(cmd), true); - if (cmd) { - history.unshift(cmd); - if (history.length > 20) { - history.splice(20); - } - } - histNum = -1; - - refresh(); - Proxmox.Utils.API2Request({ - params: { command: cmd }, - url: '/nodes/' + nodename + '/qemu/' + vmid + '/monitor', - method: 'POST', - waitMsgTarget: me, - success: function (response, opts) { - var res = response.result.data; - addResponse(res.split('\n').map((line) => Ext.htmlEncode(line))); - refresh(); - }, - failure: function (response, opts) { - Ext.Msg.alert('Error', response.htmlStatus); - }, - }); - }; - - Ext.apply(me, { - layout: { type: 'border' }, - border: false, - items: [ - textbox, - { - region: 'south', - margins: '0 5 5 5', - border: false, - xtype: 'textfield', - name: 'cmd', - value: '', - fieldStyle: 'font-family: monospace;', - allowBlank: true, - listeners: { - afterrender: function (f) { - f.focus(false); - recordInput("Type 'help' for help."); - refresh(); - }, - specialkey: function (f, e) { - var key = e.getKey(); - switch (key) { - case e.ENTER: { - let cmd = f.getValue(); - f.setValue(''); - executeCmd(cmd); - break; - } - case e.PAGE_UP: - textbox.scrollBy(0, -0.9 * textbox.getHeight(), false); - break; - case e.PAGE_DOWN: - textbox.scrollBy(0, 0.9 * textbox.getHeight(), false); - break; - case e.UP: - if (histNum + 1 < history.length) { - f.setValue(history[++histNum]); - } - e.preventDefault(); - break; - case e.DOWN: - if (histNum > 0) { - f.setValue(history[--histNum]); - } - e.preventDefault(); - break; - default: - break; - } - }, - }, - }, - ], - listeners: { - show: function () { - var field = me.query('textfield[name="cmd"]')[0]; - field.focus(false, true); - }, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.qemu.MultiHDPanel', { - extend: 'PVE.panel.MultiDiskPanel', - alias: 'widget.pveMultiHDPanel', - - onlineHelp: 'qm_hard_disk', - - importDisk: true, - - controller: { - xclass: 'Ext.app.ViewController', - - // maxCount is the sum of all controller ids - 1 (ide2 is fixed in the wizard) - maxCount: - Object.values(PVE.Utils.diskControllerMaxIDs).reduce( - (previous, current) => previous + current, - 0, - ) - 1, - - getNextFreeDisk: function (vmconfig) { - let clist = PVE.Utils.sortByPreviousUsage(vmconfig); - return PVE.Utils.nextFreeDisk(clist, vmconfig); - }, - - addPanel: function (itemId, vmconfig, nextFreeDisk, importDisk) { - let me = this; - return me.getView().add({ - vmconfig, - border: false, - showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'), - xtype: 'pveQemuHDInputPanel', - bind: { - nodename: '{nodename}', - }, - padding: '0 0 0 5', - itemId, - isCreate: true, - insideWizard: true, - importDisk, - }); - }, - - getBaseVMConfig: function () { - let me = this; - let vm = me.getViewModel(); - - let res = { - ide2: 'media=cdrom', - scsihw: vm.get('current.scsihw'), - ostype: vm.get('current.ostype'), - }; - - if (vm.get('current.ide0') === 'some') { - res.ide0 = 'media=cdrom'; - } - - return res; - }, - - diskSorter: { - sorterFn: function (rec1, rec2) { - let [, name1, id1] = PVE.Utils.bus_match.exec(rec1.data.name); - let [, name2, id2] = PVE.Utils.bus_match.exec(rec2.data.name); - - if (name1 === name2) { - return parseInt(id1, 10) - parseInt(id2, 10); - } - - return name1 < name2 ? -1 : 1; - }, - }, - - deleteDisabled: () => false, - }, -}); -Ext.define('PVE.qemu.NetworkInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveQemuNetworkInputPanel', - onlineHelp: 'qm_network_device', - - insideWizard: false, - - onGetValues: function (values) { - var me = this; - - me.network.model = values.model; - if (values.nonetwork) { - return {}; - } else { - me.network.bridge = values.bridge; - me.network.tag = values.tag; - me.network.firewall = values.firewall; - } - me.network.macaddr = values.macaddr; - me.network.disconnect = values.disconnect; - me.network.queues = values.queues; - me.network.mtu = values.mtu; - - if (values.rate) { - me.network.rate = values.rate; - } else { - delete me.network.rate; - } - - var params = {}; - - params[me.confid] = PVE.Parser.printQemuNetwork(me.network); - - return params; - }, - - viewModel: { - data: { - networkModel: undefined, - mtu: '', - }, - formulas: { - isVirtio: (get) => get('networkModel') === 'virtio', - showMtuHint: (get) => get('mtu') === 1, - }, - }, - - setNetwork: function (confid, data) { - var me = this; - - me.confid = confid; - - if (data) { - data.networkmode = data.bridge ? 'bridge' : 'nat'; - } else { - data = {}; - data.networkmode = 'bridge'; - } - me.network = data; - - me.setValues(me.network); - }, - - setNodename: function (nodename) { - var me = this; - - me.bridgesel.setNodename(nodename); - }, - - initComponent: function () { - var me = this; - - me.network = {}; - me.confid = 'net0'; - - me.column1 = []; - me.column2 = []; - - me.bridgesel = Ext.create('PVE.form.BridgeSelector', { - name: 'bridge', - fieldLabel: gettext('Bridge'), - nodename: me.nodename, - autoSelect: true, - allowBlank: false, - }); - - me.column1 = [ - me.bridgesel, - { - xtype: 'pveVlanField', - name: 'tag', - value: '', - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Firewall'), - name: 'firewall', - checked: me.insideWizard || me.isCreate, - }, - ]; - - me.advancedColumn1 = [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Disconnect'), - name: 'disconnect', - }, - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - fieldLabel: 'MTU', - bind: { - disabled: '{!isVirtio}', - value: '{mtu}', - }, - emptyText: gettext('Same as bridge'), - minValue: 1, - maxValue: 65520, - allowBlank: true, - validator: (val) => - val === '' || val >= 576 || val === '1' - ? true - : gettext( - 'MTU needs to be >= 576 or 1 to inherit the MTU from the underlying bridge.', - ), - }, - ]; - - if (me.insideWizard) { - me.column1.unshift({ - xtype: 'checkbox', - name: 'nonetwork', - inputValue: 'none', - boxLabel: gettext('No network device'), - listeners: { - change: function (cb, value) { - var fields = [ - 'disconnect', - 'bridge', - 'tag', - 'firewall', - 'model', - 'macaddr', - 'rate', - 'queues', - 'mtu', - ]; - fields.forEach(function (fieldname) { - me.down('field[name=' + fieldname + ']').setDisabled(value); - }); - me.down('field[name=bridge]').validate(); - }, - }, - }); - me.column2.unshift({ - xtype: 'displayfield', - }); - } - - me.column2.push( - { - xtype: 'pveNetworkCardSelector', - name: 'model', - fieldLabel: gettext('Model'), - bind: '{networkModel}', - value: PVE.qemu.OSDefaults.generic.networkCard, - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'macaddr', - fieldLabel: gettext('MAC address'), - vtype: 'MacAddress', - allowBlank: true, - emptyText: 'auto', - }, - ); - me.advancedColumn2 = [ - { - xtype: 'numberfield', - name: 'rate', - fieldLabel: gettext('Rate limit') + ' (MB/s)', - minValue: 0, - maxValue: 10 * 1024, - value: '', - emptyText: 'unlimited', - allowBlank: true, - }, - { - xtype: 'proxmoxintegerfield', - name: 'queues', - fieldLabel: 'Multiqueue', - minValue: 1, - maxValue: 64, - value: '', - allowBlank: true, - }, - ]; - me.advancedColumnB = [ - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - "Use the special value '1' to inherit the MTU value from the underlying bridge", - ), - bind: { - hidden: '{!showMtuHint}', - }, - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.NetworkEdit', { - extend: 'Proxmox.window.Edit', - - isAdd: true, - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - me.isCreate = !me.confid; - - var ipanel = Ext.create('PVE.qemu.NetworkInputPanel', { - confid: me.confid, - nodename: nodename, - isCreate: me.isCreate, - }); - - Ext.applyIf(me, { - subject: gettext('Network Device'), - items: ipanel, - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - var i, confid; - me.vmconfig = response.result.data; - if (!me.isCreate) { - let value = me.vmconfig[me.confid]; - let network = PVE.Parser.parseQemuNetwork(me.confid, value); - if (!network) { - Ext.Msg.alert(gettext('Error'), 'Unable to parse network options'); - me.close(); - return; - } - ipanel.setNetwork(me.confid, network); - } else { - for (i = 0; i < 100; i++) { - confid = 'net' + i.toString(); - if (!Ext.isDefined(me.vmconfig[confid])) { - me.confid = confid; - break; - } - } - - let ostype = me.vmconfig.ostype; - let defaults = PVE.qemu.OSDefaults.getDefaults(ostype); - let data = { - model: defaults.networkCard, - }; - - ipanel.setNetwork(me.confid, data); - } - }, - }); - }, -}); -/* - * This class holds performance *recommended* settings for the PVE Qemu wizards - * the *mandatory* settings are set in the PVE::QemuServer - * config_to_command sub - * We store this here until we get the data from the API server - */ - -// this is how you would add an hypothetic FreeBSD > 10 entry -// -//virtio-blk is stable but virtIO net still -// problematic as of 10.3 -// see https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=165059 -// addOS({ -// parent: 'generic', // inherits defaults -// pveOS: 'freebsd10', // must match a radiofield in OSTypeEdit.js -// busType: 'virtio' // must match a pveBusController value -// // networkCard muss match a pveNetworkCardSelector - -Ext.define('PVE.qemu.OSDefaults', { - singleton: true, // will also force creation when loaded - - constructor: function () { - let me = this; - - let addOS = function (settings) { - if (Object.hasOwn(settings, 'parent')) { - let child = Ext.clone(me[settings.parent]); - me[settings.pveOS] = Ext.apply(child, settings); - } else { - throw 'Could not find your genitor'; - } - }; - - // default values - me.generic = { - busType: 'ide', - networkCard: 'e1000', - busPriority: { - ide: 4, - sata: 3, - scsi: 2, - virtio: 1, - }, - scsihw: 'virtio-scsi-single', - cputype: 'x86-64-v2-AES', - }; - - // virtio-net is in kernel since 2.6.25 - // virtio-scsi since 3.2 but backported in RHEL with 2.6 kernel - addOS({ - pveOS: 'l26', - parent: 'generic', - busType: 'scsi', - busPriority: { - scsi: 4, - virtio: 3, - sata: 2, - ide: 1, - }, - networkCard: 'virtio', - }); - - // recommendation from http://wiki.qemu.org/Windows2000 - addOS({ - pveOS: 'w2k', - parent: 'generic', - networkCard: 'rtl8139', - scsihw: '', - }); - // https://pve.proxmox.com/wiki/Windows_XP_Guest_Notes - addOS({ - pveOS: 'wxp', - parent: 'w2k', - }); - - me.getDefaults = function (ostype) { - if (PVE.qemu.OSDefaults[ostype]) { - return PVE.qemu.OSDefaults[ostype]; - } else { - return PVE.qemu.OSDefaults.generic; - } - }; - }, -}); -Ext.define('PVE.qemu.OSTypeInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveQemuOSTypePanel', - onlineHelp: 'qm_os_settings', - insideWizard: false, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - 'combobox[name=osbase]': { - change: 'onOSBaseChange', - }, - 'combobox[name=ostype]': { - afterrender: 'onOSTypeChange', - change: 'onOSTypeChange', - }, - 'checkbox[reference=enableSecondCD]': { - change: 'onSecondCDChange', - }, - }, - onOSBaseChange: function (field, value) { - let me = this; - me.lookup('ostype').getStore().setData(PVE.Utils.kvm_ostypes[value]); - if (me.getView().insideWizard) { - let isWindows = value === 'Microsoft Windows'; - let enableSecondCD = me.lookup('enableSecondCD'); - enableSecondCD.setVisible(isWindows); - if (!isWindows) { - enableSecondCD.setValue(false); - } - } - }, - onOSTypeChange: function (field) { - var me = this, - ostype = field.getValue(); - if (!me.getView().insideWizard) { - return; - } - var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype); - - me.setWidget('pveBusSelector', targetValues.busType); - me.setWidget('pveNetworkCardSelector', targetValues.networkCard); - me.setWidget('CPUModelSelector', targetValues.cputype); - var scsihw = targetValues.scsihw || '__default__'; - this.getViewModel().set('current.scsihw', scsihw); - this.getViewModel().set('current.ostype', ostype); - }, - setWidget: function (widget, newValue) { - // changing a widget is safe only if ComponentQuery.query returns us - // a single value array - var widgets = Ext.ComponentQuery.query('pveQemuCreateWizard ' + widget); - if (widgets.length === 1) { - widgets[0].setValue(newValue); - } else { - // ignore multiple disks, we only want to set the type if there is a single disk - } - }, - onSecondCDChange: function (widget, value, lastValue) { - let me = this; - let vm = me.getViewModel(); - let updateVMConfig = function () { - let widgets = Ext.ComponentQuery.query('pveMultiHDPanel'); - if (widgets.length === 1) { - widgets[0].getController().updateVMConfig(); - } - }; - if (value) { - // only for windows - vm.set('current.ide0', 'some'); - vm.notify(); - updateVMConfig(); - me.setWidget('pveBusSelector', 'scsi'); - me.setWidget('pveNetworkCardSelector', 'virtio'); - } else { - vm.set('current.ide0', ''); - vm.notify(); - updateVMConfig(); - me.setWidget('pveBusSelector', 'scsi'); - let ostype = me.lookup('ostype').getValue(); - let targetValues = PVE.qemu.OSDefaults.getDefaults(ostype); - me.setWidget('pveBusSelector', targetValues.busType); - } - }, - }, - - setNodename: function (nodename) { - var me = this; - me.lookup('isoSelector').setNodename(nodename); - }, - - onGetValues: function (values) { - if (values.ide0) { - let drive = { - media: 'cdrom', - file: values.ide0, - }; - values.ide0 = PVE.Parser.printQemuDrive(drive); - } - return values; - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'displayfield', - value: gettext('Guest OS') + ':', - hidden: !me.insideWizard, - }, - { - xtype: 'combobox', - submitValue: false, - name: 'osbase', - fieldLabel: gettext('Type'), - editable: false, - queryMode: 'local', - value: 'Linux', - store: Object.keys(PVE.Utils.kvm_ostypes), - }, - { - xtype: 'combobox', - name: 'ostype', - reference: 'ostype', - fieldLabel: gettext('Version'), - value: 'l26', - allowBlank: false, - editable: false, - queryMode: 'local', - valueField: 'val', - displayField: 'desc', - store: { - fields: ['desc', 'val'], - data: PVE.Utils.kvm_ostypes.Linux, - listeners: { - datachanged: function (store) { - var ostype = me.lookup('ostype'); - var old_val = ostype.getValue(); - if (!me.insideWizard && old_val && store.find('val', old_val) !== -1) { - ostype.setValue(old_val); - } else { - ostype.setValue(store.getAt(0)); - } - }, - }, - }, - }, - ]; - - if (me.insideWizard) { - me.items.push( - { - xtype: 'proxmoxcheckbox', - reference: 'enableSecondCD', - isFormField: false, - hidden: true, - checked: false, - boxLabel: gettext('Add additional drive for VirtIO drivers'), - listeners: { - change: function (cb, value) { - me.lookup('isoSelector').setDisabled(!value); - me.lookup('isoSelector').setHidden(!value); - }, - }, - }, - { - xtype: 'pveIsoSelector', - reference: 'isoSelector', - name: 'ide0', - nodename: me.nodename, - insideWizard: true, - hidden: true, - disabled: true, - }, - ); - } - - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.OSTypeEdit', { - extend: 'Proxmox.window.Edit', - - subject: 'OS Type', - - items: [{ xtype: 'pveQemuOSTypePanel' }], - - initComponent: function () { - var me = this; - - me.callParent(); - - me.load({ - success: function (response, options) { - var value = response.result.data.ostype || 'other'; - var osinfo = PVE.Utils.get_kvm_osinfo(value); - me.setValues({ ostype: value, osbase: osinfo.base }); - }, - }); - }, -}); -Ext.define('PVE.qemu.Options', { - extend: 'Proxmox.grid.PendingObjectGrid', - alias: ['widget.PVE.qemu.Options'], - - onlineHelp: 'qm_options', - - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var vmid = me.pveSelNode.data.vmid; - if (!vmid) { - throw 'no VM ID specified'; - } - - var caps = Ext.state.Manager.get('GuiCap'); - - var rows = { - name: { - required: true, - defaultValue: me.pveSelNode.data.name, - header: gettext('Name'), - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Name'), - items: { - xtype: 'inputpanel', - items: { - xtype: 'textfield', - name: 'name', - vtype: 'DnsName', - value: '', - fieldLabel: gettext('Name'), - allowBlank: true, - }, - onGetValues: function (values) { - var params = values; - if ( - values.name === undefined || - values.name === null || - values.name === '' - ) { - params = { delete: 'name' }; - } - return params; - }, - }, - } - : undefined, - }, - onboot: { - header: gettext('Start at boot'), - defaultValue: '', - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Start at boot'), - items: { - xtype: 'proxmoxcheckbox', - name: 'onboot', - uncheckedValue: 0, - defaultValue: 0, - deleteDefaultValue: true, - fieldLabel: gettext('Start at boot'), - }, - } - : undefined, - }, - startup: { - header: gettext('Start/Shutdown order'), - defaultValue: '', - renderer: PVE.Utils.render_kvm_startup, - editor: - caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify'] - ? { - xtype: 'pveWindowStartupEdit', - onlineHelp: 'qm_startup_and_shutdown', - } - : undefined, - }, - ostype: { - header: gettext('OS Type'), - editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.OSTypeEdit' : undefined, - renderer: PVE.Utils.render_kvm_ostype, - defaultValue: 'other', - }, - bootdisk: { - visible: false, - }, - boot: { - header: gettext('Boot Order'), - defaultValue: 'cdn', - editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.BootOrderEdit' : undefined, - multiKey: ['boot', 'bootdisk'], - renderer: function (order, metaData, record, rowIndex, colIndex, store, pending) { - if (/^\s*$/.test(order)) { - return gettext('(No boot device selected)'); - } - let boot = PVE.Parser.parsePropertyString(order, 'legacy'); - if (boot.order) { - let list = boot.order.split(';'); - let ret = ''; - list.forEach((dev) => { - if (ret) { - ret += ', '; - } - ret += dev; - }); - return ret; - } - - // legacy style and fallback - let i; - var text = ''; - var bootdisk = me.getObjectValue('bootdisk', undefined, pending); - order = boot.legacy || 'cdn'; - for (i = 0; i < order.length; i++) { - if (text) { - text += ', '; - } - let sel = order.substring(i, i + 1); - if (sel === 'c') { - if (bootdisk) { - text += bootdisk; - } else { - text += gettext('first disk'); - } - } else if (sel === 'n') { - text += gettext('any net'); - } else if (sel === 'a') { - text += gettext('Floppy'); - } else if (sel === 'd') { - text += gettext('any CD-ROM'); - } else { - text += sel; - } - } - return text; - }, - }, - tablet: { - header: gettext('Use tablet for pointer'), - defaultValue: true, - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.Config.HWType'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Use tablet for pointer'), - items: { - xtype: 'proxmoxcheckbox', - name: 'tablet', - checked: true, - uncheckedValue: 0, - defaultValue: 1, - deleteDefaultValue: true, - fieldLabel: gettext('Enabled'), - }, - } - : undefined, - }, - hotplug: { - header: gettext('Hotplug'), - defaultValue: 'disk,network,usb', - renderer: PVE.Utils.render_hotplug_features, - editor: caps.vms['VM.Config.HWType'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Hotplug'), - items: { - xtype: 'pveHotplugFeatureSelector', - name: 'hotplug', - value: '', - multiSelect: true, - fieldLabel: gettext('Hotplug'), - allowBlank: true, - }, - } - : undefined, - }, - acpi: { - header: gettext('ACPI support'), - defaultValue: true, - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.Config.HWType'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('ACPI support'), - items: { - xtype: 'proxmoxcheckbox', - name: 'acpi', - checked: true, - uncheckedValue: 0, - defaultValue: 1, - deleteDefaultValue: true, - fieldLabel: gettext('Enabled'), - }, - } - : undefined, - }, - kvm: { - header: gettext('KVM hardware virtualization'), - defaultValue: true, - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.Config.HWType'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('KVM hardware virtualization'), - items: { - xtype: 'proxmoxcheckbox', - name: 'kvm', - checked: true, - uncheckedValue: 0, - defaultValue: 1, - deleteDefaultValue: true, - fieldLabel: gettext('Enabled'), - }, - } - : undefined, - }, - freeze: { - header: gettext('Freeze CPU at startup'), - defaultValue: false, - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.PowerMgmt'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Freeze CPU at startup'), - items: { - xtype: 'proxmoxcheckbox', - name: 'freeze', - uncheckedValue: 0, - defaultValue: 0, - deleteDefaultValue: true, - labelWidth: 140, - fieldLabel: gettext('Freeze CPU at startup'), - }, - } - : undefined, - }, - localtime: { - header: gettext('Use local time for RTC'), - defaultValue: '__default__', - renderer: PVE.Utils.render_localtime, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Use local time for RTC'), - width: 400, - items: { - xtype: 'proxmoxKVComboBox', - name: 'localtime', - value: '__default__', - comboItems: [ - ['__default__', PVE.Utils.render_localtime('__default__')], - [1, PVE.Utils.render_localtime(1)], - [0, PVE.Utils.render_localtime(0)], - ], - labelWidth: 140, - fieldLabel: gettext('Use local time for RTC'), - }, - } - : undefined, - }, - startdate: { - header: gettext('RTC start date'), - defaultValue: 'now', - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('RTC start date'), - items: { - xtype: 'proxmoxtextfield', - name: 'startdate', - deleteEmpty: true, - value: 'now', - fieldLabel: gettext('RTC start date'), - vtype: 'QemuStartDate', - allowBlank: true, - }, - } - : undefined, - }, - smbios1: { - header: gettext('SMBIOS settings (type1)'), - defaultValue: '', - renderer: Ext.String.htmlEncode, - editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.Smbios1Edit' : undefined, - }, - agent: { - header: 'QEMU Guest Agent', - defaultValue: false, - renderer: PVE.Utils.render_qga_features, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Qemu Agent'), - width: 350, - onlineHelp: 'qm_qemu_agent', - items: { - xtype: 'pveAgentFeatureSelector', - name: 'agent', - }, - } - : undefined, - }, - protection: { - header: gettext('Protection'), - defaultValue: false, - renderer: Proxmox.Utils.format_boolean, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Protection'), - items: { - xtype: 'proxmoxcheckbox', - name: 'protection', - uncheckedValue: 0, - defaultValue: 0, - deleteDefaultValue: true, - fieldLabel: gettext('Enabled'), - }, - } - : undefined, - }, - spice_enhancements: { - header: gettext('Spice Enhancements'), - defaultValue: false, - renderer: PVE.Utils.render_spice_enhancements, - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('Spice Enhancements'), - onlineHelp: 'qm_spice_enhancements', - items: { - xtype: 'pveSpiceEnhancementSelector', - name: 'spice_enhancements', - }, - } - : undefined, - }, - vmstatestorage: { - header: gettext('VM State storage'), - defaultValue: '', - renderer: (val) => val || gettext('Automatic'), - editor: caps.vms['VM.Config.Options'] - ? { - xtype: 'proxmoxWindowEdit', - subject: gettext('VM State storage'), - onlineHelp: 'qm_vmstatestorage', - width: 350, - items: { - xtype: 'pveStorageSelector', - storageContent: 'images', - allowBlank: true, - emptyText: gettext("Automatic (Storage used by the VM, or 'local')"), - autoSelect: false, - deleteEmpty: true, - skipEmptyText: true, - nodename: nodename, - name: 'vmstatestorage', - }, - } - : undefined, - }, - 'amd-sev': { - header: gettext('AMD SEV'), - editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.SevEdit' : undefined, - defaultValue: Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', - renderer: function (value, metaData, record, ri, ci, store, pending) { - let amd_sev = PVE.Parser.parsePropertyString(value, 'type'); - if (amd_sev.type === 'std') { - return 'AMD SEV (' + value + ')'; - } - if (amd_sev.type === 'es') { - return 'AMD SEV-ES (' + value + ')'; - } - if (amd_sev.type === 'snp') { - return 'AMD SEV-SNP (' + value + ')'; - } - return value; - }, - }, - 'intel-tdx': { - header: gettext('Intel TDX'), - editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.TdxEdit' : undefined, - defaultValue: Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', - renderer: function (value, metaData, record, ri, ci, store, pending) { - let intel_tdx = PVE.Parser.parsePropertyString(value, 'type'); - if (intel_tdx.type === 'tdx') { - return 'Intel (' + value + ')'; - } - return value; - }, - }, - hookscript: { - header: gettext('Hookscript'), - }, - }; - - var baseurl = 'nodes/' + nodename + '/qemu/' + vmid + '/config'; - - var edit_btn = new Ext.Button({ - text: gettext('Edit'), - disabled: true, - handler: function () { - me.run_editor(); - }, - }); - - var revert_btn = new PVE.button.PendingRevert(); - - var set_button_status = function () { - var sm = me.getSelectionModel(); - var rec = sm.getSelection()[0]; - - if (!rec) { - edit_btn.disable(); - return; - } - - var key = rec.data.key; - var pending = rec.data.delete || me.hasPendingChanges(key); - var rowdef = rows[key]; - - edit_btn.setDisabled(!rowdef.editor); - revert_btn.setDisabled(!pending); - }; - - Ext.apply(me, { - url: '/api2/json/nodes/' + nodename + '/qemu/' + vmid + '/pending', - interval: 5000, - cwidth1: 250, - tbar: [edit_btn, revert_btn], - rows: rows, - editorConfig: { - url: '/api2/extjs/' + baseurl, - }, - listeners: { - itemdblclick: me.run_editor, - selectionchange: set_button_status, - }, - }); - - me.callParent(); - - me.on('activate', () => me.rstore.startUpdate()); - me.on('destroy', () => me.rstore.stopUpdate()); - me.on('deactivate', () => me.rstore.stopUpdate()); - - me.mon(me.getStore(), 'datachanged', function () { - set_button_status(); - }); - }, -}); -Ext.define('PVE.qemu.PCIInputPanel', { - extend: 'Proxmox.panel.InputPanel', - - onlineHelp: 'qm_pci_passthrough_vm_config', - - controller: { - xclass: 'Ext.app.ViewController', - - setVMConfig: function (vmconfig) { - let me = this; - let view = me.getView(); - let vm = me.getViewModel(); - me.vmconfig = vmconfig; - - let hostpci = me.vmconfig[view.confid] || ''; - - let values = PVE.Parser.parsePropertyString(hostpci, 'host'); - if (values.host) { - if (!values.host.match(/^[0-9a-f]{4}:/i)) { - // add optional domain - values.host = '0000:' + values.host; - } - if (values.host.length < 11) { - // 0000:00:00 format not 0000:00:00.0 - values.host += '.0'; - values.multifunction = true; - } - values.type = 'raw'; - } else if (values.mapping) { - values.type = 'mapped'; - } - vm.set('isMapped', values.type !== 'raw'); - - values['x-vga'] = PVE.Parser.parseBoolean(values['x-vga'], 0); - values.pcie = PVE.Parser.parseBoolean(values.pcie, 0); - values.rombar = PVE.Parser.parseBoolean(values.rombar, 1); - - view.setValues(values); - if (!me.vmconfig.machine || me.vmconfig.machine.indexOf('q35') === -1) { - // machine is not set to some variant of q35, so we disable pcie - let pcie = me.lookup('pcie'); - pcie.setDisabled(true); - pcie.setBoxLabel(gettext('Q35 only')); - } - - if (values.romfile) { - me.lookup('romfile').setVisible(true); - } - }, - - selectorEnable: function (selector) { - let me = this; - me.pciDevChange(selector, selector.getValue()); - }, - - pciDevChange: function (pcisel, value) { - let me = this; - let mdevfield = me.lookup('mdev'); - if (!value) { - if (!pcisel.isDisabled()) { - mdevfield.setDisabled(true); - } - return; - } - let pciDev = pcisel.getStore().getById(value); - - mdevfield.setDisabled(!pciDev || !pciDev.data.mdev); - if (!pciDev) { - return; - } - - let path = value; - if (pciDev.data.map) { - path = pciDev.data.id; - } - - if (pciDev.data.mdev) { - mdevfield.setPciIdOrMapping(path); - } - if (pcisel.reference === 'selector') { - let iommu = pciDev.data.iommugroup; - if (iommu === -1) { - return; - } - // try to find out if there are more devices in that iommu group - let id = path.substring(0, 5); // 00:00 - let count = 0; - pcisel.getStore().each(({ data }) => { - if (data.iommugroup === iommu && data.id.substring(0, 5) !== id) { - count++; - return false; - } - return true; - }); - me.lookup('group_warning').setVisible(count > 0); - } - }, - - onGetValues: function (values) { - let me = this; - let view = me.getView(); - if (!view.confid) { - for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) { - if (!me.vmconfig['hostpci' + i.toString()]) { - view.confid = 'hostpci' + i.toString(); - break; - } - } - // FIXME: what if no confid was found?? - } - - values.host?.replace(/^0000:/, ''); // remove optional '0000' domain - - if (values.multifunction && values.host) { - values.host = values.host.substring(0, values.host.indexOf('.')); // skip the '.X' - delete values.multifunction; - } - - if (values.rombar) { - delete values.rombar; - } else { - values.rombar = 0; - } - - if (!values.romfile) { - delete values.romfile; - } - - delete values.type; - - let ret = {}; - ret[view.confid] = PVE.Parser.printPropertyString(values, 'host'); - return ret; - }, - }, - - viewModel: { - data: { - isMapped: true, - }, - }, - - setVMConfig: function (vmconfig) { - return this.getController().setVMConfig(vmconfig); - }, - - onGetValues: function (values) { - return this.getController().onGetValues(values); - }, - - initComponent: function () { - let me = this; - - me.nodename = me.pveSelNode.data.node; - if (!me.nodename) { - throw 'no node name specified'; - } - - me.columnT = [ - { - xtype: 'displayfield', - reference: 'iommu_warning', - hidden: true, - columnWidth: 1, - padding: '0 0 10 0', - value: - 'No IOMMU detected, please activate it.' + - 'See Documentation for further information.', - userCls: 'pmx-hint', - }, - { - xtype: 'displayfield', - reference: 'group_warning', - hidden: true, - columnWidth: 1, - padding: '0 0 10 0', - itemId: 'iommuwarning', - value: 'The selected Device is not in a separate IOMMU group, make sure this is intended.', - userCls: 'pmx-hint', - }, - ]; - - me.column1 = [ - { - xtype: 'radiofield', - name: 'type', - inputValue: 'mapped', - checked: true, - boxLabel: gettext('Mapped Device'), - bind: { - value: '{isMapped}', - }, - }, - { - xtype: 'pvePCIMapSelector', - fieldLabel: gettext('Device'), - reference: 'mapped_selector', - name: 'mapping', - labelAlign: 'right', - nodename: me.nodename, - allowBlank: false, - bind: { - disabled: '{!isMapped}', - }, - listeners: { - change: 'pciDevChange', - enable: 'selectorEnable', - }, - }, - { - xtype: 'radiofield', - name: 'type', - inputValue: 'raw', - boxLabel: gettext('Raw Device'), - }, - { - xtype: 'pvePCISelector', - fieldLabel: gettext('Device'), - name: 'host', - reference: 'selector', - nodename: me.nodename, - labelAlign: 'right', - allowBlank: false, - disabled: true, - bind: { - disabled: '{isMapped}', - }, - onLoadCallBack: function (store, records, success) { - if (!success || !records.length) { - return; - } - me.lookup('iommu_warning').setVisible( - records.every((val) => val.data.iommugroup === -1), - ); - }, - listeners: { - change: 'pciDevChange', - enable: 'selectorEnable', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('All Functions'), - reference: 'all_functions', - disabled: true, - labelAlign: 'right', - name: 'multifunction', - bind: { - disabled: '{isMapped}', - }, - }, - ]; - - me.column2 = [ - { - xtype: 'pveMDevSelector', - name: 'mdev', - reference: 'mdev', - disabled: true, - fieldLabel: gettext('MDev Type'), - nodename: me.nodename, - listeners: { - change: function (field, value) { - let multiFunction = me.down('field[name=multifunction]'); - if (value) { - multiFunction.setValue(false); - } - multiFunction.setDisabled(!!value); - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Primary GPU'), - name: 'x-vga', - }, - ]; - - me.advancedColumn1 = [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: 'ROM-Bar', - name: 'rombar', - }, - { - xtype: 'displayfield', - submitValue: true, - hidden: true, - fieldLabel: 'ROM-File', - reference: 'romfile', - name: 'romfile', - }, - { - xtype: 'textfield', - name: 'vendor-id', - fieldLabel: gettext('Vendor ID'), - emptyText: gettext('From Device'), - vtype: 'PciId', - allowBlank: true, - submitEmpty: false, - }, - { - xtype: 'textfield', - name: 'device-id', - fieldLabel: gettext('Device ID'), - emptyText: gettext('From Device'), - vtype: 'PciId', - allowBlank: true, - submitEmpty: false, - }, - ]; - - me.advancedColumn2 = [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: 'PCI-Express', - reference: 'pcie', - name: 'pcie', - }, - { - xtype: 'textfield', - name: 'sub-vendor-id', - fieldLabel: gettext('Sub-Vendor ID'), - emptyText: gettext('From Device'), - vtype: 'PciId', - allowBlank: true, - submitEmpty: false, - }, - { - xtype: 'textfield', - name: 'sub-device-id', - fieldLabel: gettext('Sub-Device ID'), - emptyText: gettext('From Device'), - vtype: 'PciId', - allowBlank: true, - submitEmpty: false, - }, - ]; - - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.PCIEdit', { - extend: 'Proxmox.window.Edit', - - subject: gettext('PCI Device'), - - vmconfig: undefined, - isAdd: true, - - initComponent: function () { - let me = this; - - me.isCreate = !me.confid; - - let ipanel = Ext.create('PVE.qemu.PCIInputPanel', { - confid: me.confid, - pveSelNode: me.pveSelNode, - }); - - Ext.apply(me, { - items: [ipanel], - }); - - me.callParent(); - - me.load({ - success: ({ result }) => ipanel.setVMConfig(result.data), - }); - }, -}); -// The view model of the parent should contain a 'cgroupMode' variable (or params for v2 are used). -Ext.define('PVE.qemu.ProcessorInputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.pveQemuProcessorPanel', - onlineHelp: 'qm_cpu', - - insideWizard: false, - - viewModel: { - data: { - socketCount: 1, - coreCount: 1, - showCustomModelPermWarning: false, - userIsRoot: false, - }, - formulas: { - totalCoreCount: (get) => get('socketCount') * get('coreCount'), - cpuunitsDefault: (get) => (get('cgroupMode') === 1 ? 1024 : 100), - cpuunitsMin: (get) => (get('cgroupMode') === 1 ? 2 : 1), - cpuunitsMax: (get) => (get('cgroupMode') === 1 ? 262144 : 10000), - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - init: function () { - let me = this; - let viewModel = me.getViewModel(); - - viewModel.set('userIsRoot', Proxmox.UserName === 'root@pam'); - }, - }, - - onGetValues: function (values) { - let me = this; - let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault'); - - if (Array.isArray(values.delete)) { - values.delete = values.delete.join(','); - } - - PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard); - PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard); - - // build the cpu options: - me.cpu.cputype = values.cputype; - - if (values.flags) { - me.cpu.flags = values.flags; - } else { - delete me.cpu.flags; - } - - delete values.cputype; - delete values.flags; - var cpustring = PVE.Parser.printQemuCpu(me.cpu); - - // remove cputype delete request: - var del = values.delete; - delete values.delete; - if (del) { - del = del.split(','); - Ext.Array.remove(del, 'cputype'); - } else { - del = []; - } - - if (cpustring) { - values.cpu = cpustring; - } else { - del.push('cpu'); - } - - var delarr = del.join(','); - if (delarr) { - values.delete = delarr; - } - - return values; - }, - - setValues: function (values) { - let me = this; - - let type = values.cputype; - let typeSelector = me.lookupReference('cputype'); - let typeStore = typeSelector.getStore(); - typeStore.on('load', (store, records, success) => { - if (!success || !type || records.some((x) => x.data.name === type)) { - return; - } - - // if we get here, a custom CPU model is selected for the VM but we - // don't have permission to configure it - it will not be in the - // list retrieved from the API, so add it manually to allow changing - // other processor options - typeStore.add({ - name: type, - displayname: type.replace(/^custom-/, ''), - custom: 1, - vendor: gettext('Unknown'), - }); - typeSelector.select(type); - }); - - me.callParent([values]); - }, - - cpu: {}, - - column1: [ - { - xtype: 'proxmoxintegerfield', - name: 'sockets', - minValue: 1, - maxValue: 4, - value: '1', - fieldLabel: gettext('Sockets'), - allowBlank: false, - bind: { - value: '{socketCount}', - }, - }, - { - xtype: 'proxmoxintegerfield', - name: 'cores', - minValue: 1, - maxValue: 256, - value: '1', - fieldLabel: gettext('Cores'), - allowBlank: false, - bind: { - value: '{coreCount}', - }, - }, - ], - - column2: [ - { - xtype: 'CPUModelSelector', - name: 'cputype', - reference: 'cputype', - fieldLabel: gettext('Type'), - }, - { - xtype: 'displayfield', - fieldLabel: gettext('Total cores'), - name: 'totalcores', - isFormField: false, - bind: { - value: '{totalCoreCount}', - }, - }, - ], - - columnB: [ - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - 'WARNING: You do not have permission to configure custom CPU types, if you change the type you will not be able to go back!', - ), - hidden: true, - bind: { - hidden: '{!showCustomModelPermWarning}', - }, - }, - ], - - advancedColumn1: [ - { - xtype: 'proxmoxintegerfield', - name: 'vcpus', - minValue: 1, - maxValue: 1, - value: '', - fieldLabel: gettext('VCPUs'), - deleteEmpty: true, - allowBlank: true, - emptyText: '1', - bind: { - emptyText: '{totalCoreCount}', - maxValue: '{totalCoreCount}', - }, - }, - { - xtype: 'numberfield', - name: 'cpulimit', - minValue: 0, - maxValue: 128, // api maximum - value: '', - step: 1, - fieldLabel: gettext('CPU limit'), - allowBlank: true, - emptyText: gettext('unlimited'), - }, - { - xtype: 'proxmoxtextfield', - name: 'affinity', - vtype: 'CpuSet', - value: '', - fieldLabel: gettext('CPU Affinity'), - allowBlank: true, - emptyText: gettext('All Cores'), - deleteEmpty: true, - bind: { - disabled: '{!userIsRoot}', - }, - }, - ], - - advancedColumn2: [ - { - xtype: 'proxmoxintegerfield', - name: 'cpuunits', - fieldLabel: gettext('CPU units'), - minValue: '1', - maxValue: '10000', - value: '', - emptyText: '100', - bind: { - minValue: '{cpuunitsMin}', - maxValue: '{cpuunitsMax}', - emptyText: '{cpuunitsDefault}', - }, - deleteEmpty: true, - allowBlank: true, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enable NUMA'), - name: 'numa', - uncheckedValue: 0, - }, - ], - advancedColumnB: [ - { - xtype: 'label', - text: 'Extra CPU Flags:', - }, - { - xtype: 'vmcpuflagselector', - name: 'flags', - }, - ], -}); - -Ext.define('PVE.qemu.ProcessorEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveQemuProcessorEdit', - - width: 700, - - viewModel: { - data: { - cgroupMode: 2, - }, - }, - - initComponent: function () { - let me = this; - me.getViewModel().set('cgroupMode', me.cgroupMode); - - var ipanel = Ext.create('PVE.qemu.ProcessorInputPanel'); - - Ext.apply(me, { - subject: gettext('Processors'), - items: ipanel, - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - var data = response.result.data; - var value = data.cpu; - if (value) { - let cpu = PVE.Parser.parseQemuCpu(value); - ipanel.cpu = cpu; - data.cputype = cpu.cputype; - if (cpu.flags) { - data.flags = cpu.flags; - } - - let caps = Ext.state.Manager.get('GuiCap'); - if (data.cputype.indexOf('custom-') === 0 && !caps.nodes['Sys.Audit']) { - let vm = ipanel.getViewModel(); - vm.set('showCustomModelPermWarning', true); - } - } - me.setValues(data); - }, - }); - }, -}); -Ext.define('PVE.qemu.BiosEdit', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveQemuBiosEdit', - - onlineHelp: 'qm_bios_and_uefi', - subject: 'BIOS', - autoLoad: true, - - viewModel: { - data: { - bios: '__default__', - efidisk0: false, - }, - formulas: { - showEFIDiskHint: (get) => get('bios') === 'ovmf' && !get('efidisk0'), - }, - }, - - items: [ - { - xtype: 'pveQemuBiosSelector', - onlineHelp: 'qm_bios_and_uefi', - name: 'bios', - value: '__default__', - bind: '{bios}', - fieldLabel: 'BIOS', - }, - { - xtype: 'displayfield', - name: 'efidisk0', - bind: '{efidisk0}', - hidden: true, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - 'You need to add an EFI disk for storing the EFI settings. See the online help for details.', - ), - bind: { - hidden: '{!showEFIDiskHint}', - }, - }, - ], -}); -Ext.define('PVE.qemu.RNGInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveRNGInputPanel', - - onlineHelp: 'qm_virtio_rng', - - onGetValues: function (values) { - if (values.max_bytes === '') { - values.max_bytes = '0'; - } else if (values.max_bytes === '1024' && values.period === '') { - delete values.max_bytes; - } - - var ret = PVE.Parser.printPropertyString(values); - - return { - rng0: ret, - }; - }, - - setValues: function (values) { - if (values.max_bytes === 0) { - values.max_bytes = null; - } - - this.callParent(arguments); - }, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - '#max_bytes': { - change: function (el, newVal) { - let limitWarning = this.lookupReference('limitWarning'); - limitWarning.setHidden(!!newVal); - }, - }, - }, - }, - - items: [ - { - itemId: 'source', - name: 'source', - xtype: 'proxmoxKVComboBox', - value: '/dev/urandom', - fieldLabel: gettext('Entropy source'), - labelWidth: 130, - comboItems: [ - ['/dev/urandom', '/dev/urandom'], - ['/dev/random', '/dev/random'], - ['/dev/hwrng', '/dev/hwrng'], - ], - }, - { - xtype: 'numberfield', - itemId: 'max_bytes', - name: 'max_bytes', - minValue: 0, - step: 1, - value: 1024, - fieldLabel: gettext('Limit (Bytes/Period)'), - labelWidth: 130, - emptyText: gettext('unlimited'), - }, - { - xtype: 'numberfield', - name: 'period', - minValue: 1, - step: 1, - fieldLabel: gettext('Period') + ' (ms)', - labelWidth: 130, - emptyText: '1000', - }, - { - xtype: 'displayfield', - reference: 'limitWarning', - value: gettext( - 'Disabling the limiter can potentially allow a guest to overload the host. Proceed with caution.', - ), - userCls: 'pmx-hint', - hidden: true, - }, - ], -}); - -Ext.define('PVE.qemu.RNGEdit', { - extend: 'Proxmox.window.Edit', - - subject: gettext('VirtIO RNG'), - - items: [ - { - xtype: 'pveRNGInputPanel', - }, - ], - - initComponent: function () { - var me = this; - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response) { - me.vmconfig = response.result.data; - - var rng0 = me.vmconfig.rng0; - if (rng0) { - me.setValues(PVE.Parser.parsePropertyString(rng0)); - } - }, - }); - } - }, -}); -Ext.define('PVE.qemu.SSHKeyInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveQemuSSHKeyInputPanel', - - insideWizard: false, - - onGetValues: function (values) { - var _me = this; - if (values.sshkeys) { - values.sshkeys.trim(); - } - if (!values.sshkeys.length) { - values = {}; - values.delete = 'sshkeys'; - return values; - } else { - values.sshkeys = encodeURIComponent(values.sshkeys); - } - return values; - }, - - items: [ - { - xtype: 'textarea', - itemId: 'sshkeys', - name: 'sshkeys', - height: 250, - }, - { - xtype: 'filebutton', - itemId: 'filebutton', - name: 'file', - text: gettext('Load SSH Key File'), - fieldLabel: 'test', - listeners: { - change: function (btn, e, value) { - let view = this.up('inputpanel'); - e = e.event; - Ext.Array.each(e.target.files, function (file) { - PVE.Utils.loadSSHKeyFromFile(file, function (res) { - let keysField = view.down('#sshkeys'); - var old = keysField.getValue(); - keysField.setValue(old + res); - }); - }); - btn.reset(); - }, - }, - }, - ], - - initComponent: function () { - var me = this; - - me.callParent(); - if (!window.FileReader) { - me.down('#filebutton').setVisible(false); - } - }, -}); - -Ext.define('PVE.qemu.SSHKeyEdit', { - extend: 'Proxmox.window.Edit', - - width: 800, - - initComponent: function () { - var me = this; - - var ipanel = Ext.create('PVE.qemu.SSHKeyInputPanel'); - - Ext.apply(me, { - subject: gettext('SSH Keys'), - items: [ipanel], - }); - - me.callParent(); - - if (!me.create) { - me.load({ - success: function (response, options) { - var data = response.result.data; - if (data.sshkeys) { - data.sshkeys = decodeURIComponent(data.sshkeys); - ipanel.setValues(data); - } - }, - }); - } - }, -}); -Ext.define('PVE.qemu.ScsiHwEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - Ext.applyIf(me, { - subject: gettext('SCSI Controller Type'), - items: { - xtype: 'pveScsiHwSelector', - name: 'scsihw', - value: '__default__', - fieldLabel: gettext('Type'), - }, - }); - - me.callParent(); - - me.load(); - }, -}); -Ext.define('PVE.qemu.SerialnputPanel', { - extend: 'Proxmox.panel.InputPanel', - - autoComplete: false, - - setVMConfig: function (vmconfig) { - var me = this, - i; - me.vmconfig = vmconfig; - - for (i = 0; i < 4; i++) { - let port = 'serial' + i.toString(); - if (!me.vmconfig[port]) { - me.down('field[name=serialid]').setValue(i); - break; - } - } - }, - - onGetValues: function (values) { - var _me = this; - - var id = 'serial' + values.serialid; - delete values.serialid; - values[id] = 'socket'; - return values; - }, - - items: [ - { - xtype: 'proxmoxintegerfield', - name: 'serialid', - fieldLabel: gettext('Serial Port'), - minValue: 0, - maxValue: 3, - allowBlank: false, - validator: function (id) { - if (!this.rendered) { - return true; - } - let view = this.up('panel'); - if (view.vmconfig !== undefined && Ext.isDefined(view.vmconfig['serial' + id])) { - return 'This device is already in use.'; - } - return true; - }, - }, - ], -}); - -Ext.define('PVE.qemu.SerialEdit', { - extend: 'Proxmox.window.Edit', - - vmconfig: undefined, - - isAdd: true, - - subject: gettext('Serial Port'), - - initComponent: function () { - var me = this; - - // for now create of (socket) serial port only - me.isCreate = true; - - var ipanel = Ext.create('PVE.qemu.SerialnputPanel', {}); - - Ext.apply(me, { - items: [ipanel], - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - ipanel.setVMConfig(response.result.data); - }, - }); - }, -}); -Ext.define('PVE.qemu.SevInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveSevInputPanel', - - onlineHelp: 'qm_memory', // TODO: change to 'qm_memory_encryption' one available - - viewModel: { - data: { - type: '__default__', - }, - formulas: { - sevEnabled: (get) => - get('type') === 'std' || get('type') === 'es' || get('type') === 'snp', - snpEnabled: (get) => get('type') === 'snp', - }, - }, - - onGetValues: function (values) { - if (values.delete === 'type') { - values.delete = 'amd-sev'; - return values; - } - if (!values.debug) { - values['no-debug'] = 1; - } - if (!values.smt && values.type === 'snp') { - values['allow-smt'] = 0; - } - if (!values['key-sharing'] && values.type !== 'snp') { - values['no-key-sharing'] = 1; - } - delete values.debug; - delete values.smt; - delete values['key-sharing']; - let ret = {}; - ret['amd-sev'] = PVE.Parser.printPropertyString(values, 'type'); - return ret; - }, - - setValues: function (values) { - if (PVE.Parser.parseBoolean(values['no-debug'])) { - values.debug = 0; - } - values.smt = PVE.Parser.parseBoolean(values['allow-smt'], 1); - if (PVE.Parser.parseBoolean(values['no-key-sharing'])) { - values['key-sharing'] = 0; - } - this.callParent(arguments); - }, - - items: [ - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('AMD SEV Type'), - labelWidth: 150, - name: 'type', - value: '__default__', - comboItems: [ - [ - '__default__', - Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', - ], - ['std', 'AMD SEV'], - ['es', 'AMD SEV-ES'], - ['snp', 'AMD SEV-SNP'], - ], - bind: { - value: '{type}', - }, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext('WARNING: When using SEV-SNP no EFI disk is loaded as pflash.'), - bind: { - hidden: '{!snpEnabled}', - }, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext('Note: SEV-SNP requires host kernel version 6.11 or higher.'), - bind: { - hidden: '{!snpEnabled}', - }, - }, - ], - - advancedItems: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Allow Debugging'), - labelWidth: 150, - name: 'debug', - value: 1, - bind: { - hidden: '{!sevEnabled}', - disabled: '{!sevEnabled}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Allow Key-Sharing'), - labelWidth: 150, - name: 'key-sharing', - value: 1, - bind: { - hidden: '{!sevEnabled || snpEnabled}', - disabled: '{!sevEnabled || snpEnabled}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Allow SMT'), - labelWidth: 150, - name: 'smt', - value: 1, - bind: { - hidden: '{!snpEnabled}', - disabled: '{!snpEnabled}', - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enable Kernel Hashes'), - labelWidth: 150, - name: 'kernel-hashes', - deleteDefaultValue: false, - bind: { - hidden: '{!sevEnabled}', - disabled: '{!sevEnabled}', - }, - }, - ], -}); - -Ext.define('PVE.qemu.SevEdit', { - extend: 'Proxmox.window.Edit', - - subject: 'AMD Secure Encrypted Virtualization (SEV)', - - items: { - xtype: 'pveSevInputPanel', - }, - - width: 400, - - initComponent: function () { - let me = this; - - me.callParent(); - - me.load({ - success: function (response) { - let conf = response.result.data; - let amd_sev = conf['amd-sev'] || '__default__'; - me.setValues(PVE.Parser.parsePropertyString(amd_sev, 'type')); - }, - }); - }, -}); -Ext.define('PVE.qemu.Smbios1InputPanel', { - extend: 'Proxmox.panel.InputPanel', - alias: 'widget.PVE.qemu.Smbios1InputPanel', - - insideWizard: false, - - smbios1: {}, - - onGetValues: function (values) { - var _me = this; - - var params = { - smbios1: PVE.Parser.printQemuSmbios1(values), - }; - - return params; - }, - - setSmbios1: function (data) { - var me = this; - - me.smbios1 = data; - - me.setValues(me.smbios1); - }, - - items: [ - { - xtype: 'textfield', - fieldLabel: 'UUID', - regex: /^[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$/, - name: 'uuid', - }, - { - xtype: 'textareafield', - fieldLabel: gettext('Manufacturer'), - fieldStyle: { - height: '2em', - minHeight: '2em', - }, - name: 'manufacturer', - }, - { - xtype: 'textareafield', - fieldLabel: gettext('Product'), - fieldStyle: { - height: '2em', - minHeight: '2em', - }, - name: 'product', - }, - { - xtype: 'textareafield', - fieldLabel: gettext('Version'), - fieldStyle: { - height: '2em', - minHeight: '2em', - }, - name: 'version', - }, - { - xtype: 'textareafield', - fieldLabel: gettext('Serial'), - fieldStyle: { - height: '2em', - minHeight: '2em', - }, - name: 'serial', - }, - { - xtype: 'textareafield', - fieldLabel: 'SKU', - fieldStyle: { - height: '2em', - minHeight: '2em', - }, - name: 'sku', - }, - { - xtype: 'textareafield', - fieldLabel: gettext('Family'), - fieldStyle: { - height: '2em', - minHeight: '2em', - }, - name: 'family', - }, - ], -}); - -Ext.define('PVE.qemu.Smbios1Edit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - var ipanel = Ext.create('PVE.qemu.Smbios1InputPanel', {}); - - Ext.applyIf(me, { - subject: gettext('SMBIOS settings (type1)'), - width: 450, - items: ipanel, - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - me.vmconfig = response.result.data; - var value = me.vmconfig.smbios1; - if (value) { - let data = PVE.Parser.parseQemuSmbios1(value); - if (!data) { - Ext.Msg.alert(gettext('Error'), 'Unable to parse smbios options'); - me.close(); - return; - } - ipanel.setSmbios1(data); - } - }, - }); - }, -}); -Ext.define('PVE.qemu.SystemInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveQemuSystemPanel', - - onlineHelp: 'qm_system_settings', - - viewModel: { - data: { - efi: false, - addefi: true, - }, - - formulas: { - efidisk: function (get) { - return get('efi') && get('addefi'); - }, - }, - }, - - onGetValues: function (values) { - if (values.vga && values.vga.substr(0, 6) === 'serial') { - values['serial' + values.vga.substr(6, 1)] = 'socket'; - } - - delete values.hdimage; - delete values.hdstorage; - delete values.diskformat; - - delete values.preEnrolledKeys; // efidisk - delete values.version; // tpmstate - - return values; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - scsihwChange: function (field, value) { - var me = this; - if (me.getView().insideWizard) { - me.getViewModel().set('current.scsihw', value); - } - }, - - biosChange: function (field, value) { - var me = this; - if (me.getView().insideWizard) { - me.getViewModel().set('efi', value === 'ovmf'); - } - }, - - control: { - pveScsiHwSelector: { - change: 'scsihwChange', - }, - pveQemuBiosSelector: { - change: 'biosChange', - }, - '#': { - afterrender: 'setMachine', - }, - }, - - setMachine: function () { - let me = this; - let vm = this.getViewModel(); - let ostype = vm.get('current.ostype'); - if (ostype === 'win11') { - me.lookup('machine').setValue('q35'); - me.lookup('bios').setValue('ovmf'); - me.lookup('addtpmbox').setValue(true); - } - }, - }, - - column1: [ - { - xtype: 'proxmoxKVComboBox', - value: '__default__', - deleteEmpty: false, - fieldLabel: gettext('Graphic card'), - name: 'vga', - comboItems: Object.entries(PVE.Utils.kvm_vga_drivers), - }, - { - xtype: 'proxmoxKVComboBox', - name: 'machine', - reference: 'machine', - value: '__default__', - fieldLabel: gettext('Machine'), - comboItems: [ - ['__default__', PVE.Utils.render_qemu_machine('')], - ['q35', 'q35'], - ], - }, - { - xtype: 'displayfield', - value: gettext('Firmware'), - }, - { - xtype: 'pveQemuBiosSelector', - name: 'bios', - reference: 'bios', - value: '__default__', - fieldLabel: 'BIOS', - }, - { - xtype: 'proxmoxcheckbox', - bind: { - value: '{addefi}', - hidden: '{!efi}', - disabled: '{!efi}', - }, - hidden: true, - submitValue: false, - disabled: true, - fieldLabel: gettext('Add EFI Disk'), - }, - { - xtype: 'pveEFIDiskInputPanel', - name: 'efidisk0', - storageContent: 'images', - bind: { - nodename: '{nodename}', - hidden: '{!efi}', - disabled: '{!efidisk}', - }, - autoSelect: false, - disabled: true, - hidden: true, - hideSize: true, - usesEFI: true, - }, - ], - - column2: [ - { - xtype: 'pveScsiHwSelector', - name: 'scsihw', - value: '__default__', - bind: { - value: '{current.scsihw}', - }, - fieldLabel: gettext('SCSI Controller'), - }, - { - xtype: 'proxmoxcheckbox', - name: 'agent', - uncheckedValue: 0, - defaultValue: 0, - deleteDefaultValue: true, - fieldLabel: gettext('Qemu Agent'), - }, - { - // fake for spacing - xtype: 'displayfield', - value: ' ', - }, - { - xtype: 'proxmoxcheckbox', - reference: 'addtpmbox', - bind: { - value: '{addtpm}', - }, - submitValue: false, - fieldLabel: gettext('Add TPM'), - }, - { - xtype: 'pveTPMDiskInputPanel', - name: 'tpmstate0', - storageContent: 'images', - bind: { - nodename: '{nodename}', - hidden: '{!addtpm}', - disabled: '{!addtpm}', - }, - disabled: true, - hidden: true, - }, - ], -}); -Ext.define('PVE.qemu.TdxInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveTdxInputPanel', - - onlineHelp: 'qm_memory', // TODO: change to 'qm_memory_encryption' one available - - viewModel: { - data: { - type: '__default__', - attestation: 1, - }, - formulas: { - tdxEnabled: (get) => get('type') === 'tdx', - attestationEnabled: (get) => Number(get('attestation')) === 1, - }, - }, - - onGetValues: function (values) { - if (values.delete === 'type') { - values.delete = 'intel-tdx'; - return values; - } - let ret = {}; - ret['intel-tdx'] = PVE.Parser.printPropertyString(values, 'type'); - return ret; - }, - - setValues: function (values) { - this.callParent(arguments); - }, - - items: [ - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('Intel TDX Type'), - labelWidth: 150, - name: 'type', - value: '__default__', - comboItems: [ - [ - '__default__', - Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')', - ], - ['tdx', 'Intel TDX'], - ], - bind: { - value: '{type}', - }, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext('WARNING: When using Intel TDX no EFI disk is loaded as pflash.'), - bind: { - hidden: '{!tdxEnabled}', - }, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - 'Note: Intel TDX is only supported by specific recent CPU models and requires host kernel version 6.16 or higher.', - ), - bind: { - hidden: '{!tdxEnabled}', - }, - }, - ], - - advancedItems: [ - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enable Attestation'), - labelWidth: 150, - name: 'attestation', - value: 1, - uncheckedValue: 0, - bind: { - value: '{attestation}', - hidden: '{!tdxEnabled}', - disabled: '{!tdxEnabled}', - }, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: gettext('CID'), - labelWidth: 150, - name: 'vsock-cid', - minValue: 2, - value: '2', - allowBlank: false, - bind: { - hidden: '{!tdxEnabled}', - disabled: '{!attestationEnabled || !tdxEnabled}', - }, - }, - { - xtype: 'proxmoxintegerfield', - fieldLabel: gettext('Port'), - labelWidth: 150, - name: 'vsock-port', - minValue: 0, - value: '4050', - allowBlank: false, - bind: { - hidden: '{!tdxEnabled}', - disabled: '{!attestationEnabled || !tdxEnabled}', - }, - }, - ], -}); - -Ext.define('PVE.qemu.TdxEdit', { - extend: 'Proxmox.window.Edit', - - subject: 'Intel Trust Domain Extension (TDX)', - - items: { - xtype: 'pveTdxInputPanel', - }, - - width: 400, - - initComponent: function () { - let me = this; - - me.callParent(); - - me.load({ - success: function (response) { - let conf = response.result.data; - let intel_tdx = conf['intel-tdx'] || '__default__'; - me.setValues(PVE.Parser.parsePropertyString(intel_tdx, 'type')); - }, - }); - }, -}); -Ext.define('PVE.qemu.USBInputPanel', { - extend: 'Proxmox.panel.InputPanel', - mixins: ['Proxmox.Mixin.CBind'], - - autoComplete: false, - onlineHelp: 'qm_usb_passthrough', - - cbindData: function (initialConfig) { - let me = this; - if (!me.pveSelNode) { - throw 'no pveSelNode given'; - } - - return { nodename: me.pveSelNode.data.node }; - }, - - viewModel: { - data: {}, - }, - - setVMConfig: function (vmconfig) { - var me = this; - me.vmconfig = vmconfig; - let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine); - if (max_usb > PVE.Utils.hardware_counts.usb_old) { - me.down('field[name=usb3]').setDisabled(true); - } - }, - - onGetValues: function (values) { - var me = this; - if (!me.confid) { - let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine); - for (let i = 0; i < max_usb; i++) { - let id = 'usb' + i.toString(); - if (!me.vmconfig[id]) { - me.confid = id; - break; - } - } - } - var val = ''; - var type = me.down('radiofield').getGroupValue(); - switch (type) { - case 'spice': - val = 'spice'; - break; - case 'mapped': - val = `mapping=${values[type]}`; - delete values.mapped; - break; - case 'hostdevice': - case 'port': - val = 'host=' + values[type]; - delete values[type]; - break; - default: - throw 'invalid type selected'; - } - - if (values.usb3) { - delete values.usb3; - val += ',usb3=1'; - } - values[me.confid] = val; - return values; - }, - - items: [ - { - xtype: 'fieldcontainer', - defaultType: 'radiofield', - layout: 'fit', - items: [ - { - name: 'usb', - inputValue: 'spice', - boxLabel: gettext('Spice Port'), - submitValue: false, - checked: true, - }, - { - name: 'usb', - inputValue: 'mapped', - boxLabel: gettext('Use mapped Device'), - reference: 'mapped', - submitValue: false, - }, - { - xtype: 'pveUSBMapSelector', - disabled: true, - name: 'mapped', - cbind: { nodename: '{nodename}' }, - bind: { disabled: '{!mapped.checked}' }, - allowBlank: false, - fieldLabel: gettext('Choose Device'), - labelAlign: 'right', - }, - { - name: 'usb', - inputValue: 'hostdevice', - boxLabel: gettext('Use USB Vendor/Device ID'), - reference: 'hostdevice', - submitValue: false, - }, - { - xtype: 'pveUSBSelector', - disabled: true, - type: 'device', - name: 'hostdevice', - cbind: { pveSelNode: '{pveSelNode}' }, - bind: { disabled: '{!hostdevice.checked}' }, - editable: true, - allowBlank: false, - fieldLabel: gettext('Choose Device'), - labelAlign: 'right', - }, - { - name: 'usb', - inputValue: 'port', - boxLabel: gettext('Use USB Port'), - reference: 'port', - submitValue: false, - }, - { - xtype: 'pveUSBSelector', - disabled: true, - name: 'port', - cbind: { pveSelNode: '{pveSelNode}' }, - bind: { disabled: '{!port.checked}' }, - editable: true, - type: 'port', - allowBlank: false, - fieldLabel: gettext('Choose Port'), - labelAlign: 'right', - }, - { - xtype: 'checkbox', - name: 'usb3', - inputValue: true, - checked: true, - reference: 'usb3', - fieldLabel: gettext('Use USB3'), - }, - ], - }, - ], -}); - -Ext.define('PVE.qemu.USBEdit', { - extend: 'Proxmox.window.Edit', - - vmconfig: undefined, - - isAdd: true, - width: 400, - subject: gettext('USB Device'), - - initComponent: function () { - var me = this; - - me.isCreate = !me.confid; - - var ipanel = Ext.create('PVE.qemu.USBInputPanel', { - confid: me.confid, - pveSelNode: me.pveSelNode, - }); - - Ext.apply(me, { - items: [ipanel], - }); - - me.callParent(); - - me.load({ - success: function (response, options) { - ipanel.setVMConfig(response.result.data); - if (me.isCreate) { - return; - } - - let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'host'); - let port, - hostdevice, - mapped, - usb3 = false; - let usb; - - if (data.host) { - if (/^(0x)?[a-zA-Z0-9]{4}:(0x)?[a-zA-Z0-9]{4}$/.test(data.host)) { - hostdevice = data.host.replace('0x', ''); - usb = 'hostdevice'; - } else if (/^(\d+)-(\d+(\.\d+)*)$/.test(data.host)) { - port = data.host; - usb = 'port'; - } else if (/^spice$/i.test(data.host)) { - usb = 'spice'; - } - } else if (data.mapping) { - mapped = data.mapping; - usb = 'mapped'; - } - - usb3 = data.usb3 ?? false; - - var values = { - usb, - hostdevice, - port, - usb3, - mapped, - }; - - ipanel.setValues(values); - }, - }); - }, -}); -Ext.define('PVE.qemu.VirtiofsInputPanel', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pveVirtiofsInputPanel', - onlineHelp: 'qm_virtiofs', - - insideWizard: false, - - onGetValues: function (values) { - var me = this; - var confid = me.confid; - var params = {}; - delete values.delete; - params[confid] = PVE.Parser.printPropertyString(values, 'dirid'); - return params; - }, - - setSharedfiles: function (confid, data) { - var me = this; - me.confid = confid; - me.virtiofs = data; - me.setValues(me.virtiofs); - }, - initComponent: function () { - let me = this; - - me.nodename = me.pveSelNode.data.node; - if (!me.nodename) { - throw 'no node name specified'; - } - me.items = [ - { - xtype: 'pveDirMapSelector', - name: 'dirid', - fieldLabel: gettext('Directory ID'), - emptyText: gettext('Mapping ID'), - nodename: me.nodename, - allowBlank: false, - }, - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: gettext( - 'Directory Mappings can be managed under Datacenter -> Directory Mappings', - ), - }, - ]; - me.advancedItems = [ - { - xtype: 'proxmoxKVComboBox', - name: 'cache', - fieldLabel: gettext('Cache'), - value: '__default__', - deleteDefaultValue: false, - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (auto)'], - ['auto', 'auto'], - ['always', 'always'], - ['metadata', 'metadata'], - ['never', 'never'], - ], - }, - { - xtype: 'proxmoxcheckbox', - name: 'expose-xattr', - fieldLabel: gettext('xattr Support'), - boxLabel: gettext('Enable support for extended attributes.'), - }, - { - xtype: 'proxmoxcheckbox', - name: 'expose-acl', - fieldLabel: gettext('POSIX ACLs'), - boxLabel: gettext('Implies xattr support.'), - listeners: { - change: function (f, value) { - let xattr = me.down('field[name=expose-xattr]'); - xattr.setDisabled(value); - xattr.setValue(value); - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'direct-io', - fieldLabel: gettext('Allow Direct IO'), - }, - ]; - - me.virtiofs = {}; - me.confid = 'virtiofs0'; - me.callParent(); - }, -}); - -Ext.define('PVE.qemu.VirtiofsEdit', { - extend: 'Proxmox.window.Edit', - - subject: gettext('Virtiofs Filesystem Passthrough'), - width: 450, - - initComponent: function () { - var me = this; - - me.isCreate = !me.confid; - - var ipanel = Ext.create('PVE.qemu.VirtiofsInputPanel', { - confid: me.confid, - pveSelNode: me.pveSelNode, - isCreate: me.isCreate, - }); - - Ext.applyIf(me, { - items: ipanel, - }); - - me.callParent(); - - me.load({ - success: function (response) { - me.conf = response.result.data; - var i, confid; - if (!me.isCreate) { - let value = me.conf[me.confid]; - let virtiofs = PVE.Parser.parsePropertyString(value, 'dirid'); - if (!virtiofs) { - Ext.Msg.alert(gettext('Error'), 'Unable to parse virtiofs options'); - me.close(); - return; - } - ipanel.setSharedfiles(me.confid, virtiofs); - } else { - for (i = 0; i < PVE.Utils.hardware_counts.virtiofs; i++) { - confid = 'virtiofs' + i.toString(); - if (!Ext.isDefined(me.conf[confid])) { - me.confid = confid; - break; - } - } - ipanel.setSharedfiles(me.confid, {}); - } - }, - }); - }, -}); -Ext.define('PVE.sdn.Browser', { - extend: 'PVE.panel.Config', - alias: 'widget.PVE.sdn.Browser', - - onlineHelp: 'chapter_pvesdn', - - initComponent: function () { - let me = this; - - let nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - let sdnId = me.pveSelNode.data.sdn; - if (!sdnId) { - throw 'no sdn ID specified'; - } - - me.items = []; - - Ext.apply(me, { - title: Ext.String.format( - gettext('Zone {0} on node {1}'), - `'${sdnId}'`, - `'${nodename}'`, - ), - hstateid: 'sdntab', - }); - - const caps = Ext.state.Manager.get('GuiCap'); - - me.items.push({ - nodename: nodename, - zone: sdnId, - xtype: 'pveSDNZoneContentPanel', - title: gettext('Content'), - iconCls: 'fa fa-th', - itemId: 'content', - }); - - if (caps.sdn['Permissions.Modify']) { - me.items.push({ - xtype: 'pveACLView', - title: gettext('Permissions'), - iconCls: 'fa fa-unlock', - itemId: 'permissions', - path: `/sdn/zones/${sdnId}`, - }); - } - - if (me.pveSelNode.data['zone-type'] && me.pveSelNode.data['zone-type'] === 'evpn') { - me.items.push({ - nodename: nodename, - zone: sdnId, - xtype: 'pveSDNEvpnZoneIpVrfPanel', - title: gettext('IP-VRF'), - iconCls: 'fa fa-th-list', - itemId: 'ip-vrf', - }); - - me.items.push({ - nodename: nodename, - zone: sdnId, - xtype: 'pveSDNEvpnZoneMacVrfPanel', - title: gettext('MAC-VRFs'), - iconCls: 'fa fa-th-list', - itemId: 'mac-vrfs', - }); - } - - me.callParent(); - }, -}); -Ext.define('PVE.network.Browser', { - extend: 'PVE.panel.Config', - alias: 'widget.PVE.network.Browser', - - initComponent: function () { - let me = this; - let data = me.pveSelNode.data; - - let node = data.node; - if (!node) { - throw 'no node name specified'; - } - - let name = data.network; - if (!name) { - throw 'no name specified'; - } - - let networkType = data['network-type']; - if (!networkType) { - throw 'no type specified'; - } - - me.items = []; - - if (networkType === 'fabric') { - me.onlineHelp = 'pvesdn_config_fabrics'; - - me.items.push({ - nodename: node, - fabricId: name, - protocol: me.pveSelNode.data.protocol, - xtype: 'pveSDNFabricRoutesContentView', - title: gettext('Routes'), - iconCls: 'fa fa-exchange', - itemId: 'routes', - width: '100%', - store: { - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${node}/sdn/fabrics/${name}/routes`, - reader: { - type: 'json', - rootProperty: 'data', - }, - }, - autoLoad: true, - }, - }); - - me.items.push({ - nodename: node, - fabricId: name, - protocol: me.pveSelNode.data.protocol, - xtype: 'pveSDNFabricNeighborsContentView', - title: gettext('Neighbors'), - iconCls: 'fa fa-handshake-o', - itemId: 'neighbors', - width: '100%', - store: { - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${node}/sdn/fabrics/${name}/neighbors`, - reader: { - type: 'json', - rootProperty: 'data', - }, - }, - autoLoad: true, - }, - }); - - me.items.push({ - nodename: node, - fabricId: name, - protocol: me.pveSelNode.data.protocol, - xtype: 'pveSDNFabricInterfacesContentView', - title: gettext('Interfaces'), - iconCls: 'fa fa-upload', - itemId: 'interfaces', - width: '100%', - store: { - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${node}/sdn/fabrics/${name}/interfaces`, - reader: { - type: 'json', - rootProperty: 'data', - }, - }, - autoLoad: true, - }, - }); - } else if (networkType === 'zone') { - const caps = Ext.state.Manager.get('GuiCap'); - - me.items.push({ - nodename: node, - zone: name, - xtype: 'pveSDNZoneContentPanel', - title: gettext('Content'), - iconCls: 'fa fa-th', - itemId: 'content', - }); - - if (caps.sdn['Permissions.Modify']) { - me.items.push({ - xtype: 'pveACLView', - title: gettext('Permissions'), - iconCls: 'fa fa-unlock', - itemId: 'permissions', - path: `/sdn/zones/${name}`, - }); - } - - me.items.push({ - nodename: node, - zone: name, - xtype: 'pveSDNZoneBridgePanel', - title: gettext('Bridges'), - iconCls: 'fa fa-network-wired x-fa-sdn-treelist', - itemId: 'bridges', - }); - - if (data['zone-type'] && data['zone-type'] === 'evpn') { - me.items.push({ - nodename: node, - zone: name, - xtype: 'pveSDNEvpnZoneIpVrfPanel', - title: gettext('IP-VRF'), - iconCls: 'fa fa-th-list', - itemId: 'ip-vrf', - }); - - me.items.push({ - nodename: node, - zone: name, - xtype: 'pveSDNEvpnZoneMacVrfPanel', - title: gettext('MAC-VRFs'), - iconCls: 'fa fa-th-list', - itemId: 'mac-vrfs', - }); - } - } else { - me.items.push({ - xtype: 'container', - title: gettext('Content'), - iconCls: 'fa fa-th', - itemId: 'content', - html: `unknown network type: ${networkType}`, - width: '100%', - }); - } - - Ext.apply(me, { - title: Ext.String.format( - gettext('{0} {1} on node {2}'), - `${networkType}`, - `'${name}'`, - `'${node}'`, - ), - hstateid: 'networktab', - }); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.ControllerView', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveSDNControllerView'], - - onlineHelp: 'pvesdn_config_controllers', - - stateful: true, - stateId: 'grid-sdn-controller', - - createSDNControllerEditWindow: function (type, sid) { - var schema = PVE.Utils.sdncontrollerSchema[type]; - if (!schema || !schema.ipanel) { - throw 'no editor registered for controller type: ' + type; - } - - Ext.create('PVE.sdn.controllers.BaseEdit', { - paneltype: 'PVE.sdn.controllers.' + schema.ipanel, - type: type, - controllerid: sid, - autoShow: true, - listeners: { - destroy: this.reloadStore, - }, - }); - }, - - initComponent: function () { - var me = this; - - var store = new Ext.data.Store({ - model: 'pve-sdn-controller', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/controllers?pending=1', - }, - sorters: { - property: 'controller', - direction: 'ASC', - }, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - let type = rec.data.type, - controller = rec.data.controller; - me.createSDNControllerEditWindow(type, controller); - }; - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/cluster/sdn/controllers/', - callback: () => store.load(), - }); - - // else we cannot dynamically generate the add menu handlers - let addHandleGenerator = function (type) { - return function () { - me.createSDNControllerEditWindow(type); - }; - }; - let addMenuItems = []; - for (const [type, controller] of Object.entries(PVE.Utils.sdncontrollerSchema)) { - if (controller.hideAdd) { - continue; - } - addMenuItems.push({ - text: PVE.Utils.format_sdncontroller_type(type), - iconCls: 'fa fa-fw fa-' + controller.faIcon, - handler: addHandleGenerator(type), - }); - } - - Ext.apply(me, { - store: store, - reloadStore: () => store.load(), - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: addMenuItems, - }), - }, - remove_btn, - edit_btn, - ], - columns: [ - { - header: 'ID', - flex: 2, - sortable: true, - dataIndex: 'controller', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'controller', 1); - }, - }, - { - header: gettext('Type'), - flex: 1, - sortable: true, - dataIndex: 'type', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'type', 1); - }, - }, - { - header: gettext('Node'), - flex: 1, - sortable: true, - dataIndex: 'node', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'node', 1); - }, - }, - { - header: gettext('State'), - width: 100, - dataIndex: 'state', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending_state(rec, value); - }, - }, - ], - listeners: { - activate: () => store.load(), - itemdblclick: run_editor, - }, - }); - store.load(); - me.callParent(); - }, -}); -Ext.define('PVE.sdn.Status', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSDNStatus', - - onlineHelp: 'chapter_pvesdn', - - layout: { - type: 'vbox', - align: 'stretch', - }, - - initComponent: function () { - var me = this; - - me.rstore = Ext.create('Proxmox.data.ObjectStore', { - interval: me.interval, - model: 'pve-sdn-status', - storeid: 'pve-store-' + ++Ext.idSeed, - groupField: 'type', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/resources', - }, - }); - - me.items = [ - { - xtype: 'pveSDNStatusView', - title: gettext('Status'), - rstore: me.rstore, - border: 0, - collapsible: true, - padding: '0 0 20 0', - }, - ]; - - me.callParent(); - me.on('activate', me.rstore.startUpdate); - }, -}); -Ext.define( - 'PVE.sdn.StatusView', - { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNStatusView', - - sortPriority: { - sdn: 1, - node: 2, - status: 3, - }, - - initComponent: function () { - var me = this; - - if (!me.rstore) { - throw 'no rstore given'; - } - - Proxmox.Utils.monStoreErrors(me, me.rstore); - - var store = Ext.create('Proxmox.data.DiffStore', { - rstore: me.rstore, - sortAfterUpdate: true, - sorters: [ - { - sorterFn: function (rec1, rec2) { - var p1 = me.sortPriority[rec1.data.type]; - var p2 = me.sortPriority[rec2.data.type]; - return p1 !== p2 ? (p1 > p2 ? 1 : -1) : 0; - }, - }, - ], - filters: { - property: 'type', - value: 'network', - operator: '==', - }, - }); - - Ext.apply(me, { - store: store, - stateful: false, - tbar: [ - { - text: gettext('Apply'), - handler: function () { - Ext.Msg.show({ - title: gettext('Confirm'), - icon: Ext.Msg.QUESTION, - msg: gettext( - 'Applying pending SDN changes will also apply any pending local node network changes. Proceed?', - ), - buttons: Ext.Msg.YESNO, - callback: function (btn) { - if (btn === 'yes') { - Proxmox.Utils.API2Request({ - url: '/cluster/sdn/', - method: 'PUT', - waitMsgTarget: me, - failure: (response) => - Ext.Msg.alert( - gettext('Error'), - response.htmlStatus, - ), - }); - } - }, - }); - }, - }, - ], - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: 'SDN', - width: 80, - dataIndex: 'network', - }, - { - header: gettext('Node'), - width: 80, - dataIndex: 'node', - }, - { - header: gettext('Type'), - width: 80, - dataIndex: 'network-type', - }, - { - header: gettext('Status'), - width: 80, - flex: 1, - dataIndex: 'status', - }, - ], - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - }, - }, - function () { - Ext.define('pve-sdn-status', { - extend: 'Ext.data.Model', - fields: ['id', 'type', 'node', 'status', 'network'], - idProperty: 'id', - }); - }, -); -Ext.define('PVE.sdn.VnetInputPanel', { - extend: 'Proxmox.panel.InputPanel', - mixins: ['Proxmox.Mixin.CBind'], - - onGetValues: function (values) { - let me = this; - - if (me.isCreate) { - values.type = 'vnet'; - } - - return values; - }, - - initComponent: function () { - let me = this; - - me.callParent(); - me.setZoneType(undefined); - }, - - items: [ - { - xtype: 'pmxDisplayEditField', - name: 'vnet', - cbind: { - editable: '{isCreate}', - }, - maxLength: 8, - flex: 1, - allowBlank: false, - fieldLabel: gettext('Name'), - }, - { - xtype: 'proxmoxtextfield', - name: 'alias', - fieldLabel: gettext('Alias'), - allowBlank: true, - skipEmptyText: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'pveSDNZoneSelector', - fieldLabel: gettext('Zone'), - name: 'zone', - value: '', - allowBlank: false, - listeners: { - change: function () { - let me = this; - - let record = me.findRecordByValue(me.value); - let zoneType = record?.data?.type; - - let panel = me.up('panel'); - panel.setZoneType(zoneType); - }, - }, - }, - { - xtype: 'proxmoxintegerfield', - itemId: 'sdnVnetTagField', - name: 'tag', - minValue: 1, - maxValue: 16777216, - fieldLabel: gettext('Tag'), - allowBlank: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - advancedItems: [ - { - xtype: 'proxmoxcheckbox', - name: 'isolate-ports', - uncheckedValue: null, - checked: false, - fieldLabel: gettext('Isolate Ports'), - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxcheckbox', - itemId: 'sdnVnetVlanAwareField', - name: 'vlanaware', - uncheckedValue: null, - checked: false, - fieldLabel: gettext('VLAN Aware'), - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - - setZoneType: function (zoneType) { - let me = this; - - let tagField = me.down('#sdnVnetTagField'); - if (!zoneType || zoneType === 'simple') { - tagField.setVisible(false); - tagField.setValue(''); - } else { - tagField.setVisible(true); - } - - let vlanField = me.down('#sdnVnetVlanAwareField'); - if (!zoneType || zoneType === 'evpn') { - vlanField.setVisible(false); - vlanField.setValue(''); - } else { - vlanField.setVisible(true); - } - }, -}); - -Ext.define('PVE.sdn.VnetEdit', { - extend: 'Proxmox.window.Edit', - - subject: gettext('VNet'), - - vnet: undefined, - - width: 350, - - initComponent: function () { - var me = this; - - me.isCreate = me.vnet === undefined; - - if (me.isCreate) { - me.url = '/api2/extjs/cluster/sdn/vnets'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/cluster/sdn/vnets/' + me.vnet; - me.method = 'PUT'; - } - - let ipanel = Ext.create('PVE.sdn.VnetInputPanel', { - isCreate: me.isCreate, - }); - - Ext.apply(me, { - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - let values = response.result.data; - ipanel.setValues(values); - }, - }); - } - }, -}); -Ext.define('PVE.sdn.VnetView', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNVnetView', - - onlineHelp: 'pvesdn_config_vnet', - emptyText: gettext('No VNet configured.'), - - stateful: true, - stateId: 'grid-sdn-vnet', - - subnetview_panel: undefined, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-sdn-vnet', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/vnets?pending=1', - }, - sorters: { - property: 'vnet', - direction: 'ASC', - }, - }); - - let reload = () => store.load(); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - - let win = Ext.create('PVE.sdn.VnetEdit', { - autoShow: true, - onlineHelp: 'pvesdn_config_vnet', - vnet: rec.data.vnet, - }); - win.on('destroy', reload); - }; - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/cluster/sdn/vnets/', - callback: reload, - }); - - let set_button_status = function () { - var rec = me.selModel.getSelection()[0]; - - if (!rec || rec.data.state === 'deleted') { - edit_btn.disable(); - remove_btn.disable(); - } - }; - - Ext.apply(me, { - store: store, - reloadStore: reload, - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [ - { - text: gettext('Create'), - handler: function () { - let win = Ext.create('PVE.sdn.VnetEdit', { - autoShow: true, - onlineHelp: 'pvesdn_config_vnet', - type: 'vnet', - }); - win.on('destroy', reload); - }, - }, - remove_btn, - edit_btn, - ], - columns: [ - { - header: 'ID', - flex: 2, - dataIndex: 'vnet', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'vnet', 1); - }, - }, - { - header: gettext('Alias'), - flex: 1, - dataIndex: 'alias', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'alias'); - }, - }, - { - header: gettext('Zone'), - flex: 1, - dataIndex: 'zone', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'zone'); - }, - }, - { - header: gettext('Tag'), - flex: 1, - dataIndex: 'tag', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'tag'); - }, - }, - { - header: gettext('VLAN Aware'), - flex: 1, - dataIndex: 'vlanaware', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'vlanaware'); - }, - }, - { - header: gettext('State'), - width: 100, - dataIndex: 'state', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending_state(rec, value); - }, - }, - ], - listeners: { - activate: reload, - itemdblclick: run_editor, - selectionchange: set_button_status, - show: reload, - select: function (_sm, rec) { - let url = `/cluster/sdn/vnets/${rec.data.vnet}/subnets`; - me.subnetview_panel.setBaseUrl(url); - }, - deselect: function () { - me.subnetview_panel.setBaseUrl(undefined); - }, - }, - }); - store.load(); - me.callParent(); - }, -}); -Ext.define('PVE.sdn.VnetACLAdd', { - extend: 'Proxmox.window.Edit', - alias: ['widget.pveSDNVnetACLAdd'], - - url: '/access/acl', - method: 'PUT', - isAdd: true, - isCreate: true, - - width: 400, - initComponent: function () { - let me = this; - - let items = [ - { - xtype: 'hiddenfield', - name: 'path', - value: me.path, - allowBlank: false, - fieldLabel: gettext('Path'), - }, - ]; - - if (me.aclType === 'group') { - me.subject = gettext('Group Permission'); - items.push({ - xtype: 'pveGroupSelector', - name: 'groups', - fieldLabel: gettext('Group'), - }); - } else if (me.aclType === 'user') { - me.subject = gettext('User Permission'); - items.push({ - xtype: 'pmxUserSelector', - name: 'users', - fieldLabel: gettext('User'), - }); - } else if (me.aclType === 'token') { - me.subject = gettext('API Token Permission'); - items.push({ - xtype: 'pveTokenSelector', - name: 'tokens', - fieldLabel: gettext('API Token'), - }); - } else { - throw 'unknown ACL type'; - } - - items.push({ - xtype: 'pmxRoleSelector', - name: 'roles', - value: 'NoAccess', - fieldLabel: gettext('Role'), - }); - - items.push({ - xtype: 'proxmoxintegerfield', - name: 'vlan', - minValue: 1, - maxValue: 4096, - allowBlank: true, - fieldLabel: 'VLAN', - emptyText: gettext('All'), - }); - - let ipanel = Ext.create('Proxmox.panel.InputPanel', { - items: items, - onlineHelp: 'pveum_permission_management', - onGetValues: function (values) { - if (values.vlan) { - values.path = values.path + '/' + values.vlan; - delete values.vlan; - } - return values; - }, - }); - - Ext.apply(me, { - items: [ipanel], - }); - - me.callParent(); - }, -}); - -Ext.define( - 'PVE.sdn.VnetACLView', - { - extend: 'Ext.grid.GridPanel', - - alias: ['widget.pveSDNVnetACLView'], - - onlineHelp: 'chapter_user_management', - - stateful: true, - stateId: 'grid-acls', - - // use fixed path - path: undefined, - - setPath: function (path) { - let me = this; - - me.path = path; - - if (path === undefined) { - me.down('#groupmenu').setDisabled(true); - me.down('#usermenu').setDisabled(true); - me.down('#tokenmenu').setDisabled(true); - } else { - me.down('#groupmenu').setDisabled(false); - me.down('#usermenu').setDisabled(false); - me.down('#tokenmenu').setDisabled(false); - me.store.load(); - } - }, - initComponent: function () { - let me = this; - - let store = Ext.create('Ext.data.Store', { - model: 'pve-acl', - proxy: { - type: 'proxmox', - url: '/api2/json/access/acl', - }, - sorters: { - property: 'path', - direction: 'ASC', - }, - }); - - store.addFilter( - Ext.create('Ext.util.Filter', { - filterFn: (item) => - item.data.path.replace(/(\/sdn\/zones\/(.*)\/(.*))\/[0-9]*$/, '$1') === - me.path, - }), - ); - - let render_ugid = function (ugid, metaData, record) { - if (record.data.type === 'group') { - return '@' + ugid; - } - - return Ext.String.htmlEncode(ugid); - }; - - let render_vlan = function (path, metaData, record) { - let vlan = 'any'; - const match = path.match(/(\/sdn\/zones\/)(.*)\/(.*)\/([0-9]*)$/); - if (match) { - vlan = match[4]; - } - - return Ext.String.htmlEncode(vlan); - }; - - let columns = [ - { - header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'), - flex: 1, - sortable: true, - renderer: render_ugid, - dataIndex: 'ugid', - }, - { - header: gettext('Role'), - flex: 1, - sortable: true, - dataIndex: 'roleid', - }, - { - header: gettext('VLAN'), - flex: 1, - sortable: true, - renderer: render_vlan, - dataIndex: 'path', - }, - ]; - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let remove_btn = new Proxmox.button.Button({ - text: gettext('Remove'), - disabled: true, - selModel: sm, - confirmMsg: gettext('Are you sure you want to remove this entry'), - handler: function (btn, event, rec) { - var params = { - delete: 1, - path: rec.data.path, - roles: rec.data.roleid, - }; - if (rec.data.type === 'group') { - params.groups = rec.data.ugid; - } else if (rec.data.type === 'user') { - params.users = rec.data.ugid; - } else if (rec.data.type === 'token') { - params.tokens = rec.data.ugid; - } else { - throw 'unknown data type'; - } - - Proxmox.Utils.API2Request({ - url: '/access/acl', - params: params, - method: 'PUT', - waitMsgTarget: me, - callback: () => store.load(), - failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), - }); - }, - }); - - Proxmox.Utils.monStoreErrors(me, store); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: [ - { - text: gettext('Add'), - menu: { - xtype: 'menu', - items: [ - { - text: gettext('Group Permission'), - disabled: !me.path, - itemId: 'groupmenu', - iconCls: 'fa fa-fw fa-group', - handler: function () { - var win = Ext.create('PVE.sdn.VnetACLAdd', { - aclType: 'group', - path: me.path, - }); - win.on('destroy', () => store.load()); - win.show(); - }, - }, - { - text: gettext('User Permission'), - disabled: !me.path, - itemId: 'usermenu', - iconCls: 'fa fa-fw fa-user', - handler: function () { - var win = Ext.create('PVE.sdn.VnetACLAdd', { - aclType: 'user', - path: me.path, - }); - win.on('destroy', () => store.load()); - win.show(); - }, - }, - { - text: gettext('API Token Permission'), - disabled: !me.path, - itemId: 'tokenmenu', - iconCls: 'fa fa-fw fa-user-o', - handler: function () { - let win = Ext.create('PVE.sdn.VnetACLAdd', { - aclType: 'token', - path: me.path, - }); - win.on('destroy', () => store.load()); - win.show(); - }, - }, - ], - }, - }, - remove_btn, - ], - viewConfig: { - trackOver: false, - }, - columns: columns, - listeners: {}, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-acl-vnet', { - extend: 'Ext.data.Model', - fields: [ - 'path', - 'type', - 'ugid', - 'roleid', - { - name: 'propagate', - type: 'boolean', - }, - ], - }); - }, -); -Ext.define('PVE.sdn.Vnet', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSDNVnet', - - title: 'VNet', - - onlineHelp: 'pvesdn_config_vnet', - - initComponent: function () { - var me = this; - - var subnetview_panel = Ext.createWidget('pveSDNSubnetView', { - title: gettext('Subnets'), - region: 'center', - border: false, - }); - - var vnetview_panel = Ext.createWidget('pveSDNVnetView', { - title: 'VNets', - region: 'west', - subnetview_panel: subnetview_panel, - width: '50%', - border: false, - split: true, - }); - - Ext.apply(me, { - layout: 'border', - items: [vnetview_panel, subnetview_panel], - listeners: { - show: function () { - subnetview_panel.fireEvent('show', subnetview_panel); - }, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.SubnetInputPanel', { - extend: 'Proxmox.panel.InputPanel', - mixins: ['Proxmox.Mixin.CBind'], - - onGetValues: function (values) { - let me = this; - - if (me.isCreate) { - values.type = 'subnet'; - values.subnet = values.cidr; - delete values.cidr; - } - - return values; - }, - - items: [ - { - xtype: 'pmxDisplayEditField', - name: 'cidr', - cbind: { - editable: '{isCreate}', - }, - flex: 1, - allowBlank: false, - fieldLabel: gettext('Subnet'), - }, - { - xtype: 'proxmoxtextfield', - name: 'gateway', - vtype: 'IP64Address', - fieldLabel: gettext('Gateway'), - allowBlank: true, - skipEmptyText: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'snat', - uncheckedValue: null, - checked: false, - fieldLabel: 'SNAT', - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'dnszoneprefix', - skipEmptyText: true, - fieldLabel: gettext('DNS Zone Prefix'), - allowBlank: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], -}); - -Ext.define('PVE.sdn.SubnetDhcpRangePanel', { - extend: 'Ext.form.FieldContainer', - mixins: ['Ext.form.field.Field'], - - initComponent: function () { - let me = this; - - me.callParent(); - me.initField(); - }, - - // since value is an array of objects we need to override isEquals here - isEqual: function (value1, value2) { - return JSON.stringify(value1) === JSON.stringify(value2); - }, - - getValue: function () { - let me = this; - let store = me.lookup('grid').getStore(); - - let value = []; - - store.getData().each((item) => { - // needs a deep copy otherwise we run in to ExtJS reference - // shenaningans - value.push({ - 'start-address': item.data['start-address'], - 'end-address': item.data['end-address'], - }); - }); - - return value; - }, - - getSubmitData: function () { - let me = this; - - let data = {}; - - let value = me - .getValue() - .map( - (item) => - `start-address=${item['start-address']},end-address=${item['end-address']}`, - ); - - if (value.length) { - data[me.getName()] = value; - } else if (!me.isCreate) { - data.delete = me.getName(); - } - - return data; - }, - - setValue: function (dhcpRanges) { - let me = this; - let store = me.lookup('grid').getStore(); - - let data = []; - - dhcpRanges.forEach((item) => { - // needs a deep copy otherwise we run in to ExtJS reference - // shenaningans - data.push({ - 'start-address': item['start-address'], - 'end-address': item['end-address'], - }); - }); - - store.setData(data); - }, - - getErrors: function () { - let _me = this; - let errors = []; - - return errors; - }, - - controller: { - xclass: 'Ext.app.ViewController', - - addRange: function () { - let me = this; - me.lookup('grid').getStore().add({}); - - me.getView().checkChange(); - }, - - removeRange: function (field) { - let me = this; - let record = field.getWidgetRecord(); - - me.lookup('grid').getStore().remove(record); - - me.getView().checkChange(); - }, - - onValueChange: function (field, value) { - let me = this; - let record = field.getWidgetRecord(); - let column = field.getWidgetColumn(); - - record.set(column.dataIndex, value); - record.commit(); - - me.getView().checkChange(); - }, - - control: { - 'grid button': { - click: 'removeRange', - }, - field: { - change: 'onValueChange', - }, - }, - }, - - items: [ - { - xtype: 'grid', - reference: 'grid', - scrollable: true, - store: { - fields: ['start-address', 'end-address'], - }, - columns: [ - { - text: gettext('Start Address'), - xtype: 'widgetcolumn', - dataIndex: 'start-address', - flex: 1, - widget: { - xtype: 'textfield', - vtype: 'IP64Address', - }, - }, - { - text: gettext('End Address'), - xtype: 'widgetcolumn', - dataIndex: 'end-address', - flex: 1, - widget: { - xtype: 'textfield', - vtype: 'IP64Address', - }, - }, - { - xtype: 'widgetcolumn', - width: 40, - widget: { - xtype: 'button', - iconCls: 'fa fa-trash-o', - }, - }, - ], - }, - { - xtype: 'container', - layout: { - type: 'hbox', - }, - items: [ - { - xtype: 'button', - text: gettext('Add'), - iconCls: 'fa fa-plus-circle', - handler: 'addRange', - }, - ], - }, - ], -}); - -Ext.define('PVE.sdn.SubnetEdit', { - extend: 'Proxmox.window.Edit', - - subject: gettext('Subnet'), - - subnet: undefined, - - width: 350, - - base_url: undefined, - - bodyPadding: 0, - - initComponent: function () { - var me = this; - - me.isCreate = me.subnet === undefined; - - if (me.isCreate) { - me.url = me.base_url; - me.method = 'POST'; - } else { - me.url = me.base_url + '/' + me.subnet; - me.method = 'PUT'; - } - - let ipanel = Ext.create('PVE.sdn.SubnetInputPanel', { - isCreate: me.isCreate, - title: gettext('General'), - }); - - let dhcpPanel = Ext.create('PVE.sdn.SubnetDhcpRangePanel', { - isCreate: me.isCreate, - title: gettext('DHCP Ranges'), - name: 'dhcp-range', - }); - - Ext.apply(me, { - items: [ - { - xtype: 'tabpanel', - bodyPadding: 10, - items: [ipanel, dhcpPanel], - }, - ], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - me.setValues(response.result.data); - }, - }); - } - }, -}); -Ext.define( - 'PVE.sdn.SubnetView', - { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNSubnetView', - - stateful: true, - stateId: 'grid-sdn-subnet', - - base_url: undefined, - - remove_btn: undefined, - - setBaseUrl: function (url) { - let me = this; - - me.base_url = url; - - if (url === undefined) { - me.store.removeAll(); - me.create_btn.disable(); - } else { - me.remove_btn.baseurl = url + '/'; - me.store.setProxy({ - type: 'proxmox', - url: '/api2/json/' + url + '?pending=1', - }); - me.create_btn.enable(); - me.store.load(); - } - }, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-sdn-subnet', - }); - - let reload = function () { - store.load(); - }; - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - - let win = Ext.create('PVE.sdn.SubnetEdit', { - autoShow: true, - subnet: rec.data.subnet, - base_url: me.base_url, - }); - win.on('destroy', reload); - }; - - me.create_btn = new Proxmox.button.Button({ - text: gettext('Create'), - disabled: true, - handler: function () { - let win = Ext.create('PVE.sdn.SubnetEdit', { - autoShow: true, - base_url: me.base_url, - type: 'subnet', - }); - win.on('destroy', reload); - }, - }); - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - me.remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: me.base_url + '/', - callback: () => store.load(), - }); - - let set_button_status = function () { - var rec = me.selModel.getSelection()[0]; - - if (!rec || rec.data.state === 'deleted') { - edit_btn.disable(); - me.remove_btn.disable(); - } - }; - - Ext.apply(me, { - store: store, - reloadStore: reload, - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [me.create_btn, me.remove_btn, edit_btn], - columns: [ - { - header: gettext('Subnet'), - flex: 2, - dataIndex: 'cidr', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'cidr', 1); - }, - }, - { - header: gettext('Gateway'), - flex: 1, - dataIndex: 'gateway', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'gateway'); - }, - }, - { - header: 'SNAT', - flex: 1, - dataIndex: 'snat', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'snat'); - }, - }, - { - header: gettext('DNS Prefix'), - flex: 1, - dataIndex: 'dnszoneprefix', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'dnszoneprefix'); - }, - }, - { - header: gettext('State'), - width: 100, - dataIndex: 'state', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending_state(rec, value); - }, - }, - ], - listeners: { - activate: reload, - itemdblclick: run_editor, - selectionchange: set_button_status, - }, - }); - - me.callParent(); - - if (me.base_url) { - me.setBaseUrl(me.base_url); // load - } - }, - }, - function () { - Ext.define('pve-sdn-subnet', { - extend: 'Ext.data.Model', - fields: ['cidr', 'gateway', 'snat'], - idProperty: 'subnet', - }); - }, -); -Ext.define( - 'PVE.sdn.ZoneContentView', - { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNZoneContentView', - - stateful: true, - stateId: 'grid-sdnzone-content', - viewConfig: { - trackOver: false, - loadMask: false, - }, - features: [ - { - ftype: 'grouping', - groupHeaderTpl: '{name} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})', - }, - ], - - sub_panel: null, - - columns: [ - { - header: gettext('VNet'), - width: 100, - sortable: true, - dataIndex: 'vnet', - }, - { - header: gettext('Alias'), - width: 300, - sortable: true, - dataIndex: 'alias', - }, - { - header: gettext('Status'), - width: 100, - sortable: true, - dataIndex: 'status', - }, - { - header: gettext('Details'), - flex: 1, - dataIndex: 'statusmsg', - }, - ], - - on_select: function (selectionModel, record) { - // do nothing by default - }, - - on_deselect: function () { - // do nothing by default - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.zone) { - throw 'no zone ID specified'; - } - - var baseurl = '/nodes/' + me.nodename + '/sdn/zones/' + me.zone + '/content'; - if (me.zone === 'localnetwork') { - baseurl = '/nodes/' + me.nodename + '/network?type=any_local_bridge'; - } - var store = Ext.create('Ext.data.Store', { - model: 'pve-sdnzone-content', - groupField: 'content', - proxy: { - type: 'proxmox', - url: '/api2/json' + baseurl, - }, - sorters: { - property: 'vnet', - direction: 'ASC', - }, - }); - - var reload = function () { - store.load(); - }; - - Proxmox.Utils.monStoreErrors(me, store); - Ext.apply(me, { - store: store, - listeners: { - activate: reload, - show: reload, - select: me.on_select, - deselect: me.on_deselect, - }, - }); - store.load(); - me.callParent(); - }, - }, - function () { - Ext.define('pve-sdnzone-content', { - extend: 'Ext.data.Model', - fields: [ - { - name: 'iface', - convert: function (value, record) { - //map local vmbr to vnet - if (record.data.iface) { - record.data.vnet = record.data.iface; - } - return value; - }, - }, - { - name: 'comments', - convert: function (value, record) { - //map local vmbr comments to vnet alias - if (record.data.comments) { - record.data.alias = record.data.comments; - } - return value; - }, - }, - 'vnet', - 'status', - 'statusmsg', - { - name: 'text', - convert: function (value, record) { - // check for volid, because if you click on a grouping header, - // it calls convert (but with an empty volid) - if (value || record.data.vnet === null) { - return value; - } - return PVE.Utils.format_sdnvnet_type(value, {}, record); - }, - }, - ], - idProperty: 'vnet', - }); - }, -); -Ext.define('PVE.sdn.ZoneContentPanel', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSDNZoneContentPanel', - - title: 'VNet', - - onlineHelp: 'pvesdn_config_vnet', - - initComponent: function () { - var me = this; - - var permissions_panel = Ext.createWidget('pveSDNVnetACLView', { - title: gettext('VNet Permissions'), - region: 'center', - border: false, - }); - - var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', { - title: 'VNets', - region: 'west', - sub_panel: permissions_panel, - nodename: me.nodename, - zone: me.zone, - width: '50%', - border: false, - split: true, - - on_select: function (_sm, rec) { - let path = `/sdn/zones/${me.zone}/${rec.data.vnet}`; - permissions_panel.setPath(path); - }, - - on_deselect: function () { - permissions_panel.setPath(undefined); - }, - }); - - Ext.apply(me, { - layout: 'border', - items: [vnetview_panel, permissions_panel], - listeners: { - show: function () { - permissions_panel.fireEvent('show', permissions_panel); - }, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.ZoneBridgePanel', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSDNZoneBridgePanel', - - title: gettext('Bridges'), - onlineHelp: 'pvesdn_zone_plugin_evpn', - - stateful: true, - stateId: 'grid-sdn-zone-bridges', - - initComponent: function () { - var me = this; - let nodename = me.nodename; - - var bridge_ports_panel = Ext.createWidget('pveSDNZoneBridgePortsPanel', { - title: gettext('Bridge Ports'), - region: 'center', - border: false, - }); - - var vnetview_panel = Ext.createWidget('pveSDNZoneBridgeView', { - title: gettext('VNets'), - region: 'west', - nodename: me.nodename, - zone: me.zone, - - width: '50%', - border: false, - split: true, - - on_select: function (_sm, rec) { - let deepCopy = structuredClone(rec.data.ports); - bridge_ports_panel.setPorts(deepCopy, nodename); - }, - - on_deselect: function () { - bridge_ports_panel.clearPorts(); - }, - }); - - Ext.apply(me, { - layout: 'border', - items: [vnetview_panel, bridge_ports_panel], - }); - - me.callParent(); - }, -}); - -Ext.define('ZoneBridgePort', { - extend: 'Ext.data.Model', - fields: ['index', 'name', 'primary_vlan', 'vlans', 'vmid'], -}); - -Ext.define('PVE.sdn.ZoneBridgePortsPanel', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNZoneBridgePortsPanel', - - title: gettext('IP-VRF'), - onlineHelp: 'pvesdn_zone_plugin_evpn', - - stateful: true, - stateId: 'grid-sdn-zone-ports', - - columns: [ - { - text: gettext('Name'), - flex: 2, - sortable: true, - dataIndex: 'name', - }, - { - text: gettext('VMID'), - flex: 1, - sortable: true, - dataIndex: 'vmid', - }, - { - text: gettext('Guest Network Device'), - flex: 1, - sortable: true, - dataIndex: 'index', - }, - { - text: gettext('Primary VLAN'), - flex: 1, - sortable: true, - dataIndex: 'primary_vlan', - }, - { - text: gettext('VLANs'), - flex: 1, - sortable: true, - dataIndex: 'vlans', - }, - ], - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'ZoneBridge', - sorters: [ - { - property: 'vmid', - direction: 'ASC', - }, - { - property: 'index', - direction: 'ASC', - }, - ], - }); - - Ext.apply(me, { - store, - }); - - me.callParent(); - }, - - setPorts: function (ports) { - let me = this; - me.getStore().setData(ports); - }, - - clearPorts: function (ports) { - let me = this; - me.getStore().removeAll(); - }, -}); -Ext.define('ZoneBridge', { - extend: 'Ext.data.Model', - fields: ['name', 'vlan_filtering', 'ports'], -}); - -Ext.define('PVE.sdn.ZoneBridgeView', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNZoneBridgeView', - - stateful: true, - stateId: 'grid-sdnzone-bridges', - - viewConfig: { - trackOver: false, - loadMask: false, - }, - - columns: [ - { - header: gettext('Bridge'), - width: 100, - sortable: true, - dataIndex: 'name', - flex: 1, - }, - { - header: gettext('VLAN-aware'), - width: 300, - sortable: true, - dataIndex: 'vlan_filtering', - flex: 1, - renderer: function (value) { - return value === 1 ? gettext('Yes') : gettext('No'); - }, - }, - ], - - on_select: function (selectionModel, record) { - // do nothing by default - }, - - on_deselect: function () { - // do nothing by default - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - if (!me.zone) { - throw 'no zone ID specified'; - } - - let baseUrl = `/nodes/${me.nodename}/sdn/zones/${me.zone}/bridges`; - - let store = Ext.create('Ext.data.Store', { - model: 'ZoneBridge', - proxy: { - type: 'proxmox', - url: '/api2/json' + baseUrl, - }, - sorters: { - property: 'name', - direction: 'ASC', - }, - }); - - let reload = function () { - store.load(); - }; - - Proxmox.Utils.monStoreErrors(me, store); - Ext.apply(me, { - store: store, - listeners: { - activate: reload, - show: reload, - select: me.on_select, - deselect: me.on_deselect, - }, - }); - store.load(); - me.callParent(); - }, -}); -Ext.define('IpVrfRoute', { - extend: 'Ext.data.Model', - fields: ['ip', 'metric', 'nexthops', 'protocol'], -}); - -Ext.define('PVE.sdn.EvpnZoneIpVrfPanel', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNEvpnZoneIpVrfPanel', - - title: gettext('IP-VRF'), - onlineHelp: 'pvesdn_zone_plugin_evpn', - - stateful: true, - stateId: 'grid-sdn-ip-vrf', - - columns: [ - { - text: gettext('CIDR'), - flex: 2, - sortable: true, - dataIndex: 'ip', - }, - { - text: gettext('Nexthop'), - flex: 3, - dataIndex: 'nexthops', - renderer: (value) => { - if (Ext.isArray(value)) { - return value.join('
    '); - } - return value || ''; - }, - }, - { - text: gettext('Protocol'), - flex: 1, - sortable: true, - dataIndex: 'protocol', - }, - { - text: gettext('Metric'), - flex: 1, - sortable: true, - dataIndex: 'metric', - }, - ], - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'IpVrfRoute', - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${me.nodename}/sdn/zones/${me.zone}/ip-vrf`, - reader: { - type: 'json', - rootProperty: 'data', - }, - }, - sorters: [ - { - property: 'ip', - direction: 'ASC', - }, - { - property: 'nexthop', - direction: 'ASC', - }, - { - property: 'metric', - direction: 'ASC', - }, - ], - autoLoad: true, - }); - - Ext.apply(me, { - store, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.EvpnZoneMacVrfPanel', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSDNEvpnZoneMacVrfPanel', - - title: 'MAC-VRFs', - onlineHelp: 'pvesdn_zone_plugin_evpn', - - initComponent: function () { - var me = this; - let nodename = me.nodename; - - var mac_vrf_panel = Ext.createWidget('pveSDNEvpnZoneMacVrfGridPanel', { - title: gettext('VNet MAC-VRF'), - region: 'center', - border: false, - }); - - var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', { - title: gettext('VNets'), - region: 'west', - sub_panel: mac_vrf_panel, - nodename: me.nodename, - zone: me.zone, - - width: '50%', - border: false, - split: true, - - on_select: function (_sm, rec) { - mac_vrf_panel.setVnet(rec.data.vnet, nodename); - }, - - on_deselect: function () { - mac_vrf_panel.clearVnet(); - }, - }); - - Ext.apply(me, { - layout: 'border', - items: [vnetview_panel, mac_vrf_panel], - }); - - me.callParent(); - }, -}); - -Ext.define('MacVrfRoute', { - extend: 'Ext.data.Model', - fields: ['ip', 'metric', 'nexthops', 'protocol'], -}); - -Ext.define('PVE.sdn.EvpnZoneMacVrfGridPanel', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNEvpnZoneMacVrfGridPanel', - - title: gettext('MAC-VRF'), - - stateful: true, - stateId: 'grid-sdn-mac-vrf', - - columns: [ - { - text: gettext('IP'), - flex: 1, - sortable: true, - dataIndex: 'ip', - }, - { - text: gettext('MAC-Address'), - flex: 1, - sortable: true, - dataIndex: 'mac', - }, - { - text: gettext('Nexthop'), - flex: 1, - dataIndex: 'nexthop', - }, - ], - - clearVnet: function () { - let me = this; - - me.getStore().removeAll(); - }, - - setVnet: function (vnet, node) { - let me = this; - - let store = me.getStore(); - - store.getProxy().setUrl(`/api2/json/nodes/${node}/sdn/vnets/${vnet}/mac-vrf`); - store.load(); - }, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'MacVrfRoute', - proxy: { - type: 'proxmox', - reader: { - type: 'json', - rootProperty: 'data', - }, - }, - sorters: [ - { - property: 'ip', - direction: 'ASC', - }, - { - property: 'mac', - direction: 'ASC', - }, - { - property: 'nexthop', - direction: 'ASC', - }, - ], - }); - - Ext.apply(me, { - store, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.FirewallPanel', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSDNFirewall', - - title: 'VNet', - - onlineHelp: 'pvesdn_firewall_integration', - - initComponent: function () { - let me = this; - - let tabPanel = Ext.create('Ext.TabPanel', { - fullscreen: true, - region: 'center', - border: false, - split: true, - disabled: true, - flex: 2, - items: [ - { - xtype: 'pveFirewallRules', - title: gettext('Rules'), - list_refs_url: '/cluster/firewall/refs', - firewall_type: 'vnet', - }, - { - xtype: 'pveFirewallOptions', - title: gettext('Options'), - fwtype: 'vnet', - }, - ], - }); - - let vnetPanel = Ext.createWidget('pveSDNFirewallVnetView', { - title: 'VNets', - region: 'west', - border: false, - split: true, - forceFit: true, - flex: 1, - tabPanel, - }); - - Ext.apply(me, { - layout: 'border', - items: [vnetPanel, tabPanel], - }); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.FirewallVnetView', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNFirewallVnetView', - - stateful: true, - stateId: 'grid-sdn-vnet-firewall', - - tabPanel: undefined, - - emptyText: gettext('No VNet configured.'), - - getRulesPanel: function () { - let me = this; - return me.tabPanel.items.getAt(0); - }, - - getOptionsPanel: function () { - let me = this; - return me.tabPanel.items.getAt(1); - }, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-sdn-vnet', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/vnets', - }, - sorters: { - property: ['zone', 'vnet'], - direction: 'ASC', - }, - }); - - let reload = () => store.load(); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - Ext.apply(me, { - store: store, - reloadStore: reload, - selModel: sm, - viewConfig: { - trackOver: false, - }, - columns: [ - { - header: 'ID', - flex: 1, - dataIndex: 'vnet', - }, - { - header: gettext('Zone'), - flex: 1, - dataIndex: 'zone', - renderer: Ext.htmlEncode, - }, - { - header: gettext('Alias'), - flex: 1, - dataIndex: 'alias', - renderer: Ext.htmlEncode, - }, - ], - listeners: { - activate: reload, - show: reload, - select: function (_sm, rec) { - me.tabPanel.setDisabled(false); - - me.getRulesPanel().setBaseUrl(`/cluster/sdn/vnets/${rec.id}/firewall/rules`); - me.getOptionsPanel().setBaseUrl( - `/cluster/sdn/vnets/${rec.id}/firewall/options`, - ); - }, - }, - }); - store.load(); - me.callParent(); - }, -}); -Ext.define('PVE.sdn.ZoneView', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveSDNZoneView'], - - onlineHelp: 'pvesdn_config_zone', - emptyText: gettext('No zone configured.'), - - stateful: true, - stateId: 'grid-sdn-zone', - - createSDNEditWindow: function (type, sid) { - let schema = PVE.Utils.sdnzoneSchema[type]; - if (!schema || !schema.ipanel) { - throw 'no editor registered for zone type: ' + type; - } - - Ext.create('PVE.sdn.zones.BaseEdit', { - paneltype: 'PVE.sdn.zones.' + schema.ipanel, - type: type, - zone: sid, - autoShow: true, - listeners: { - destroy: this.reloadStore, - }, - }); - }, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-sdn-zone', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/zones?pending=1', - }, - sorters: { - property: 'zone', - direction: 'ASC', - }, - }); - - let reload = function () { - store.load(); - }; - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - let type = rec.data.type, - zone = rec.data.zone; - - me.createSDNEditWindow(type, zone); - }; - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/cluster/sdn/zones/', - callback: reload, - }); - - let set_button_status = function () { - var rec = me.selModel.getSelection()[0]; - - if (!rec || rec.data.state === 'deleted') { - edit_btn.disable(); - remove_btn.disable(); - } - }; - - // else we cannot dynamically generate the add menu handlers - let addHandleGenerator = function (type) { - return function () { - me.createSDNEditWindow(type); - }; - }; - let addMenuItems = []; - for (const [type, zone] of Object.entries(PVE.Utils.sdnzoneSchema)) { - if (zone.hideAdd) { - continue; - } - addMenuItems.push({ - text: PVE.Utils.format_sdnzone_type(type), - iconCls: 'fa fa-fw fa-' + zone.faIcon, - handler: addHandleGenerator(type), - }); - } - - Ext.apply(me, { - store: store, - reloadStore: reload, - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: addMenuItems, - }), - }, - remove_btn, - edit_btn, - ], - columns: [ - { - header: 'ID', - width: 100, - dataIndex: 'zone', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'zone', 1); - }, - }, - { - header: gettext('Type'), - width: 100, - dataIndex: 'type', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'type', 1); - }, - }, - { - header: 'MTU', - width: 50, - dataIndex: 'mtu', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'mtu'); - }, - }, - { - header: 'IPAM', - flex: 3, - dataIndex: 'ipam', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'ipam'); - }, - }, - { - header: gettext('Domain'), - flex: 3, - dataIndex: 'dnszone', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'dnszone'); - }, - }, - { - header: gettext('DNS'), - flex: 3, - dataIndex: 'dns', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'dns'); - }, - }, - { - header: gettext('Reverse DNS'), - flex: 3, - dataIndex: 'reversedns', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'reversedns'); - }, - }, - { - header: gettext('Nodes'), - flex: 3, - dataIndex: 'nodes', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending(rec, value, 'nodes'); - }, - }, - { - header: gettext('State'), - width: 100, - dataIndex: 'state', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending_state(rec, value); - }, - }, - ], - listeners: { - activate: reload, - itemdblclick: run_editor, - selectionchange: set_button_status, - }, - }); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.IpamEditInputPanel', { - extend: 'Proxmox.panel.InputPanel', - mixins: ['Proxmox.Mixin.CBind'], - - isCreate: false, - - onGetValues: function (values) { - let _me = this; - - if (!values.vmid) { - delete values.vmid; - } - - return values; - }, - - items: [ - { - xtype: 'pmxDisplayEditField', - name: 'vmid', - fieldLabel: 'VMID', - allowBlank: false, - editable: false, - cbind: { - hidden: '{isCreate}', - }, - }, - { - xtype: 'pmxDisplayEditField', - name: 'mac', - fieldLabel: 'MAC', - allowBlank: false, - cbind: { - editable: '{isCreate}', - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'ip', - fieldLabel: gettext('IP Address'), - allowBlank: false, - }, - ], -}); - -Ext.define('PVE.sdn.IpamEdit', { - extend: 'Proxmox.window.Edit', - - subject: gettext('DHCP Mapping'), - width: 350, - - isCreate: false, - mapping: {}, - - url: '/cluster/sdn/vnets', - - submitUrl: function (url, values) { - return `${url}/${values.vnet}/ips`; - }, - - initComponent: function () { - var me = this; - - me.method = me.isCreate ? 'POST' : 'PUT'; - - let ipanel = Ext.create('PVE.sdn.IpamEditInputPanel', { - isCreate: me.isCreate, - }); - - Ext.apply(me, { - items: [ipanel], - }); - - me.callParent(); - - ipanel.setValues(me.mapping); - }, -}); -Ext.define('PVE.sdn.Options', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveSDNOptions', - - title: 'Options', - - layout: { - type: 'vbox', - align: 'stretch', - }, - - onlineHelp: 'pvesdn_config_controllers', - - items: [ - { - xtype: 'pveSDNControllerView', - title: gettext('Controllers'), - flex: 1, - padding: '0 0 20 0', - border: 0, - }, - { - xtype: 'pveSDNIpamView', - title: 'IPAM', - flex: 1, - padding: '0 0 20 0', - border: 0, - }, - { - xtype: 'pveSDNDnsView', - title: 'DNS', - flex: 1, - border: 0, - }, - ], -}); -Ext.define('PVE.panel.SDNControllerBase', { - extend: 'Proxmox.panel.InputPanel', - - type: '', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - delete values.delete; - } else { - delete values.controller; - - for (const [key, value] of Object.entries(values)) { - if (value === null || value === undefined || value === '') { - delete values[key]; - - if (values.delete) { - if (Array.isArray(values.delete)) { - values.delete.push(key); - } else { - values.delete = [values.delete, key]; - } - } else { - values.delete = [key]; - } - } - } - } - - return values; - }, -}); - -Ext.define('PVE.sdn.controllers.BaseEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - me.isCreate = !me.controllerid; - - if (me.isCreate) { - me.url = '/api2/extjs/cluster/sdn/controllers'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/cluster/sdn/controllers/' + me.controllerid; - me.method = 'PUT'; - } - - var ipanel = Ext.create(me.paneltype, { - type: me.type, - isCreate: me.isCreate, - controllerid: me.controllerid, - }); - - Ext.apply(me, { - subject: PVE.Utils.format_sdncontroller_type(me.type), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - var ctypes = values.content || ''; - - values.content = ctypes.split(','); - - if (values.nodes) { - values.nodes = values.nodes.split(','); - } - values.enable = values.disable ? 0 : 1; - - ipanel.setValues(values); - }, - }); - } - }, -}); -Ext.define('PVE.sdn.controllers.EvpnInputPanel', { - extend: 'PVE.panel.SDNControllerBase', - - onlineHelp: 'pvesdn_controller_plugin_evpn', - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'controller', - maxLength: 8, - value: me.controllerid || '', - fieldLabel: 'ID', - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'asn', - minValue: 1, - maxValue: 4294967295, - value: 65000, - fieldLabel: 'ASN #', - allowBlank: false, - }, - { - xtype: 'proxmoxNetworkSelector', - name: 'fabric', - type: 'fabric', - valueField: 'iface', - displayField: 'iface', - fieldLabel: 'SDN Fabric', - allowBlank: true, - deleteEmpty: true, - skipEmptyText: true, - autoSelect: false, - emptyText: gettext('used as underlay network'), - nodename: 'localhost', - listConfig: { - width: 600, - columns: [ - { - header: gettext('Fabric'), - width: 90, - dataIndex: 'iface', - }, - { - header: gettext('CIDR'), - dataIndex: 'cidr', - hideable: false, - flex: 1, - }, - ], - }, - }, - { - xtype: 'proxmoxtextfield', - name: 'peers', - fieldLabel: gettext('Peers'), - allowBlank: true, - deleteEmpty: true, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.controllers.BgpInputPanel', { - extend: 'PVE.panel.SDNControllerBase', - - onlineHelp: 'pvesdn_controller_plugin_evpn', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - values.controller = 'bgp' + values.node; - } else { - delete values.controller; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'pveNodeSelector', - name: 'node', - fieldLabel: gettext('Node'), - multiSelect: false, - autoSelect: false, - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'asn', - minValue: 1, - maxValue: 4294967295, - value: 65000, - fieldLabel: 'ASN #', - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'peers', - fieldLabel: gettext('Peers'), - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'ebgp', - uncheckedValue: 0, - checked: false, - fieldLabel: 'EBGP', - }, - ]; - - me.advancedItems = [ - { - xtype: 'textfield', - name: 'loopback', - fieldLabel: gettext('Loopback Interface'), - }, - { - xtype: 'proxmoxintegerfield', - name: 'ebgp-multihop', - minValue: 1, - maxValue: 100, - fieldLabel: 'ebgp-multihop', - allowBlank: true, - }, - { - xtype: 'proxmoxcheckbox', - name: 'bgp-multipath-as-path-relax', - uncheckedValue: 0, - checked: false, - fieldLabel: 'bgp-multipath-as-path-relax', - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.controllers.IsisInputPanel', { - extend: 'PVE.panel.SDNControllerBase', - - onlineHelp: 'pvesdn_controller_plugin_evpn', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - values.controller = 'isis' + values.node; - } else { - delete values.controller; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'pveNodeSelector', - name: 'node', - fieldLabel: gettext('Node'), - multiSelect: false, - autoSelect: false, - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'isis-domain', - fieldLabel: 'Domain', - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'isis-net', - fieldLabel: 'Network entity title', - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'isis-ifaces', - fieldLabel: gettext('Interfaces'), - allowBlank: false, - }, - ]; - - me.advancedItems = [ - { - xtype: 'textfield', - name: 'loopback', - fieldLabel: gettext('Loopback Interface'), - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.IpamView', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveSDNIpamView'], - - stateful: true, - stateId: 'grid-sdn-ipam', - - createSDNEditWindow: function (type, sid) { - let schema = PVE.Utils.sdnipamSchema[type]; - if (!schema || !schema.ipanel) { - throw 'no editor registered for ipam type: ' + type; - } - - Ext.create('PVE.sdn.ipams.BaseEdit', { - paneltype: 'PVE.sdn.ipams.' + schema.ipanel, - type: type, - ipam: sid, - autoShow: true, - listeners: { - destroy: this.reloadStore, - }, - }); - }, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-sdn-ipam', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/ipams', - }, - sorters: { - property: 'ipam', - direction: 'ASC', - }, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - let type = rec.data.type, - ipam = rec.data.ipam; - me.createSDNEditWindow(type, ipam); - }; - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/cluster/sdn/ipams/', - callback: () => store.load(), - }); - - // else we cannot dynamically generate the add menu handlers - let addHandleGenerator = function (type) { - return function () { - me.createSDNEditWindow(type); - }; - }; - let addMenuItems = []; - for (const [type, ipam] of Object.entries(PVE.Utils.sdnipamSchema)) { - if (ipam.hideAdd) { - continue; - } - addMenuItems.push({ - text: PVE.Utils.format_sdnipam_type(type), - iconCls: 'fa fa-fw fa-' + ipam.faIcon, - handler: addHandleGenerator(type), - }); - } - - Ext.apply(me, { - store: store, - reloadStore: () => store.load(), - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: addMenuItems, - }), - }, - remove_btn, - edit_btn, - ], - columns: [ - { - header: 'ID', - flex: 2, - dataIndex: 'ipam', - renderer: Ext.htmlEncode, - }, - { - header: gettext('Type'), - flex: 1, - dataIndex: 'type', - renderer: PVE.Utils.format_sdnipam_type, - }, - { - header: 'url', - flex: 1, - dataIndex: 'url', - renderer: Ext.htmlEncode, - }, - ], - listeners: { - activate: () => store.load(), - itemdblclick: run_editor, - }, - }); - - store.load(); - me.callParent(); - }, -}); -Ext.define('PVE.panel.SDNIpamBase', { - extend: 'Proxmox.panel.InputPanel', - - type: '', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.ipam; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.callParent(); - }, -}); - -Ext.define('PVE.sdn.ipams.BaseEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - me.isCreate = !me.ipam; - - if (me.isCreate) { - me.url = '/api2/extjs/cluster/sdn/ipams'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/cluster/sdn/ipams/' + me.ipam; - me.method = 'PUT'; - } - - var ipanel = Ext.create(me.paneltype, { - type: me.type, - isCreate: me.isCreate, - ipam: me.ipam, - }); - - Ext.apply(me, { - subject: PVE.Utils.format_sdnipam_type(me.type), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - var ctypes = values.content || ''; - - values.content = ctypes.split(','); - - if (values.nodes) { - values.nodes = values.nodes.split(','); - } - values.enable = values.disable ? 0 : 1; - - ipanel.setValues(values); - }, - }); - } - }, -}); -Ext.define('PVE.sdn.ipams.NetboxInputPanel', { - extend: 'PVE.panel.SDNIpamBase', - - onlineHelp: 'pvesdn_ipam_plugin_netbox', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.ipam; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'ipam', - maxLength: 10, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'token', - fieldLabel: gettext('Token'), - allowBlank: false, - }, - ]; - - me.column2 = [ - { - xtype: 'textfield', - name: 'url', - fieldLabel: gettext('URL'), - allowBlank: false, - }, - ]; - - me.columnB = [ - { - xtype: 'pmxFingerprintField', - name: 'fingerprint', - value: me.isCreate ? null : undefined, - deleteEmpty: !me.isCreate, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.ipams.PVEIpamInputPanel', { - extend: 'PVE.panel.SDNIpamBase', - - onlineHelp: 'pvesdn_ipam_plugin_pveipam', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.ipam; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'ipam', - maxLength: 10, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.ipams.PhpIpamInputPanel', { - extend: 'PVE.panel.SDNIpamBase', - - onlineHelp: 'pvesdn_ipam_plugin_phpipam', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.ipam; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'ipam', - maxLength: 10, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'token', - fieldLabel: gettext('Token'), - allowBlank: false, - }, - ]; - me.column2 = [ - { - xtype: 'textfield', - name: 'url', - fieldLabel: gettext('URL'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'section', - fieldLabel: gettext('Section'), - allowBlank: false, - }, - ]; - - me.columnB = [ - { - xtype: 'pmxFingerprintField', - name: 'fingerprint', - value: me.isCreate ? null : undefined, - deleteEmpty: !me.isCreate, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.DnsView', { - extend: 'Ext.grid.GridPanel', - alias: ['widget.pveSDNDnsView'], - - stateful: true, - stateId: 'grid-sdn-dns', - - createSDNEditWindow: function (type, sid) { - let schema = PVE.Utils.sdndnsSchema[type]; - if (!schema || !schema.ipanel) { - throw 'no editor registered for dns type: ' + type; - } - - Ext.create('PVE.sdn.dns.BaseEdit', { - paneltype: 'PVE.sdn.dns.' + schema.ipanel, - type: type, - dns: sid, - autoShow: true, - listeners: { - destroy: this.reloadStore, - }, - }); - }, - - initComponent: function () { - let me = this; - - let store = new Ext.data.Store({ - model: 'pve-sdn-dns', - proxy: { - type: 'proxmox', - url: '/api2/json/cluster/sdn/dns', - }, - sorters: { - property: 'dns', - direction: 'ASC', - }, - }); - - let sm = Ext.create('Ext.selection.RowModel', {}); - - let run_editor = function () { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - let type = rec.data.type, - dns = rec.data.dns; - - me.createSDNEditWindow(type, dns); - }; - - let edit_btn = new Proxmox.button.Button({ - text: gettext('Edit'), - disabled: true, - selModel: sm, - handler: run_editor, - }); - - let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { - selModel: sm, - baseurl: '/cluster/sdn/dns/', - callback: () => store.load(), - }); - - // else we cannot dynamically generate the add menu handlers - let addHandleGenerator = function (type) { - return function () { - me.createSDNEditWindow(type); - }; - }; - let addMenuItems = []; - for (const [type, dns] of Object.entries(PVE.Utils.sdndnsSchema)) { - if (dns.hideAdd) { - continue; - } - addMenuItems.push({ - text: PVE.Utils.format_sdndns_type(type), - iconCls: 'fa fa-fw fa-' + dns.faIcon, - handler: addHandleGenerator(type), - }); - } - - Ext.apply(me, { - store: store, - reloadStore: () => store.load(), - selModel: sm, - viewConfig: { - trackOver: false, - }, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: addMenuItems, - }), - }, - remove_btn, - edit_btn, - ], - columns: [ - { - header: 'ID', - flex: 2, - dataIndex: 'dns', - renderer: Ext.htmlEncode, - }, - { - header: gettext('Type'), - flex: 1, - dataIndex: 'type', - renderer: PVE.Utils.format_sdndns_type, - }, - { - header: 'url', - flex: 1, - dataIndex: 'url', - renderer: Ext.htmlEncode, - }, - ], - listeners: { - activate: () => store.load(), - itemdblclick: run_editor, - }, - }); - - store.load(); - me.callParent(); - }, -}); -Ext.define('PVE.panel.SDNDnsBase', { - extend: 'Proxmox.panel.InputPanel', - - type: '', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.dns; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.callParent(); - }, -}); - -Ext.define('PVE.sdn.dns.BaseEdit', { - extend: 'Proxmox.window.Edit', - - initComponent: function () { - var me = this; - - me.isCreate = !me.dns; - - if (me.isCreate) { - me.url = '/api2/extjs/cluster/sdn/dns'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/cluster/sdn/dns/' + me.dns; - me.method = 'PUT'; - } - - var ipanel = Ext.create(me.paneltype, { - type: me.type, - isCreate: me.isCreate, - dns: me.dns, - }); - - Ext.apply(me, { - subject: PVE.Utils.format_sdndns_type(me.type), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - var ctypes = values.content || ''; - - values.content = ctypes.split(','); - - if (values.nodes) { - values.nodes = values.nodes.split(','); - } - values.enable = values.disable ? 0 : 1; - - ipanel.setValues(values); - }, - }); - } - }, -}); -Ext.define('PVE.sdn.dns.PowerdnsInputPanel', { - extend: 'PVE.panel.SDNDnsBase', - - onlineHelp: 'pvesdn_dns_plugin_powerdns', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.dns; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'dns', - maxLength: 10, - value: me.dns || '', - fieldLabel: 'ID', - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'key', - fieldLabel: gettext('API Key'), - allowBlank: false, - }, - ]; - me.column2 = [ - { - xtype: 'textfield', - name: 'url', - fieldLabel: 'URL', - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'ttl', - fieldLabel: 'TTL', - allowBlank: true, - }, - ]; - me.columnB = [ - { - xtype: 'pmxFingerprintField', - name: 'fingerprint', - value: me.isCreate ? null : undefined, - deleteEmpty: !me.isCreate, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.panel.SDNZoneBase', { - extend: 'Proxmox.panel.InputPanel', - - type: '', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.zone; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.items.unshift({ - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'zone', - maxLength: 8, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }); - - me.items.push( - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - minValue: 100, - maxValue: 65000, - fieldLabel: 'MTU', - allowBlank: true, - emptyText: 'auto', - deleteEmpty: !me.isCreate, - }, - { - xtype: 'pveNodeSelector', - name: 'nodes', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')', - multiSelect: true, - autoSelect: false, - }, - { - xtype: 'pveSDNIpamSelector', - fieldLabel: gettext('IPAM'), - name: 'ipam', - value: me.ipam || 'pve', - allowBlank: false, - }, - ); - - me.advancedItems = me.advancedItems ?? []; - - me.advancedItems.unshift( - { - xtype: 'pveSDNDnsSelector', - fieldLabel: gettext('DNS Server'), - name: 'dns', - value: '', - allowBlank: true, - }, - { - xtype: 'pveSDNDnsSelector', - fieldLabel: gettext('Reverse DNS Server'), - name: 'reversedns', - value: '', - allowBlank: true, - }, - { - xtype: 'proxmoxtextfield', - name: 'dnszone', - skipEmptyText: true, - fieldLabel: gettext('DNS Zone'), - allowBlank: true, - deleteEmpty: !me.isCreate, - }, - ); - - me.callParent(); - }, -}); - -Ext.define('PVE.sdn.zones.BaseEdit', { - extend: 'Proxmox.window.Edit', - - width: 400, - - initComponent: function () { - var me = this; - - me.isCreate = !me.zone; - - if (me.isCreate) { - me.url = '/api2/extjs/cluster/sdn/zones'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/cluster/sdn/zones/' + me.zone; - me.method = 'PUT'; - } - - var ipanel = Ext.create(me.paneltype, { - type: me.type, - isCreate: me.isCreate, - zone: me.zone, - }); - - Ext.apply(me, { - subject: PVE.Utils.format_sdnzone_type(me.type), - isAdd: true, - items: [ipanel], - }); - - me.callParent(); - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - var values = response.result.data; - var ctypes = values.content || ''; - - values.content = ctypes.split(','); - - if (values.nodes) { - values.nodes = values.nodes.split(','); - } - - if (values.exitnodes) { - values.exitnodes = values.exitnodes.split(','); - } - - values.enable = values.disable ? 0 : 1; - - ipanel.setValues(values); - }, - }); - } - }, -}); -Ext.define('PVE.sdn.zones.EvpnInputPanel', { - extend: 'PVE.panel.SDNZoneBase', - - onlineHelp: 'pvesdn_zone_plugin_evpn', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'pveSDNControllerSelector', - fieldLabel: gettext('Controller'), - name: 'controller', - value: '', - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'vrf-vxlan', - minValue: 1, - maxValue: 16000000, - fieldLabel: 'VRF-VXLAN Tag', - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'mac', - fieldLabel: gettext('VNet MAC Address'), - vtype: 'MacAddress', - allowBlank: true, - emptyText: 'auto', - deleteEmpty: !me.isCreate, - }, - { - xtype: 'pveNodeSelector', - name: 'exitnodes', - fieldLabel: gettext('Exit Nodes'), - multiSelect: true, - autoSelect: false, - }, - { - xtype: 'pveNodeSelector', - name: 'exitnodes-primary', - fieldLabel: gettext('Primary Exit Node'), - multiSelect: false, - autoSelect: false, - skipEmptyText: true, - deleteEmpty: !me.isCreate, - }, - { - xtype: 'proxmoxcheckbox', - name: 'exitnodes-local-routing', - uncheckedValue: null, - checked: false, - fieldLabel: gettext('Exit Nodes Local Routing'), - deleteEmpty: !me.isCreate, - }, - { - xtype: 'proxmoxcheckbox', - name: 'advertise-subnets', - uncheckedValue: null, - checked: false, - fieldLabel: gettext('Advertise Subnets'), - deleteEmpty: !me.isCreate, - }, - { - xtype: 'proxmoxcheckbox', - name: 'disable-arp-nd-suppression', - uncheckedValue: null, - checked: false, - fieldLabel: gettext('Disable ARP-nd Suppression'), - deleteEmpty: !me.isCreate, - }, - { - xtype: 'proxmoxtextfield', - name: 'rt-import', - fieldLabel: gettext('Route Target Import'), - allowBlank: true, - deleteEmpty: !me.isCreate, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.zones.QinQInputPanel', { - extend: 'PVE.panel.SDNZoneBase', - - onlineHelp: 'pvesdn_zone_plugin_qinq', - - onGetValues: function (values) { - let me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.sdn; - } - - return values; - }, - - initComponent: function () { - let me = this; - - me.items = [ - { - xtype: 'textfield', - name: 'bridge', - fieldLabel: 'Bridge', - allowBlank: false, - vtype: 'BridgeName', - minLength: 1, - maxLength: 10, - }, - { - xtype: 'proxmoxintegerfield', - name: 'tag', - minValue: 0, - maxValue: 4096, - fieldLabel: gettext('Service VLAN'), - allowBlank: false, - }, - { - xtype: 'proxmoxKVComboBox', - name: 'vlan-protocol', - fieldLabel: gettext('Service VLAN Protocol'), - allowBlank: true, - value: '802.1q', - comboItems: [ - ['802.1q', '802.1q'], - ['802.1ad', '802.1ad'], - ], - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.zones.SimpleInputPanel', { - extend: 'PVE.panel.SDNZoneBase', - - onlineHelp: 'pvesdn_zone_plugin_simple', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.zone; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.items = []; - me.advancedItems = [ - { - xtype: 'proxmoxcheckbox', - name: 'dhcp', - inputValue: 'dnsmasq', - uncheckedValue: null, - checked: false, - fieldLabel: gettext('automatic DHCP'), - deleteEmpty: !me.isCreate, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.zones.VlanInputPanel', { - extend: 'PVE.panel.SDNZoneBase', - - onlineHelp: 'pvesdn_zone_plugin_vlan', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.zone; - } - - return values; - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'textfield', - name: 'bridge', - fieldLabel: 'Bridge', - allowBlank: false, - vtype: 'BridgeName', - minLength: 1, - maxLength: 10, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.zones.VxlanInputPanel', { - extend: 'PVE.panel.SDNZoneBase', - - onlineHelp: 'pvesdn_zone_plugin_vxlan', - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.type = me.type; - delete values.delete; - } else { - delete values.zone; - - for (const [key, value] of Object.entries(values)) { - if (value === null || value === undefined || value === '') { - delete values[key]; - - if (values.delete) { - if (Array.isArray(values.delete)) { - values.delete.push(key); - } else { - values.delete = [values.delete, key]; - } - } else { - values.delete = [key]; - } - } - } - } - - delete values.mode; - - return values; - }, - - initComponent: function () { - var me = this; - - me.items = [ - { - xtype: 'proxmoxtextfield', - name: 'peers', - fieldLabel: gettext('Peer Address List'), - allowBlank: true, - deleteEmpty: true, - }, - { - xtype: 'proxmoxNetworkSelector', - name: 'fabric', - type: 'fabric', - valueField: 'iface', - displayField: 'iface', - fieldLabel: 'SDN Fabric', - skipEmptyText: true, - allowBlank: true, - deleteEmpty: true, - autoSelect: false, - emptyText: gettext('used as underlay network'), - nodename: 'localhost', - listConfig: { - width: 600, - columns: [ - { - // TRANSLATORS: As in "Network Fabric": https://en.wikipedia.org/wiki/Switched_fabric - header: gettext('Fabric'), - width: 90, - dataIndex: 'iface', - }, - { - header: gettext('CIDR'), - dataIndex: 'cidr', - hideable: false, - flex: 1, - }, - ], - }, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.Fabric.TreeModel', { - extend: 'Ext.data.TreeModel', - idProperty: 'tree_id', -}); - -Ext.define('PVE.sdn.Fabric.View', { - extend: 'Ext.tree.Panel', - - xtype: 'pveSDNFabricView', - - onlineHelp: 'pvesdn_config_fabrics', - - columns: [ - { - xtype: 'treecolumn', - text: gettext('Name'), - dataIndex: 'node_id', - width: 200, - renderer: function (value, metaData, rec) { - if (rec.data.type === 'fabric') { - return PVE.Utils.render_sdn_pending(rec, rec.data.id, 'id'); - } - - return PVE.Utils.render_sdn_pending(rec, value, 'node_id'); - }, - }, - { - text: gettext('Protocol'), - dataIndex: 'protocol', - width: 100, - renderer: function (value, metaData, rec) { - if (rec.data.type === 'fabric') { - const PROTOCOL_DISPLAY_NAMES = { - openfabric: 'OpenFabric', - ospf: 'OSPF', - }; - const displayValue = PROTOCOL_DISPLAY_NAMES[value]; - if (rec.data.state === undefined || rec.data.state === null) { - return Ext.htmlEncode(displayValue); - } - if (rec.data.state === 'deleted') { - if (value === undefined) { - return ' '; - } else { - let encoded = Ext.htmlEncode(displayValue); - return `${encoded}`; - } - } - return Ext.htmlEncode(displayValue); - } - - return ''; - }, - }, - { - text: gettext('IPv4'), - dataIndex: 'ip', - width: 150, - renderer: function (value, metaData, rec) { - if (rec.data.type === 'fabric') { - return PVE.Utils.render_sdn_pending(rec, rec.data.ip_prefix, 'ip_prefix'); - } - - return PVE.Utils.render_sdn_pending(rec, value, 'ip'); - }, - }, - { - text: gettext('IPv6'), - dataIndex: 'ip6', - width: 150, - renderer: function (value, metaData, rec) { - if (rec.data.type === 'fabric') { - return PVE.Utils.render_sdn_pending(rec, rec.data.ip6_prefix, 'ip6_prefix'); - } - - return PVE.Utils.render_sdn_pending(rec, value, 'ip6'); - }, - }, - { - header: gettext('Interfaces'), - width: 200, - dataIndex: 'interface', - renderer: function (value, metaData, rec) { - const interfaces = rec.data.pending?.interfaces || rec.data.interfaces || []; - - let names = interfaces.map((iface) => { - const properties = Proxmox.Utils.parsePropertyString(iface); - return properties.name; - }); - - names.sort(); - const displayValue = Ext.htmlEncode(names.join(', ')); - if (rec.data.state === 'deleted') { - return `${displayValue}`; - } - return displayValue; - }, - }, - { - text: gettext('Action'), - xtype: 'actioncolumn', - dataIndex: 'text', - width: 100, - items: [ - { - handler: 'addActionTreeColumn', - getTip: (_v, _m, _rec) => gettext('Add Node'), - getClass: (_v, _m, { data }) => { - if (data.type === 'fabric') { - return 'fa fa-plus-circle'; - } - - return 'pmx-hidden'; - }, - isActionDisabled: (_v, _r, _c, _i, { data }) => data.type !== 'fabric', - }, - { - tooltip: gettext('Edit'), - handler: 'editAction', - getClass: (_v, _m, { data }) => { - // the fabric type (openfabric, ospf, etc.) cannot be edited - if (data.type && data.state !== 'deleted') { - return 'fa fa-pencil fa-fw'; - } - - return 'pmx-hidden'; - }, - isActionDisabled: (_v, _r, _c, _i, { data }) => !data.type, - }, - { - tooltip: gettext('Delete'), - handler: 'deleteAction', - getClass: (_v, _m, { data }) => { - // the fabric type (openfabric, ospf, etc.) cannot be deleted - if (data.type && data.state !== 'deleted') { - return 'fa critical fa-trash-o'; - } - - return 'pmx-hidden'; - }, - isActionDisabled: (_v, _r, _c, _i, { data }) => !data.type, - }, - ], - }, - { - header: gettext('State'), - width: 100, - dataIndex: 'state', - renderer: function (value, metaData, rec) { - return PVE.Utils.render_sdn_pending_state(rec, value); - }, - }, - ], - - store: { - sorters: ['tree_id'], - model: 'PVE.sdn.Fabric.TreeModel', - }, - - layout: 'fit', - rootVisible: false, - animate: false, - - initComponent: function () { - let me = this; - - let addNodeButton = new Proxmox.button.Button({ - text: gettext('Add Node'), - handler: 'addActionTbar', - disabled: true, - }); - - let setAddNodeButtonStatus = function () { - let selection = me.view.getSelection(); - - if (selection.length === 0) { - return; - } - - let enabled = selection[0].data.type === 'fabric'; - addNodeButton.setDisabled(!enabled); - }; - - Ext.apply(me, { - tbar: [ - { - text: gettext('Add Fabric'), - menu: [ - { - text: 'OpenFabric', - handler: 'addOpenfabric', - }, - { - text: 'OSPF', - handler: 'addOspf', - }, - ], - }, - addNodeButton, - { - xtype: 'proxmoxButton', - text: gettext('Reload'), - handler: function () { - const view = this.up('pveSDNFabricView'); - view.getController().reload(); - }, - }, - ], - listeners: { - selectionchange: setAddNodeButtonStatus, - }, - }); - - me.callParent(); - }, - - controller: { - xclass: 'Ext.app.ViewController', - - reload: function (successCallback) { - let me = this; - - Proxmox.Utils.API2Request({ - url: `/cluster/sdn/fabrics/all?pending=1`, - method: 'GET', - success: function (response, opts) { - let fabrics = {}; - - for (const fabric of response.result.data.fabrics) { - let mergedFabric = { - expanded: true, - type: 'fabric', - iconCls: 'fa fa-road x-fa-treepanel', - children: [], - ...fabric, - ...fabric.pending, - }; - - mergedFabric.tree_id = mergedFabric.id; - - fabrics[mergedFabric.id] = mergedFabric; - } - - for (const node of response.result.data.nodes) { - let mergedNode = { - type: 'node', - iconCls: 'fa fa-desktop x-fa-treepanel', - leaf: true, - ...node, - ...node.pending, - }; - - mergedNode.tree_id = `${mergedNode.fabric_id}_${mergedNode.node_id}`; - - fabrics[mergedNode.fabric_id].children.push(mergedNode); - } - - me.getView().setRootNode({ - name: '__root', - expanded: true, - children: Object.values(fabrics), - }); - - if (successCallback) { - successCallback(); - } - }, - }); - }, - - getFabricEditPanel: function (protocol) { - const FABRIC_PANELS = { - openfabric: 'PVE.sdn.Fabric.OpenFabric.Fabric.Edit', - ospf: 'PVE.sdn.Fabric.Ospf.Fabric.Edit', - }; - - return FABRIC_PANELS[protocol]; - }, - - getNodeEditPanel: function (protocol) { - const NODE_PANELS = { - openfabric: 'PVE.sdn.Fabric.OpenFabric.Node.Edit', - ospf: 'PVE.sdn.Fabric.Ospf.Node.Edit', - }; - - return NODE_PANELS[protocol]; - }, - - addOpenfabric: function () { - let me = this; - me.openFabricAddWindow('openfabric'); - }, - - addOspf: function () { - let me = this; - me.openFabricAddWindow('ospf'); - }, - - openFabricAddWindow: function (protocol) { - let me = this; - - let component = me.getFabricEditPanel(protocol); - - let window = Ext.create(component, { - autoShow: true, - autoLoad: false, - isCreate: true, - }); - - window.on('destroy', () => me.reload()); - }, - - addActionTreeColumn: function (_grid, _rI, _cI, _item, _e, rec) { - this.openNodeAddWindow(rec.data); - }, - - addActionTbar: function () { - let me = this; - - let selection = me.view.getSelection(); - - if (selection.length === 0) { - return; - } - - if (selection[0].data.type === 'fabric') { - me.openNodeAddWindow(selection[0].data); - } - }, - - openNodeAddWindow: function (fabric) { - let me = this; - - let component = me.getNodeEditPanel(fabric.protocol); - - let disallowedNodes = fabric.children - .filter((node) => !node.state || node.state !== 'deleted') - .map((node) => node.node_id); - - Ext.create(component, { - autoShow: true, - fabricId: fabric.id, - protocol: fabric.protocol, - disallowedNodes, - addAnotherCallback: () => { - let successCallback = () => { - let new_fabric = me - .getView() - .getStore() - .findRecord('tree_id', fabric.tree_id); - - me.openNodeAddWindow(new_fabric.data); - }; - - me.reload(successCallback); - }, - apiCallDone: (success, _response, _options) => { - if (success) { - me.reload(); - } - }, - }); - }, - - openFabricEditWindow: function (fabric) { - let me = this; - - let component = me.getFabricEditPanel(fabric.protocol); - - let window = Ext.create(component, { - autoShow: true, - fabricId: fabric.id, - }); - - window.on('destroy', () => me.reload()); - }, - - openNodeEditWindow: function (node) { - let me = this; - - let component = me.getNodeEditPanel(node.protocol); - - let window = Ext.create(component, { - autoShow: true, - fabricId: node.fabric_id, - nodeId: node.node_id, - protocol: node.protocol, - }); - - window.on('destroy', () => me.reload()); - }, - - editAction: function (_grid, _rI, _cI, _item, _e, rec) { - let me = this; - - if (rec.data.type === 'fabric') { - me.openFabricEditWindow(rec.data); - } else if (rec.data.type === 'node') { - me.openNodeEditWindow(rec.data); - } else { - console.warn(`unknown type ${rec.data.type}`); - } - }, - - handleDeleteAction: function (url, message) { - let me = this; - let view = me.getView(); - - Ext.Msg.show({ - title: gettext('Confirm'), - icon: Ext.Msg.WARNING, - message: Ext.htmlEncode(message), - buttons: Ext.Msg.YESNO, - defaultFocus: 'no', - callback: function (btn) { - if (btn !== 'yes') { - return; - } - - Proxmox.Utils.API2Request({ - url, - method: 'DELETE', - waitMsgTarget: view, - failure: function (response, opts) { - Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus); - }, - callback: () => me.reload(), - }); - }, - }); - }, - - deleteAction: function (table, rI, cI, item, e, rec) { - let me = this; - - if (rec.data.type === 'fabric') { - let message = Ext.String.format( - gettext('Are you sure you want to remove the fabric "{0}"?'), - rec.data.id, - ); - - let url = `/cluster/sdn/fabrics/fabric/${rec.data.id}`; - - me.handleDeleteAction(url, message); - } else if (rec.data.type === 'node') { - let message = Ext.String.format( - gettext( - 'Are you sure you want to remove the node "{0}" from the fabric "{1}"?', - ), - rec.data.node_id, - rec.data.fabric_id, - ); - - let url = `/cluster/sdn/fabrics/node/${rec.data.fabric_id}/${rec.data.node_id}`; - - me.handleDeleteAction(url, message); - } else { - console.warn(`unknown type: ${rec.data.type}`); - } - }, - - init: function (view) { - let me = this; - me.reload(); - }, - }, -}); -Ext.define('PVE.sdn.FabricRoutesContentView', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNFabricRoutesContentView', - - columns: [ - { - header: gettext('Route'), - sortable: true, - dataIndex: 'route', - flex: 1, - }, - { - header: gettext('Via'), - sortable: true, - dataIndex: 'via', - renderer: (value) => { - if (Ext.isArray(value)) { - return value.join('
    '); - } - return value || ''; - }, - flex: 1, - }, - ], -}); - -Ext.define('PVE.sdn.FabricNeighborsContentView', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNFabricNeighborsContentView', - - columns: [ - { - header: gettext('Neighbor'), - sortable: true, - dataIndex: 'neighbor', - flex: 1, - }, - { - header: gettext('Status'), - sortable: true, - dataIndex: 'status', - flex: 0.5, - }, - { - header: gettext('Uptime'), - sortable: true, - dataIndex: 'uptime', - flex: 0.5, - }, - ], -}); - -Ext.define('PVE.sdn.FabricInterfacesContentView', { - extend: 'Ext.grid.GridPanel', - alias: 'widget.pveSDNFabricInterfacesContentView', - - columns: [ - { - header: gettext('Name'), - sortable: true, - dataIndex: 'name', - flex: 1, - }, - { - header: gettext('Type'), - sortable: true, - dataIndex: 'type', - flex: 1, - }, - { - header: gettext('State'), - sortable: true, - dataIndex: 'state', - flex: 1, - }, - ], -}); -Ext.define('Pve.sdn.Fabric', { - extend: 'Ext.data.Model', - idProperty: 'name', - fields: ['id', 'protocol', 'ip_prefix', 'ip6_prefix'], -}); - -Ext.define('Pve.sdn.Node', { - extend: 'Ext.data.Model', - idProperty: 'name', - fields: ['fabric_id', 'node_id', 'protocol', 'ip', 'ip6', 'area'], -}); - -Ext.define('Pve.sdn.Interface', { - extend: 'Ext.data.Model', - idProperty: 'name', - fields: ['name', 'ip', 'ip6', 'hello_interval', 'hello_multiplier', 'csnp_interval'], -}); -Ext.define('PVE.sdn.Fabric.InterfacePanel', { - extend: 'Ext.grid.Panel', - mixins: ['Ext.form.field.Field'], - - xtype: 'pveSDNFabricsInterfacePanel', - - nodeInterfaces: {}, - - selModel: { - mode: 'SIMPLE', - type: 'checkboxmodel', - }, - - commonColumns: [ - { - text: gettext('Status'), - dataIndex: 'status', - width: 30, - renderer: function (value, metaData, record) { - let me = this; - - let warning; - let nodeInterface = me.nodeInterfaces[record.data.name]; - - if (!nodeInterface) { - warning = gettext('Interface does not exist on node'); - } else if ( - (nodeInterface.ip && record.data.ip) || - (nodeInterface.ip6 && record.data.ip6) - ) { - warning = gettext( - 'Interface already has an address configured in /etc/network/interfaces', - ); - } else if (nodeInterface.ip || nodeInterface.ip6) { - warning = gettext( - 'Configure the IP in the fabric, instead of /etc/network/interfaces', - ); - } - - if (warning) { - metaData.tdAttr = `data-qtip="${Ext.htmlEncode(Ext.htmlEncode(warning))}"`; - return ``; - } - - return ''; - }, - }, - { - text: gettext('Name'), - dataIndex: 'name', - flex: 2, - }, - { - text: gettext('Type'), - dataIndex: 'type', - flex: 1, - }, - { - text: gettext('IP'), - xtype: 'widgetcolumn', - dataIndex: 'ip', - flex: 1, - widget: { - xtype: 'proxmoxtextfield', - isFormField: false, - bind: { - disabled: '{record.isDisabled}', - }, - }, - }, - ], - - additionalColumns: [], - - controller: { - onValueChange: function (field, value) { - let me = this; - - let record = field.getWidgetRecord(); - - if (!record) { - return; - } - - let column = field.getWidgetColumn(); - - record.set(column.dataIndex, value); - record.commit(); - - me.getView().checkChange(); - }, - - control: { - field: { - change: 'onValueChange', - }, - }, - }, - - listeners: { - selectionchange: function () { - this.checkChange(); - }, - }, - - initComponent: function () { - let me = this; - - Ext.apply(me, { - store: Ext.create('Ext.data.Store', { - model: 'Pve.sdn.Interface', - sorters: { - property: 'name', - direction: 'ASC', - }, - }), - columns: me.commonColumns.concat(me.additionalColumns), - }); - - me.callParent(); - - Proxmox.Utils.monStoreErrors(me, me.getStore(), true); - me.initField(); - }, - - setNodeInterfaces: function (interfaces) { - let me = this; - - let nodeInterfaces = {}; - for (const iface of interfaces) { - nodeInterfaces[iface.name] = iface; - } - - me.nodeInterfaces = nodeInterfaces; - - // reset value when setting new available interfaces - me.setValue([]); - }, - - getValue: function () { - let me = this; - - return me.getSelection().map((rec) => { - let data = {}; - - for (const [key, value] of Object.entries(rec.data)) { - if (value === '' || value === undefined || value === null) { - continue; - } - - if (['type', 'isDisabled'].includes(key)) { - continue; - } - - data[key] = value; - } - - return PVE.Parser.printPropertyString(data); - }); - }, - - setValue: function (value) { - let me = this; - - let store = me.getStore(); - - let selection = me.getSelectionModel(); - selection.deselectAll(); - - let data = structuredClone(me.nodeInterfaces); - - for (const iface of Object.values(data)) { - iface.isDisabled = iface.ip || iface.ip6; - } - - let selected = []; - let fabricInterfaces = structuredClone(value); - - for (let iface of fabricInterfaces) { - iface = PVE.Parser.parsePropertyString(iface); - - selected.push(iface.name); - - // if the fabric configuration defines an interface that was - // previously disabled, re-enable the field to allow editing of the - // value set in the fabric - we show a warning as well if there is - // already an IP configured in /e/n/i - iface.isDisabled = false; - - if (Object.hasOwn(data, iface.name)) { - data[iface.name] = { - ...data[iface.name], - // fabric properties have precedence - ...iface, - }; - } else { - data[iface.name] = iface; - } - } - - store.setData(Object.values(data)); - - let selected_records = selected.map((name) => store.findRecord('name', name)); - selection.select(selected_records); - - me.resetOriginalValue(); - }, - - getSubmitData: function () { - let me = this; - - let name = me.getName(); - let value = me.getValue(); - - if (value.length === 0 && !me.isCreate) { - return { - delete: name, - }; - } - - return { - [name]: value, - }; - }, -}); -Ext.define('PVE.sdn.Fabric.Node.Edit', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - width: 800, - subject: gettext('Node'), - - isCreate: undefined, - - fabricId: undefined, - nodeId: undefined, - protocol: undefined, - - disallowedNodes: [], - - baseUrl: '/cluster/sdn/fabrics/node', - - items: [ - { - xtype: 'textfield', - name: 'digest', - hidden: true, - allowBlank: true, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('IPv4'), - labelWidth: 120, - name: 'ip', - allowBlank: true, - skipEmptyText: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - - additionalItems: [], - - addAnotherCallback: undefined, - - initComponent: function () { - let me = this; - - me.isCreate = me.nodeId === undefined; - me.autoLoad = !me.isCreate; - me.method = me.isCreate ? 'POST' : 'PUT'; - - if (!me.isCreate) { - me.url = `${me.baseUrl}/${me.fabricId}/${me.nodeId}`; - } else { - me.url = `${me.baseUrl}/${me.fabricId}`; - } - - me.nodeSelector = me.getNodeSelector(); - me.interfaceSelector = me.getInterfaceSelector(); - - me.items = [me.nodeSelector, ...me.items, ...me.additionalItems, me.interfaceSelector]; - - me.callParent(); - - if (me.isCreate && me.addAnotherCallback) { - let addAnotherBtn = Ext.create('Ext.Button', { - text: gettext('Create another'), - disabled: !me.isCreate, - handler: function () { - me.apiCallDone = (success, _response, _options) => { - if (success) { - me.addAnotherCallback(); - } - }; - - me.submit(); - }, - }); - - let form = me.formPanel.getForm(); - - let set_button_status = function () { - let valid = form.isValid(); - let dirty = form.isDirty(); - addAnotherBtn.setDisabled(!valid || !(dirty || me.isCreate)); - }; - - form.on('dirtychange', set_button_status); - form.on('validitychange', set_button_status); - - me.getDockedItems()[0].add(addAnotherBtn); - } - }, - - loadNode: async function () { - let me = this; - - if (me.isCreate) { - return {}; - } - - let req = await Proxmox.Async.api2({ - url: `/cluster/sdn/fabrics/node/${me.fabricId}/${me.nodeId}`, - method: 'GET', - }); - - return req.result.data; - }, - - loadNodeInterfaces: async function () { - let me = this; - - let req = await Proxmox.Async.api2({ - url: `/api2/extjs/nodes/${me.nodeId}/network`, - method: 'GET', - }); - - return req.result.data.map((iface) => ({ - name: iface.iface, - type: iface.type, - ip: iface.cidr, - ipv6: iface.cidr6, - })); - }, - - load: function () { - let me = this; - - me.setLoading('fetching node information'); - - Promise.all([me.loadNode(me.fabricId, me.nodeId), me.loadNodeInterfaces(me.nodeId)]) - .catch(Proxmox.Utils.alertResponseFailure) - .then(([node, nodeInterfaces]) => { - me.interfaceSelector.setNodeInterfaces(nodeInterfaces); - me.setValues(node); - }) - .finally(() => { - me.setLoading(false); - }); - }, - - getNodeSelector: function () { - let me = this; - - return Ext.create('PVE.form.NodeSelector', { - xtype: 'pveNodeSelector', - reference: 'nodeselector', - fieldLabel: gettext('Node'), - labelWidth: 120, - name: 'node_id', - allowBlank: false, - disabled: !me.isCreate, - disallowedNodes: me.disallowedNodes, - onlineValidator: me.isCreate, - autoSelect: me.isCreate, - listeners: { - change: function (f, value) { - if (me.isCreate) { - me.nodeId = value; - me.load(); - } - }, - }, - listConfig: { - columns: [ - { - header: gettext('Node'), - dataIndex: 'node', - sortable: true, - hideable: false, - flex: 1, - }, - ], - }, - store: { - fields: ['node'], - proxy: { - type: 'proxmox', - url: '/api2/json/nodes', - }, - sorters: [ - { - property: 'node', - direction: 'ASC', - }, - ], - listeners: { - load: function (store) { - if (store.count() === 0) { - Ext.Msg.alert( - gettext('Add Node'), - gettext('All available nodes are already part of the fabric'), - () => me.destroy(), - ); - } - }, - }, - }, - }); - }, - - getInterfacePanel: function (protocol) { - const INTERFACE_PANELS = { - openfabric: 'PVE.sdn.Fabric.OpenFabric.InterfacePanel', - ospf: 'PVE.sdn.Fabric.Ospf.InterfacePanel', - }; - - return INTERFACE_PANELS[protocol]; - }, - - getInterfaceSelector: function () { - let me = this; - - return Ext.create(me.getInterfacePanel(me.protocol), { - name: 'interfaces', - }); - }, -}); -Ext.define('PVE.sdn.Fabric.Fabric.Edit', { - extend: 'Proxmox.window.Edit', - mixins: ['Proxmox.Mixin.CBind'], - - width: 400, - - fabricId: undefined, - baseUrl: '/cluster/sdn/fabrics/fabric', - - items: [ - { - xtype: 'textfield', - name: 'digest', - hidden: true, - allowBlank: true, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('Name'), - labelWidth: 120, - maxLength: 8, - name: 'id', - cbind: { - disabled: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('IPv4 Prefix'), - labelWidth: 120, - name: 'ip_prefix', - allowBlank: true, - skipEmptyText: true, - cbind: { - disabled: '{!isCreate}', - deleteEmpty: '{!isCreate}', - }, - }, - ], - - additionalItems: [], - - initComponent: function () { - let me = this; - - me.isCreate = me.fabricId === undefined; - me.autoLoad = !me.isCreate; - me.method = me.isCreate ? 'POST' : 'PUT'; - - if (!me.isCreate) { - me.url = `${me.baseUrl}/${me.fabricId}`; - } else { - me.url = me.baseUrl; - } - - me.items.push(...me.additionalItems); - - me.callParent(); - }, -}); -Ext.define('PVE.sdn.Fabric.OpenFabric.InterfacePanel', { - extend: 'PVE.sdn.Fabric.InterfacePanel', - - additionalColumns: [ - { - text: gettext('IPv6'), - xtype: 'widgetcolumn', - dataIndex: 'ip6', - flex: 1, - widget: { - xtype: 'proxmoxtextfield', - isFormField: false, - bind: { - disabled: '{record.isDisabled}', - }, - }, - }, - { - text: gettext('Hello Multiplier'), - xtype: 'widgetcolumn', - dataIndex: 'hello_multiplier', - flex: 1, - hidden: true, - widget: { - xtype: 'proxmoxintegerfield', - isFormField: false, - emptyText: '10', - bind: { - disabled: '{record.isDisabled}', - }, - }, - }, - ], -}); -Ext.define('PVE.sdn.Fabric.OpenFabric.Node.Edit', { - extend: 'PVE.sdn.Fabric.Node.Edit', - protocol: 'openfabric', - - extraRequestParams: { - protocol: 'openfabric', - }, - - additionalItems: [ - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('IPv6'), - labelWidth: 120, - name: 'ip6', - allowBlank: true, - skipEmptyText: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], -}); -Ext.define('PVE.sdn.Fabric.OpenFabric.Fabric.Edit', { - extend: 'PVE.sdn.Fabric.Fabric.Edit', - - subject: 'OpenFabric', - onlineHelp: 'pvesdn_openfabric_fabric', - - viewModel: { - data: { - showIpv6ForwardingHint: false, - }, - }, - - extraRequestParams: { - protocol: 'openfabric', - }, - - additionalItems: [ - { - xtype: 'displayfield', - value: 'To make IPv6 fabrics work, enable global IPv6 forwarding on all nodes. Click on the Help button for more details.', - bind: { - hidden: '{!showIpv6ForwardingHint}', - }, - userCls: 'pmx-hint', - }, - { - xtype: 'proxmoxtextfield', - fieldLabel: gettext('IPv6 Prefix'), - labelWidth: 120, - name: 'ip6_prefix', - allowBlank: true, - skipEmptyText: true, - cbind: { - disabled: '{!isCreate}', - deleteEmpty: '{!isCreate}', - }, - listeners: { - change: function (textbox, value) { - let vm = textbox.up('window').getViewModel(); - vm.set('showIpv6ForwardingHint', !!value); - }, - }, - }, - { - xtype: 'proxmoxintegerfield', - // TRANSLATORS: See https://en.wikipedia.org/wiki/IS-IS#Packet_types - fieldLabel: gettext('Hello Interval'), - labelWidth: 120, - name: 'hello_interval', - allowBlank: true, - emptyText: '3', - skipEmptyText: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - { - xtype: 'proxmoxintegerfield', - // TRANSLATORS: Stands for Complete Sequence Number Packet, see - // https://datatracker.ietf.org/doc/html/draft-ietf-lsr-distoptflood#name-flooding-failures - fieldLabel: gettext('CSNP Interval'), - labelWidth: 120, - name: 'csnp_interval', - allowBlank: true, - emptyText: '10', - skipEmptyText: true, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], -}); -Ext.define('PVE.sdn.Fabric.Ospf.InterfacePanel', { - extend: 'PVE.sdn.Fabric.InterfacePanel', -}); -Ext.define('PVE.sdn.Fabric.Ospf.Node.Edit', { - extend: 'PVE.sdn.Fabric.Node.Edit', - protocol: 'ospf', - - extraRequestParams: { - protocol: 'ospf', - }, -}); -Ext.define('PVE.sdn.Fabric.Ospf.Fabric.Edit', { - extend: 'PVE.sdn.Fabric.Fabric.Edit', - - subject: 'OSPF', - onlineHelp: 'pvesdn_ospf_fabric', - - extraRequestParams: { - protocol: 'ospf', - }, - - additionalItems: [ - { - xtype: 'textfield', - fieldLabel: gettext('Area'), - labelWidth: 120, - name: 'area', - emptyText: '0', - allowBlank: false, - }, - ], -}); -Ext.define( - 'PVE.storage.ContentView', - { - extend: 'Ext.grid.GridPanel', - - alias: 'widget.pveStorageContentView', - - itemdblclick: Ext.emptyFn, - - viewConfig: { - trackOver: false, - loadMask: false, - }, - initComponent: function () { - var me = this; - - if (!me.nodename) { - me.nodename = me.pveSelNode.data.node; - if (!me.nodename) { - throw 'no node name specified'; - } - } - const nodename = me.nodename; - - if (!me.storage) { - me.storage = me.pveSelNode.data.storage; - if (!me.storage) { - throw 'no storage ID specified'; - } - } - const storage = me.storage; - - var content = me.content; - if (!content) { - throw 'no content type specified'; - } - - const baseurl = `/nodes/${nodename}/storage/${storage}/content`; - let store = (me.store = Ext.create('Ext.data.Store', { - model: 'pve-storage-content', - proxy: { - type: 'proxmox', - url: '/api2/json' + baseurl, - extraParams: { - content: content, - }, - }, - sorters: [ - (a, b) => - a.data.text - .toString() - .localeCompare(b.data.text.toString(), undefined, { numeric: true }), - ], - })); - - if (!me.sm) { - me.sm = Ext.create('Ext.selection.RowModel', {}); - } - let sm = me.sm; - - let reload = () => store.load(); - - Proxmox.Utils.monStoreErrors(me, store); - - let tbar = me.tbar ? [...me.tbar] : []; - if (me.useUploadButton) { - tbar.unshift( - { - xtype: 'button', - text: gettext('Upload'), - disabled: !me.enableUploadButton, - handler: function () { - Ext.create('PVE.window.UploadToStorage', { - nodename: nodename, - storage: storage, - content: content, - autoShow: true, - taskDone: () => reload(), - }); - }, - }, - { - xtype: 'button', - text: gettext('Download from URL'), - disabled: !me.enableDownloadUrlButton, - handler: function () { - Ext.create('PVE.window.DownloadUrlToStorage', { - nodename: nodename, - storage: storage, - content: content, - autoShow: true, - taskDone: () => reload(), - }); - }, - }, - '-', - ); - } - if (!me.useCustomRemoveButton) { - tbar.push({ - xtype: 'proxmoxStdRemoveButton', - selModel: sm, - enableFn: (rec) => !rec?.data?.protected, - delay: 5, - callback: () => reload(), - baseurl: baseurl + '/', - }); - } - tbar.push('->', gettext('Search') + ':', ' ', { - xtype: 'textfield', - width: 200, - enableKeyEvents: true, - emptyText: - content === 'backup' ? gettext('Name, Format, Notes') : gettext('Name, Format'), - listeners: { - keyup: { - buffer: 500, - fn: function (field) { - let needle = field.getValue().toLocaleLowerCase(); - store.clearFilter(true); - store.filter([ - { - filterFn: ({ data }) => - data.text?.toLocaleLowerCase().includes(needle) || - data.notes?.toLocaleLowerCase().includes(needle), - }, - ]); - }, - }, - change: function (field, newValue, oldValue) { - if (newValue !== this.originalValue) { - this.triggers.clear.setVisible(true); - } - }, - }, - triggers: { - clear: { - cls: 'pmx-clear-trigger', - weight: -1, - hidden: true, - handler: function () { - this.triggers.clear.setVisible(false); - this.setValue(this.originalValue); - store.clearFilter(); - }, - }, - }, - }); - - let availableColumns = { - name: { - header: gettext('Name'), - flex: 2, - sortable: true, - renderer: PVE.Utils.render_storage_content, - sorter: (a, b) => - a.data.text - .toString() - .localeCompare(b.data.text.toString(), undefined, { numeric: true }), - dataIndex: 'text', - }, - notes: { - header: gettext('Notes'), - flex: 1, - renderer: Ext.htmlEncode, - dataIndex: 'notes', - }, - protected: { - header: ``, - tooltip: gettext('Protected'), - width: 30, - renderer: (v) => - v ? `` : '', - sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0), - dataIndex: 'protected', - }, - date: { - header: gettext('Date'), - width: 150, - dataIndex: 'vdate', - }, - format: { - header: gettext('Format'), - width: 100, - dataIndex: 'format', - }, - size: { - header: gettext('Size'), - width: 100, - renderer: Proxmox.Utils.format_size, - dataIndex: 'size', - }, - }; - - let showColumns = me.showColumns || ['name', 'date', 'format', 'size']; - - Object.keys(availableColumns).forEach(function (key) { - if (!showColumns.includes(key)) { - delete availableColumns[key]; - } - }); - - if (me.extraColumns && typeof me.extraColumns === 'object') { - Object.assign(availableColumns, me.extraColumns); - } - const columns = Object.values(availableColumns); - - Ext.apply(me, { - store, - selModel: sm, - tbar, - columns, - listeners: { - activate: reload, - itemdblclick: (view, record) => me.itemdblclick(view, record), - }, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-storage-content', { - extend: 'Ext.data.Model', - fields: [ - 'volid', - 'content', - 'format', - 'size', - 'used', - 'vmid', - 'channel', - 'id', - 'lun', - 'notes', - 'verification', - { - name: 'text', - convert: function (value, record) { - // check for volid, because if you click on a grouping header, - // it calls convert (but with an empty volid) - if (value || record.data.volid === null) { - return value; - } - return PVE.Utils.render_storage_content(value, {}, record); - }, - }, - { - name: 'vdate', - convert: function (value, record) { - // check for volid, because if you click on a grouping header, - // it calls convert (but with an empty volid) - if (value || record.data.volid === null) { - return value; - } - let t = record.data.content; - if (t === 'backup') { - let v = record.data.volid; - let match = v.match(/(\d{4}_\d{2}_\d{2})-(\d{2}_\d{2}_\d{2})/); - if (match) { - let date = match[1].replace(/_/g, '-'); - let time = match[2].replace(/_/g, ':'); - return date + ' ' + time; - } - } - if (record.data.ctime) { - let ctime = new Date(record.data.ctime * 1000); - return Ext.Date.format(ctime, 'Y-m-d H:i:s'); - } - return ''; - }, - }, - ], - idProperty: 'volid', - }); - }, -); -Ext.define('PVE.storage.BackupView', { - extend: 'PVE.storage.ContentView', - - onlineHelp: 'chapter_vzdump', - - alias: 'widget.pveStorageBackupView', - - showColumns: ['name', 'notes', 'protected', 'date', 'format', 'size'], - - initComponent: function () { - let me = this; - - let nodename = (me.nodename = me.pveSelNode.data.node); - if (!nodename) { - throw 'no node name specified'; - } - - let storage = (me.storage = me.pveSelNode.data.storage); - if (!storage) { - throw 'no storage ID specified'; - } - - me.content = 'backup'; - - let sm = (me.sm = Ext.create('Ext.selection.RowModel', {})); - - let pruneButton = Ext.create('Proxmox.button.Button', { - text: gettext('Prune group'), - disabled: true, - selModel: sm, - setBackupGroup: function (backup) { - if (backup) { - let name = backup.text; - let vmid = backup.vmid; - let format = backup.format; - - let vmtype; - if (name.startsWith('vzdump-lxc-') || format === 'pbs-ct') { - vmtype = 'lxc'; - } else if (name.startsWith('vzdump-qemu-') || format === 'pbs-vm') { - vmtype = 'qemu'; - } - - if (vmid && vmtype) { - this.setText(gettext('Prune group') + ` ${vmtype}/${vmid}`); - this.vmid = vmid; - this.vmtype = vmtype; - this.setDisabled(false); - return; - } - } - this.setText(gettext('Prune group')); - this.vmid = null; - this.vmtype = null; - this.setDisabled(true); - }, - handler: function (b, e, rec) { - Ext.create('PVE.window.Prune', { - autoShow: true, - nodename, - storage, - backup_id: this.vmid, - backup_type: this.vmtype, - listeners: { - destroy: () => me.store.load(), - }, - }); - }, - }); - - me.on('selectionchange', function (model, srecords, eOpts) { - if (srecords.length === 1) { - pruneButton.setBackupGroup(srecords[0].data); - } else { - pruneButton.setBackupGroup(null); - } - }); - - let isPBS = me.pluginType === 'pbs'; - - me.tbar = [ - { - xtype: 'proxmoxButton', - text: gettext('Restore'), - selModel: sm, - disabled: true, - handler: function (b, e, rec) { - let vmtype; - if (PVE.Utils.volume_is_qemu_backup(rec.data)) { - vmtype = 'qemu'; - } else if (PVE.Utils.volume_is_lxc_backup(rec.data)) { - vmtype = 'lxc'; - } else { - return; - } - - Ext.create('PVE.window.Restore', { - autoShow: true, - nodename, - volid: rec.data.volid, - volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec), - vmtype, - isPBS, - listeners: { - destroy: () => me.store.load(), - }, - }); - }, - }, - ]; - if (isPBS) { - me.tbar.push({ - xtype: 'proxmoxButton', - text: gettext('File Restore'), - disabled: true, - selModel: sm, - handler: function (b, e, rec) { - let isVMArchive = PVE.Utils.volume_is_qemu_backup( - rec.data.volid, - rec.data.format, - ); - Ext.create('Proxmox.window.FileBrowser', { - title: gettext('File Restore') + ' - ' + rec.data.text, - listURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/list`, - downloadURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/download`, - extraParams: { - volume: rec.data.volid, - }, - archive: isVMArchive ? 'all' : undefined, - autoShow: true, - }); - }, - }); - } - me.tbar.push( - { - xtype: 'proxmoxButton', - text: gettext('Show Configuration'), - disabled: true, - selModel: sm, - handler: function (b, e, rec) { - Ext.create('PVE.window.BackupConfig', { - autoShow: true, - volume: rec.data.volid, - pveSelNode: me.pveSelNode, - }); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit Notes'), - disabled: true, - selModel: sm, - handler: function (b, e, rec) { - let volid = rec.data.volid; - Ext.create('Proxmox.window.Edit', { - autoShow: true, - autoLoad: true, - width: 600, - height: 400, - resizable: true, - title: gettext('Notes'), - url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`, - layout: 'fit', - items: [ - { - xtype: 'textarea', - layout: 'fit', - name: 'notes', - height: '100%', - }, - ], - listeners: { - destroy: () => me.store.load(), - }, - }); - }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Change Protection'), - disabled: true, - handler: function (button, event, record) { - const volid = record.data.volid; - Proxmox.Utils.API2Request({ - url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`, - method: 'PUT', - waitMsgTarget: me, - params: { protected: record.data.protected ? 0 : 1 }, - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - success: () => { - me.store.load({ - callback: () => sm.fireEvent('selectionchange', sm, [record]), - }); - }, - }); - }, - }, - '-', - pruneButton, - ); - - me.extraColumns = {}; - - if (isPBS) { - me.extraColumns.encrypted = { - header: gettext('Encrypted'), - dataIndex: 'encrypted', - renderer: PVE.Utils.render_backup_encryption, - sorter: { - property: 'encrypted', - transform: (encrypted) => (encrypted ? 1 : 0), - }, - }; - me.extraColumns.verification = { - // TRANSLATORS: The state of the verification task - header: gettext('Verify State'), - dataIndex: 'verification', - renderer: PVE.Utils.render_backup_verification, - sorter: { - property: 'verification', - transform: (value) => { - let state = value?.state ?? 'none'; - let order = PVE.Utils.verificationStateOrder; - return order[state] ?? order.__default__; - }, - }, - }; - } - - me.extraColumns.vmid = { - header: 'VMID', - dataIndex: 'vmid', - hidden: true, - sorter: (a, b) => (a.data.vmid ?? 0) - (b.data.vmid ?? 0), - }; - - me.callParent(); - - me.store.getSorters().clear(); - me.store.setSorters([ - { - property: 'vdate', - direction: 'DESC', - }, - ]); - }, -}); -Ext.define('PVE.panel.StorageBase', { - extend: 'Proxmox.panel.InputPanel', - controller: 'storageEdit', - - type: '', - - onGetValues: function (values) { - let me = this; - - if (me.isCreate) { - values.type = me.type; - } else { - delete values.storage; - } - - values.disable = values.enable ? 0 : 1; - delete values.enable; - - return values; - }, - - initComponent: function () { - let me = this; - - me.column1.unshift({ - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'storage', - value: me.storageId || '', - fieldLabel: 'ID', - vtype: 'StorageId', - allowBlank: false, - }); - - me.column2 = me.column2 || []; - me.column2.unshift( - { - xtype: 'pveNodeSelector', - name: 'nodes', - reference: 'storageNodeRestriction', - disabled: me.storageId === 'local', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')', - multiSelect: true, - autoSelect: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'enable', - checked: true, - uncheckedValue: 0, - fieldLabel: gettext('Enable'), - }, - ); - - const addAdvancedWidget = (widget) => { - me.advancedColumn1 = me.advancedColumn1 || []; - me.advancedColumn2 = me.advancedColumn2 || []; - if (me.advancedColumn2.length < me.advancedColumn1.length) { - me.advancedColumn2.unshift(widget); - } else { - me.advancedColumn1.unshift(widget); - } - }; - - const qemuImgStorageTypes = ['dir', 'btrfs', 'nfs', 'cifs']; - - if (qemuImgStorageTypes.includes(me.type)) { - addAdvancedWidget({ - xtype: 'pvePreallocationSelector', - name: 'preallocation', - fieldLabel: gettext('Preallocation'), - allowBlank: false, - deleteEmpty: !me.isCreate, - value: '__default__', - }); - } - - const externalStorageManagedSnapshotSupport = ['dir', 'nfs', 'cifs', 'lvm']; - - if (externalStorageManagedSnapshotSupport.includes(me.type)) { - addAdvancedWidget({ - xtype: 'proxmoxcheckbox', - name: 'snapshot-as-volume-chain', - // TRANSLATORS: As in "a chain of volumes, each referencing the next one". - boxLabel: gettext('Allow Snapshots as Volume-Chain'), - deleteEmpty: !me.isCreate, - // can only allow to enable this on creation for storages that previously already - // supported qcow2 to avoid ambiguity with existing volumes. - disabled: !me.isCreate && me.type !== 'lvm', - checked: false, - }); - - me.advancedColumnB = me.advancedColumnB || []; - if (me.type === 'lvm') { - me.advancedColumnB.unshift({ - xtype: 'displayfield', - name: 'external-snapshot-hint-lvm', - userCls: 'pmx-hint', - value: gettext('Keep Snapshots as Volume-Chain enabled if qcow2 images exist!'), - }); - } - me.advancedColumnB.unshift({ - xtype: 'displayfield', - name: 'external-snapshot-hint', - userCls: 'pmx-hint', - value: gettext('Snapshots as Volume-Chain are a technology preview.'), - }); - } - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.BaseEdit', { - extend: 'Proxmox.window.Edit', - - apiCallDone: function (success, response, options) { - let me = this; - if (typeof me.ipanel.apiCallDone === 'function') { - me.ipanel.apiCallDone(success, response, options); - } - }, - - initComponent: function () { - let me = this; - - me.isCreate = !me.storageId; - - if (me.isCreate) { - me.url = '/api2/extjs/storage'; - me.method = 'POST'; - } else { - me.url = '/api2/extjs/storage/' + me.storageId; - me.method = 'PUT'; - } - - me.ipanel = Ext.create(me.paneltype, { - title: gettext('General'), - type: me.type, - isCreate: me.isCreate, - storageId: me.storageId, - }); - - Ext.apply(me, { - subject: PVE.Utils.format_storage_type(me.type), - isAdd: true, - bodyPadding: 0, - items: { - xtype: 'tabpanel', - region: 'center', - layout: 'fit', - bodyPadding: 10, - items: [ - me.ipanel, - { - xtype: 'pveBackupJobPrunePanel', - title: gettext('Backup Retention'), - hasMaxProtected: true, - isCreate: me.isCreate, - keepAllDefaultForCreate: true, - showPBSHint: me.ipanel.isPBS, - fallbackHintHtml: gettext( - "Without any keep option, the node's vzdump.conf or `keep-all` is used as fallback for backup jobs", - ), - }, - ], - }, - }); - - if (me.ipanel.extraTabs) { - me.ipanel.extraTabs.forEach((panel) => { - panel.isCreate = me.isCreate; - me.items.items.push(panel); - }); - } - - me.callParent(); - - if (!me.canDoBackups) { - // cannot mask now, not fully rendered until activated - me.down('pmxPruneInputPanel').needMask = true; - } - - if (!me.isCreate) { - me.load({ - success: function (response, options) { - let values = response.result.data; - let ctypes = values.content || ''; - - values.content = ctypes.split(','); - - if (values.nodes) { - values.nodes = values.nodes.split(','); - } - values.enable = values.disable ? 0 : 1; - if (values['prune-backups']) { - let retention = PVE.Parser.parsePropertyString(values['prune-backups']); - delete values['prune-backups']; - Object.assign(values, retention); - } - - me.query('inputpanel').forEach((panel) => { - panel.setValues(values); - }); - }, - }); - } - }, -}); -Ext.define('PVE.storage.Browser', { - extend: 'PVE.panel.Config', - alias: 'widget.PVE.storage.Browser', - - onlineHelp: 'chapter_storage', - - initComponent: function () { - let me = this; - - let nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - let storeid = me.pveSelNode.data.storage; - if (!storeid) { - throw 'no storage ID specified'; - } - - let storageInfo = PVE.data.ResourceStore.findRecord( - 'id', - `storage/${nodename}/${storeid}`, - 0, // startIndex - false, // anyMatch - true, // caseSensitive - true, // exactMatch - ); - let res = storageInfo.data; - let plugin = res.plugintype; - - let isEsxi = plugin === 'esxi'; - - me.items = !isEsxi - ? [ - { - title: gettext('Summary'), - xtype: 'pveStorageSummary', - iconCls: 'fa fa-book', - itemId: 'summary', - }, - ] - : []; - - let caps = Ext.state.Manager.get('GuiCap'); - - Ext.apply(me, { - title: Ext.String.format( - gettext('Storage {0} on node {1}'), - `'${storeid}'`, - `'${nodename}'`, - ), - hstateid: 'storagetab', - }); - - if ( - caps.storage['Datastore.Allocate'] || - caps.storage['Datastore.AllocateSpace'] || - caps.storage['Datastore.Audit'] - ) { - let contents = res.content.split(','); - - let enableUpload = !!caps.storage['Datastore.AllocateTemplate']; - let enableDownloadUrl = - enableUpload && - (!!(caps.nodes['Sys.Audit'] && caps.nodes['Sys.Modify']) || // for backward compat - !!caps.nodes['Sys.AccessNetwork']); // new explicit priv for querying (local) networks - - if (contents.includes('backup')) { - me.items.push({ - xtype: 'pveStorageBackupView', - title: gettext('Backups'), - iconCls: 'fa fa-floppy-o', - itemId: 'contentBackup', - pluginType: plugin, - }); - } - if (contents.includes('images')) { - me.items.push({ - xtype: 'pveStorageImageView', - title: gettext('VM Disks'), - iconCls: 'fa fa-hdd-o', - itemId: 'contentImages', - content: 'images', - pluginType: plugin, - }); - } - if (contents.includes('rootdir')) { - me.items.push({ - xtype: 'pveStorageImageView', - title: gettext('CT Volumes'), - iconCls: 'fa fa-hdd-o lxc', - itemId: 'contentRootdir', - content: 'rootdir', - pluginType: plugin, - }); - } - if (contents.includes('iso')) { - me.items.push({ - xtype: 'pveStorageContentView', - title: gettext('ISO Images'), - iconCls: 'pve-itype-treelist-item-icon-cdrom', - itemId: 'contentIso', - content: 'iso', - pluginType: plugin, - enableUploadButton: enableUpload, - enableDownloadUrlButton: enableDownloadUrl, - useUploadButton: true, - }); - } - if (contents.includes('vztmpl')) { - me.items.push({ - xtype: 'pveStorageTemplateView', - title: gettext('CT Templates'), - iconCls: 'fa fa-file-o lxc', - itemId: 'contentVztmpl', - pluginType: plugin, - enableUploadButton: enableUpload, - enableDownloadUrlButton: enableDownloadUrl, - useUploadButton: true, - }); - } - if (contents.includes('snippets')) { - me.items.push({ - xtype: 'pveStorageContentView', - title: gettext('Snippets'), - iconCls: 'fa fa-file-code-o', - itemId: 'contentSnippets', - content: 'snippets', - pluginType: plugin, - }); - } - if (contents.includes('import')) { - let isImportable = (format) => - ['ova', 'ovf', 'vmx', 'raw', 'qcow2', 'vmdk'].indexOf(format) !== -1; - let createGuestImportWindow = (selection) => { - if (!selection) { - return; - } - - let volumeName = selection.data.volid.replace(/^.*?:/, ''); - - if (['raw', 'vmdk', 'qcow2'].indexOf(selection.data.format) !== -1) { - Ext.create('PVE.qemu.HDImportEdit', { - selection: selection.data.volid, - nodename, - autoShow: true, - }); - } else { - Ext.create('PVE.window.GuestImport', { - storage: storeid, - volumeName, - nodename, - autoShow: true, - }); - } - }; - me.items.push({ - xtype: 'pveStorageContentView', - // each gettext needs to be in a separate line - title: isEsxi ? gettext('Virtual Guests') : gettext('Import'), - iconCls: isEsxi ? 'fa fa-desktop' : 'fa fa-cloud-download', - itemId: 'contentImport', - content: 'import', - useCustomRemoveButton: isEsxi, // hide default remove button for esxi - showColumns: isEsxi ? ['name', 'format'] : ['name', 'size', 'format'], - enableUploadButton: enableUpload && !isEsxi, - enableDownloadUrlButton: enableDownloadUrl && !isEsxi, - useUploadButton: !isEsxi, - itemdblclick: (view, record) => { - if (isImportable(record.data.format)) { - createGuestImportWindow(record); - } - }, - tbar: [ - { - xtype: 'proxmoxButton', - disabled: true, - text: gettext('Import'), - iconCls: 'fa fa-cloud-download', - enableFn: (rec) => isImportable(rec.data.format), - handler: function () { - let grid = this.up('pveStorageContentView'); - let selection = grid.getSelection()?.[0]; - - createGuestImportWindow(selection); - }, - }, - ], - pluginType: plugin, - }); - } - } - - if (caps.storage['Permissions.Modify']) { - me.items.push({ - xtype: 'pveACLView', - title: gettext('Permissions'), - iconCls: 'fa fa-unlock', - itemId: 'permissions', - path: `/storage/${storeid}`, - }); - } - - me.callParent(); - }, -}); -Ext.define('PVE.storage.CIFSScan', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveCIFSScan', - - queryParam: 'server', - - valueField: 'share', - displayField: 'share', - matchFieldWidth: false, - listConfig: { - loadingText: gettext('Scanning...'), - width: 350, - }, - doRawQuery: Ext.emptyFn, - - onTriggerClick: function () { - var me = this; - - if (!me.queryCaching || me.lastQuery !== me.cifsServer) { - me.store.removeAll(); - } - - var params = {}; - if (me.cifsUsername) { - params.username = me.cifsUsername; - } - if (me.cifsPassword) { - params.password = me.cifsPassword; - } - if (me.cifsDomain) { - params.domain = me.cifsDomain; - } - - me.store.getProxy().setExtraParams(params); - me.allQuery = me.cifsServer; - - me.callParent(); - }, - - resetProxy: function () { - let me = this; - me.lastQuery = null; - if (!me.readOnly && !me.disabled) { - if (me.isExpanded) { - me.collapse(); - } - } - }, - - setServer: function (server) { - if (this.cifsServer !== server) { - this.cifsServer = server; - this.resetProxy(); - } - }, - setUsername: function (username) { - if (this.cifsUsername !== username) { - this.cifsUsername = username; - this.resetProxy(); - } - }, - setPassword: function (password) { - if (this.cifsPassword !== password) { - this.cifsPassword = password; - this.resetProxy(); - } - }, - setDomain: function (domain) { - if (this.cifsDomain !== domain) { - this.cifsDomain = domain; - this.resetProxy(); - } - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - - let store = Ext.create('Ext.data.Store', { - fields: ['description', 'share'], - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/scan/cifs', - }, - }); - store.sort('share', 'ASC'); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - - let picker = me.getPicker(); - // don't use monStoreErrors directly, it doesn't copes well with comboboxes - picker.mon(store, 'beforeload', function (s, operation, eOpts) { - picker.unmask(); - delete picker.minHeight; - }); - picker.mon(store.proxy, 'afterload', function (proxy, request, success) { - if (success) { - Proxmox.Utils.setErrorMask(picker, false); - return; - } - let error = request._operation.getError(); - let msg = Proxmox.Utils.getResponseErrorMessage(error); - if (msg) { - picker.minHeight = 100; - } - Proxmox.Utils.setErrorMask(picker, msg); - }); - }, -}); - -Ext.define('PVE.storage.CIFSInputPanel', { - extend: 'PVE.panel.StorageBase', - - onlineHelp: 'storage_cifs', - - onGetValues: function (values) { - let me = this; - - if (values.password?.length === 0) { - delete values.password; - } - if (values.username?.length === 0) { - delete values.username; - } - if (values.subdir?.length === 0) { - delete values.subdir; - } - - return me.callParent([values]); - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'server', - value: '', - fieldLabel: gettext('Server'), - allowBlank: false, - listeners: { - change: function (f, value) { - if (me.isCreate) { - let exportField = me.down('field[name=share]'); - exportField.setServer(value); - } - }, - }, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'username', - value: '', - fieldLabel: gettext('Username'), - emptyText: gettext('Guest user'), - listeners: { - change: function (f, value) { - if (!me.isCreate) { - return; - } - var exportField = me.down('field[name=share]'); - exportField.setUsername(value); - }, - }, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - inputType: 'password', - name: 'password', - value: me.isCreate ? '' : '********', - emptyText: me.isCreate ? gettext('None') : '', - fieldLabel: gettext('Password'), - minLength: 1, - listeners: { - change: function (f, value) { - let exportField = me.down('field[name=share]'); - exportField.setPassword(value); - }, - }, - }, - { - xtype: me.isCreate ? 'pveCIFSScan' : 'displayfield', - name: 'share', - value: '', - fieldLabel: 'Share', - allowBlank: false, - }, - ]; - - me.column2 = [ - { - xtype: 'pveContentTypeSelector', - name: 'content', - value: 'images', - multiSelect: true, - fieldLabel: gettext('Content'), - allowBlank: false, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'domain', - value: me.isCreate ? '' : undefined, - fieldLabel: gettext('Domain'), - allowBlank: true, - listeners: { - change: function (f, value) { - if (me.isCreate) { - let exportField = me.down('field[name=share]'); - exportField.setDomain(value); - } - }, - }, - }, - { - xtype: 'pmxDisplayEditField', - editable: me.isCreate, - name: 'subdir', - fieldLabel: gettext('Subdirectory'), - allowBlank: true, - emptyText: gettext('/some/path'), - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.CephFSInputPanel', { - extend: 'PVE.panel.StorageBase', - controller: 'cephstorage', - - onlineHelp: 'storage_cephfs', - - viewModel: { - type: 'cephstorage', - }, - - setValues: function (values) { - if (values.monhost) { - this.viewModel.set('pveceph', false); - this.lookupReference('pvecephRef').setValue(false); - this.lookupReference('pvecephRef').resetOriginalValue(); - } - this.callParent([values]); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - me.type = 'cephfs'; - - me.column1 = []; - - me.column1.push( - { - xtype: 'textfield', - name: 'monhost', - vtype: 'HostList', - value: '', - bind: { - disabled: '{pveceph}', - submitValue: '{!pveceph}', - hidden: '{pveceph}', - }, - fieldLabel: 'Monitor(s)', - allowBlank: false, - }, - { - xtype: 'displayfield', - reference: 'monhost', - bind: { - disabled: '{!pveceph}', - hidden: '{!pveceph}', - }, - value: '', - fieldLabel: 'Monitor(s)', - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'username', - value: 'admin', - bind: { - disabled: '{pveceph}', - submitValue: '{!pveceph}', - }, - fieldLabel: gettext('User name'), - allowBlank: true, - }, - ); - - if (me.isCreate) { - me.column1.push( - { - xtype: 'pveCephFSSelector', - nodename: me.nodename, - name: 'fs-name', - bind: { - disabled: '{!pveceph}', - submitValue: '{pveceph}', - hidden: '{!pveceph}', - }, - fieldLabel: gettext('FS Name'), - allowBlank: false, - }, - { - xtype: 'textfield', - nodename: me.nodename, - name: 'fs-name', - bind: { - disabled: '{pveceph}', - submitValue: '{!pveceph}', - hidden: '{pveceph}', - }, - fieldLabel: gettext('FS Name'), - }, - ); - } - - me.column2 = [ - { - xtype: 'pveContentTypeSelector', - cts: ['backup', 'iso', 'vztmpl', 'snippets', 'import'], - fieldLabel: gettext('Content'), - name: 'content', - value: 'backup', - multiSelect: true, - allowBlank: false, - }, - ]; - - me.columnB = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'keyring', - fieldLabel: gettext('Secret Key'), - value: me.isCreate ? '' : '***********', - allowBlank: false, - bind: { - hidden: '{pveceph}', - disabled: '{pveceph}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'pveceph', - reference: 'pvecephRef', - bind: { - disabled: '{!pvecephPossible}', - value: '{pveceph}', - }, - checked: true, - uncheckedValue: 0, - submitValue: false, - hidden: !me.isCreate, - boxLabel: gettext('Use Proxmox VE managed hyper-converged cephFS'), - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.DirInputPanel', { - extend: 'PVE.panel.StorageBase', - - onlineHelp: 'storage_directory', - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'path', - value: '', - fieldLabel: gettext('Directory'), - allowBlank: false, - }, - { - xtype: 'pveContentTypeSelector', - name: 'content', - value: 'images', - multiSelect: true, - fieldLabel: gettext('Content'), - allowBlank: false, - }, - ]; - - me.column2 = [ - { - xtype: 'proxmoxcheckbox', - name: 'shared', - uncheckedValue: 0, - fieldLabel: gettext('Shared'), - autoEl: { - tag: 'div', - 'data-qtip': gettext( - 'Enable if the underlying file system is already shared between nodes.', - ), - }, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.ImageView', { - extend: 'PVE.storage.ContentView', - - alias: 'widget.pveStorageImageView', - - initComponent: function () { - var me = this; - - var nodename = (me.nodename = me.pveSelNode.data.node); - if (!me.nodename) { - throw 'no node name specified'; - } - - var storage = (me.storage = me.pveSelNode.data.storage); - if (!me.storage) { - throw 'no storage ID specified'; - } - - if (!me.content || (me.content !== 'images' && me.content !== 'rootdir')) { - throw "content needs to be either 'images' or 'rootdir'"; - } - - var sm = (me.sm = Ext.create('Ext.selection.RowModel', {})); - - var reload = function () { - me.store.load(); - }; - - me.tbar = [ - { - xtype: 'proxmoxButton', - selModel: sm, - text: gettext('Remove'), - disabled: true, - handler: function (btn, event, rec) { - let url = `/nodes/${nodename}/storage/${storage}/content/${rec.data.volid}`; - var vmid = rec.data.vmid; - - var store = PVE.data.ResourceStore; - - if (vmid && store.findVMID(vmid)) { - let guest_node = store.guestNode(vmid); - let storage_path = 'storage/' + nodename + '/' + storage; - - // allow to delete local backed images if a VMID exists on another node. - if (store.storageIsShared(storage_path) || guest_node === nodename) { - let msg = Ext.String.format( - gettext("Cannot remove image, a guest with VMID '{0}' exists!"), - vmid, - ); - msg += - '
    ' + - gettext("You can delete the image from the guest's hardware pane"); - - Ext.Msg.show({ - title: gettext('Cannot remove disk image.'), - icon: Ext.Msg.ERROR, - msg: msg, - }); - return; - } - } - var win = Ext.create('Proxmox.window.SafeDestroy', { - title: Ext.String.format(gettext("Destroy '{0}'"), rec.data.volid), - showProgress: true, - url: url, - item: { type: 'Image', id: vmid }, - taskName: 'unknownimgdel', - }).show(); - win.on('destroy', reload); - }, - }, - ]; - me.useCustomRemoveButton = true; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.IScsiScan', { - extend: 'PVE.form.ComboBoxSetStoreNode', - alias: 'widget.pveIScsiScan', - - queryParam: 'portal', - valueField: 'target', - displayField: 'target', - matchFieldWidth: false, - allowBlank: false, - - listConfig: { - width: 350, - columns: [ - { - dataIndex: 'target', - flex: 1, - }, - ], - emptyText: PVE.Utils.renderNotFound(gettext('iSCSI Target')), - }, - - config: { - apiSuffix: '/scan/iscsi', - }, - - showNodeSelector: true, - - reload: function () { - let me = this; - if (!me.isDisabled()) { - me.getStore().load(); - } - }, - - setPortal: function (portal) { - let me = this; - me.portal = portal; - me.getStore().getProxy().setExtraParams({ portal }); - me.reload(); - }, - - setNodeName: function (value) { - let me = this; - me.callParent([value]); - me.reload(); - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - - let store = Ext.create('Ext.data.Store', { - fields: ['target', 'portal'], - proxy: { - type: 'proxmox', - url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, - }, - }); - store.sort('target', 'ASC'); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.IScsiInputPanel', { - extend: 'PVE.panel.StorageBase', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'storage_open_iscsi', - - onGetValues: function (values) { - let me = this; - - values.content = values.luns ? 'images' : 'none'; - delete values.luns; - - return me.callParent([values]); - }, - - setValues: function (values) { - values.luns = values.content.indexOf('images') !== -1; - this.callParent([values]); - }, - - column1: [ - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{isCreate}', - }, - - name: 'portal', - value: '', - fieldLabel: 'Portal', - allowBlank: false, - - editConfig: { - listeners: { - change: { - fn: function (f, value) { - let panel = this.up('inputpanel'); - let exportField = panel.lookup('iScsiTargetScan'); - if (exportField) { - exportField.setDisabled(!value); - exportField.setPortal(value); - exportField.setValue(''); - } - }, - buffer: 500, - }, - }, - }, - }, - { - cbind: { - xtype: (get) => (get('isCreate') ? 'pveIScsiScan' : 'displayfield'), - readOnly: '{!isCreate}', - disabled: '{isCreate}', - }, - - name: 'target', - value: '', - fieldLabel: gettext('Target'), - allowBlank: false, - reference: 'iScsiTargetScan', - listeners: { - nodechanged: function (value) { - this.up('inputpanel').lookup('storageNodeRestriction').setValue(value); - }, - }, - }, - ], - - column2: [ - { - xtype: 'checkbox', - name: 'luns', - checked: true, - fieldLabel: gettext('Use LUNs directly'), - }, - ], -}); -Ext.define('PVE.storage.VgSelector', { - extend: 'PVE.form.ComboBoxSetStoreNode', - alias: 'widget.pveVgSelector', - valueField: 'vg', - displayField: 'vg', - queryMode: 'local', - editable: false, - - listConfig: { - columns: [ - { - dataIndex: 'vg', - flex: 1, - }, - ], - emptyText: PVE.Utils.renderNotFound('VGs'), - }, - - config: { - apiSuffix: '/scan/lvm', - }, - - showNodeSelector: true, - - setNodeName: function (value) { - let me = this; - me.callParent([value]); - me.getStore().load(); - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - - let store = Ext.create('Ext.data.Store', { - autoLoad: {}, // true, - fields: ['vg', 'size', 'free'], - proxy: { - type: 'proxmox', - url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, - }, - }); - - store.sort('vg', 'ASC'); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.BaseStorageSelector', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveBaseStorageSelector', - - existingGroupsText: gettext('Existing volume groups'), - queryMode: 'local', - editable: false, - value: '', - valueField: 'storage', - displayField: 'text', - initComponent: function () { - let me = this; - - let store = Ext.create('Ext.data.Store', { - autoLoad: { - addRecords: true, - params: { - type: 'iscsi', - }, - }, - fields: [ - 'storage', - 'type', - 'content', - { - name: 'text', - convert: function (value, record) { - if (record.data.storage) { - return record.data.storage + ' (iSCSI)'; - } else { - return me.existingGroupsText; - } - }, - }, - ], - proxy: { - type: 'proxmox', - url: '/api2/json/storage/', - }, - }); - - store.loadData([{ storage: '' }], true); - - store.sort('storage', 'ASC'); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.LunSelector', { - extend: 'PVE.form.FileSelector', - alias: 'widget.pveStorageLunSelector', - - nodename: 'localhost', - storageContent: 'images', - allowBlank: false, - - initComponent: function () { - let me = this; - - if (!PVE.Utils.isStandaloneNode()) { - me.errorHeight = 140; - Ext.apply(me.listConfig ?? {}, { - tbar: { - xtype: 'toolbar', - items: [ - { - xtype: 'pveStorageScanNodeSelector', - autoSelect: false, - fieldLabel: gettext('Node to scan'), - listeners: { - change: (_field, value) => me.setNodename(value), - }, - }, - ], - }, - emptyText: me.listConfig?.emptyText ?? PVE.Utils.renderNotFound(gettext('Volume')), - }); - } - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.LVMInputPanel', { - extend: 'PVE.panel.StorageBase', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'storage_lvm', - - column1: [ - { - xtype: 'pveBaseStorageSelector', - name: 'basesel', - fieldLabel: gettext('Base storage'), - cbind: { - disabled: '{!isCreate}', - hidden: '{!isCreate}', - }, - submitValue: false, - listeners: { - change: function (f, value) { - let me = this; - let vgField = me.up('inputpanel').lookup('volumeGroupSelector'); - let vgNameField = me.up('inputpanel').lookup('vgName'); - let baseField = me.up('inputpanel').lookup('lunSelector'); - - vgField.setVisible(!value); - vgField.setDisabled(!!value); - - baseField.setVisible(!!value); - baseField.setDisabled(!value); - baseField.setStorage(value); - - vgNameField.setVisible(!!value); - vgNameField.setDisabled(!value); - }, - }, - }, - { - xtype: 'pveStorageLunSelector', - name: 'base', - fieldLabel: gettext('Base volume'), - reference: 'lunSelector', - hidden: true, - disabled: true, - }, - { - xtype: 'pveVgSelector', - name: 'vgname', - fieldLabel: gettext('Volume group'), - reference: 'volumeGroupSelector', - cbind: { - disabled: '{!isCreate}', - hidden: '{!isCreate}', - }, - allowBlank: false, - listeners: { - nodechanged: function (value) { - this.up('inputpanel').lookup('storageNodeRestriction').setValue(value); - }, - }, - }, - { - name: 'vgname', - fieldLabel: gettext('Volume group'), - reference: 'vgName', - cbind: { - xtype: (get) => (get('isCreate') ? 'textfield' : 'displayfield'), - hidden: '{isCreate}', - disabled: '{isCreate}', - }, - value: '', - allowBlank: false, - }, - { - xtype: 'pveContentTypeSelector', - cts: ['images', 'rootdir'], - fieldLabel: gettext('Content'), - name: 'content', - value: ['images', 'rootdir'], - multiSelect: true, - allowBlank: false, - }, - ], - - column2: [ - { - xtype: 'proxmoxcheckbox', - name: 'shared', - uncheckedValue: 0, - fieldLabel: gettext('Shared'), - autoEl: { - tag: 'div', - 'data-qtip': gettext('Enable if the LVM is located on a shared LUN.'), - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'saferemove', - uncheckedValue: 0, - fieldLabel: gettext('Wipe Removed Volumes'), - }, - ], -}); -Ext.define('PVE.storage.TPoolSelector', { - extend: 'PVE.form.ComboBoxSetStoreNode', - alias: 'widget.pveTPSelector', - - queryParam: 'vg', - valueField: 'lv', - displayField: 'lv', - editable: false, - allowBlank: false, - - listConfig: { - emptyText: PVE.Utils.renderNotFound('Thin-Pool'), - columns: [ - { - dataIndex: 'lv', - flex: 1, - }, - ], - }, - - config: { - apiSuffix: '/scan/lvmthin', - }, - - reload: function () { - let me = this; - if (!me.isDisabled()) { - me.getStore().load(); - } - }, - - setVG: function (myvg) { - let me = this; - me.vg = myvg; - me.getStore().getProxy().setExtraParams({ vg: myvg }); - me.reload(); - }, - - setNodeName: function (value) { - let me = this; - me.callParent([value]); - me.reload(); - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - - let store = Ext.create('Ext.data.Store', { - fields: ['lv'], - proxy: { - type: 'proxmox', - url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, - }, - }); - - store.sort('lv', 'ASC'); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.BaseVGSelector', { - extend: 'PVE.form.ComboBoxSetStoreNode', - alias: 'widget.pveBaseVGSelector', - - valueField: 'vg', - displayField: 'vg', - queryMode: 'local', - editable: false, - allowBlank: false, - - listConfig: { - columns: [ - { - dataIndex: 'vg', - flex: 1, - }, - ], - }, - - showNodeSelector: true, - - config: { - apiSuffix: '/scan/lvm', - }, - - setNodeName: function (value) { - let me = this; - me.callParent([value]); - me.getStore().load(); - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - - let store = Ext.create('Ext.data.Store', { - autoLoad: {}, - fields: ['vg', 'size', 'free'], - proxy: { - type: 'proxmox', - url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, - }, - }); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.LvmThinInputPanel', { - extend: 'PVE.panel.StorageBase', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'storage_lvmthin', - - column1: [ - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{isCreate}', - }, - - name: 'vgname', - fieldLabel: gettext('Volume group'), - - editConfig: { - xtype: 'pveBaseVGSelector', - listeners: { - nodechanged: function (value) { - let panel = this.up('inputpanel'); - panel.lookup('thinPoolSelector').setNodeName(value); - panel.lookup('storageNodeRestriction').setValue(value); - }, - change: function (f, value) { - let vgField = this.up('inputpanel').lookup('thinPoolSelector'); - if (vgField && !f.isDisabled()) { - vgField.setDisabled(!value); - vgField.setVG(value); - vgField.setValue(''); - } - }, - }, - }, - }, - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{isCreate}', - }, - - name: 'thinpool', - fieldLabel: gettext('Thin Pool'), - allowBlank: false, - - editConfig: { - xtype: 'pveTPSelector', - reference: 'thinPoolSelector', - disabled: true, - }, - }, - { - xtype: 'pveContentTypeSelector', - cts: ['images', 'rootdir'], - fieldLabel: gettext('Content'), - name: 'content', - value: ['images', 'rootdir'], - multiSelect: true, - allowBlank: false, - }, - ], -}); -Ext.define('PVE.storage.BTRFSInputPanel', { - extend: 'PVE.panel.StorageBase', - - onlineHelp: 'storage_btrfs', - - initComponent: function () { - let me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'path', - value: '', - fieldLabel: gettext('Path'), - allowBlank: false, - }, - { - xtype: 'pveContentTypeSelector', - name: 'content', - value: ['images', 'rootdir'], - multiSelect: true, - fieldLabel: gettext('Content'), - allowBlank: false, - }, - ]; - - me.columnB = [ - { - xtype: 'displayfield', - userCls: 'pmx-hint', - value: `BTRFS integration is currently a technology preview.`, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.NFSScan', { - extend: 'Ext.form.field.ComboBox', - alias: 'widget.pveNFSScan', - - queryParam: 'server', - - valueField: 'path', - displayField: 'path', - matchFieldWidth: false, - listConfig: { - loadingText: gettext('Scanning...'), - width: 350, - }, - doRawQuery: function () { - // do nothing - }, - - onTriggerClick: function () { - var me = this; - - if (!me.queryCaching || me.lastQuery !== me.nfsServer) { - me.store.removeAll(); - } - - me.allQuery = me.nfsServer; - - me.callParent(); - }, - - setServer: function (server) { - var me = this; - - me.nfsServer = server; - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - - var store = Ext.create('Ext.data.Store', { - fields: ['path', 'options'], - proxy: { - type: 'proxmox', - url: '/api2/json/nodes/' + me.nodename + '/scan/nfs', - }, - }); - - store.sort('path', 'ASC'); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.NFSInputPanel', { - extend: 'PVE.panel.StorageBase', - - onlineHelp: 'storage_nfs', - - options: [], - - onGetValues: function (values) { - var me = this; - - var i; - var res = []; - for (i = 0; i < me.options.length; i++) { - let item = me.options[i]; - if (!item.match(/^vers=(.*)$/)) { - res.push(item); - } - } - if (values.nfsversion && values.nfsversion !== '__default__') { - res.push('vers=' + values.nfsversion); - } - delete values.nfsversion; - values.options = res.join(','); - if (values.options === '') { - delete values.options; - if (!me.isCreate) { - values.delete = 'options'; - } - } - - return me.callParent([values]); - }, - - setValues: function (values) { - var me = this; - if (values.options) { - me.options = values.options.split(','); - me.options.forEach(function (item) { - var match = item.match(/^vers=(.*)$/); - if (match) { - values.nfsversion = match[1]; - } - }); - } - return me.callParent([values]); - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'server', - value: '', - fieldLabel: gettext('Server'), - allowBlank: false, - listeners: { - change: function (f, value) { - if (me.isCreate) { - let exportField = me.down('field[name=export]'); - exportField.setServer(value); - exportField.setValue(''); - } - }, - }, - }, - { - xtype: me.isCreate ? 'pveNFSScan' : 'displayfield', - name: 'export', - value: '', - fieldLabel: 'Export', - allowBlank: false, - }, - { - xtype: 'pveContentTypeSelector', - name: 'content', - value: 'images', - multiSelect: true, - fieldLabel: gettext('Content'), - allowBlank: false, - }, - ]; - - me.advancedColumn2 = [ - { - xtype: 'proxmoxKVComboBox', - fieldLabel: gettext('NFS Version'), - name: 'nfsversion', - value: '__default__', - deleteEmpty: false, - comboItems: [ - ['__default__', Proxmox.Utils.defaultText], - ['3', '3'], - ['4', '4'], - ['4.1', '4.1'], - ['4.2', '4.2'], - ], - }, - ]; - - me.callParent(); - }, -}); -/*global QRCode*/ -Ext.define('PVE.Storage.PBSKeyShow', { - extend: 'Ext.window.Window', - xtype: 'pvePBSKeyShow', - mixins: ['Proxmox.Mixin.CBind'], - - width: 600, - modal: true, - resizable: false, - title: gettext('Important: Save your Encryption Key'), - - // avoid that esc closes this by mistake, force user to more manual action - onEsc: Ext.emptyFn, - closable: false, - - items: [ - { - xtype: 'form', - layout: { - type: 'vbox', - align: 'stretch', - }, - bodyPadding: 10, - border: false, - defaults: { - anchor: '100%', - border: false, - padding: '10 0 0 0', - }, - items: [ - { - xtype: 'textfield', - fieldLabel: gettext('Key'), - labelWidth: 80, - inputId: 'encryption-key-value', - cbind: { - value: '{key}', - }, - editable: false, - }, - { - xtype: 'component', - html: - gettext( - 'Keep your encryption key safe, but easily accessible for disaster recovery.', - ) + - '
    ' + - gettext('We recommend the following safe-keeping strategy:'), - }, - { - xtyp: 'container', - layout: 'hbox', - items: [ - { - xtype: 'component', - html: '1. ' + gettext('Save the key in your password manager.'), - flex: 1, - }, - { - xtype: 'button', - text: gettext('Copy Key'), - iconCls: 'fa fa-clipboard x-btn-icon-el-default-toolbar-small', - cls: 'x-btn-default-toolbar-small proxmox-inline-button', - width: 110, - handler: function (b) { - document.getElementById('encryption-key-value').select(); - document.execCommand('copy'); - }, - }, - ], - }, - { - xtype: 'container', - layout: 'hbox', - items: [ - { - xtype: 'component', - html: - '2. ' + - gettext( - 'Download the key to a USB (pen) drive, placed in secure vault.', - ), - flex: 1, - }, - { - xtype: 'button', - text: gettext('Download'), - iconCls: 'fa fa-download x-btn-icon-el-default-toolbar-small', - cls: 'x-btn-default-toolbar-small proxmox-inline-button', - width: 110, - handler: function (b) { - let win = this.up('window'); - - let pveID = PVE.ClusterName || window.location.hostname; - let name = `pve-${pveID}-storage-${win.sid}.enc`; - - let hiddenElement = document.createElement('a'); - hiddenElement.href = 'data:attachment/text,' + encodeURI(win.key); - hiddenElement.target = '_blank'; - hiddenElement.download = name; - hiddenElement.click(); - }, - }, - ], - }, - { - xtype: 'container', - layout: 'hbox', - items: [ - { - xtype: 'component', - html: - '3. ' + - gettext('Print as paperkey, laminated and placed in secure vault.'), - flex: 1, - }, - { - xtype: 'button', - text: gettext('Print Key'), - iconCls: 'fa fa-print x-btn-icon-el-default-toolbar-small', - cls: 'x-btn-default-toolbar-small proxmox-inline-button', - width: 110, - handler: function (b) { - let win = this.up('window'); - win.paperkey(win.key); - }, - }, - ], - }, - ], - }, - { - xtype: 'component', - border: false, - padding: '10 10 10 10', - userCls: 'pmx-hint', - html: gettext( - 'Please save the encryption key - losing it will render any backup created with it unusable', - ), - }, - ], - buttons: [ - { - text: gettext('Close'), - handler: function (b) { - let win = this.up('window'); - win.close(); - }, - }, - ], - paperkey: function (keyString) { - let me = this; - - const key = JSON.parse(keyString); - - const qrwidth = 500; - let qrdiv = document.createElement('div'); - let qrcode = new QRCode(qrdiv, { - width: qrwidth, - height: qrwidth, - correctLevel: QRCode.CorrectLevel.H, - }); - qrcode.makeCode(keyString); - - let shortKeyFP = ''; - if (key.fingerprint) { - shortKeyFP = PVE.Utils.render_pbs_fingerprint(key.fingerprint); - } - - let printFrame = document.createElement('iframe'); - Object.assign(printFrame.style, { - position: 'fixed', - right: '0', - bottom: '0', - width: '0', - height: '0', - border: '0', - }); - const prettifiedKey = JSON.stringify(key, null, 2); - const keyQrBase64 = qrdiv.children[0].toDataURL('image/png'); - const html = ` -

    Encryption Key - Storage '${me.sid}' (${shortKeyFP})

    -

    ------BEGIN PROXMOX BACKUP KEY----- -${prettifiedKey} ------END PROXMOX BACKUP KEY-----

    -
    - `; - - printFrame.src = 'data:text/html;base64,' + btoa(html); - document.body.appendChild(printFrame); - me.on('destroy', () => document.body.removeChild(printFrame)); - }, -}); - -Ext.define('PVE.panel.PBSEncryptionKeyTab', { - extend: 'Proxmox.panel.InputPanel', - xtype: 'pvePBSEncryptionKeyTab', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'storage_pbs_encryption', - - onGetValues: function (form) { - let values = {}; - if (form.cryptMode === 'upload') { - values['encryption-key'] = form['crypt-key-upload']; - } else if (form.cryptMode === 'autogenerate') { - values['encryption-key'] = 'autogen'; - } else if (form.cryptMode === 'none') { - if (!this.isCreate) { - values.delete = ['encryption-key']; - } - } - return values; - }, - - setValues: function (values) { - let me = this; - let vm = me.getViewModel(); - - let cryptKeyInfo = values['encryption-key']; - if (cryptKeyInfo) { - let icon = ' '; - if (cryptKeyInfo.match(/^[a-fA-F0-9]{2}:/)) { - // new style fingerprint - let shortKeyFP = PVE.Utils.render_pbs_fingerprint(cryptKeyInfo); - values['crypt-key-fp'] = - icon + `${gettext('Active')} - ${gettext('Fingerprint')} ${shortKeyFP}`; - } else { - // old key without FP - values['crypt-key-fp'] = icon + gettext('Active'); - } - values.cryptMode = 'keep'; - values['crypt-allow-edit'] = false; - } else { - values['crypt-key-fp'] = gettext('None'); - let cryptModeNone = me.down('radiofield[inputValue=none]'); - cryptModeNone.setBoxLabel(gettext('Do not encrypt backups')); - values.cryptMode = 'none'; - values['crypt-allow-edit'] = true; - } - vm.set('keepCryptVisible', !!cryptKeyInfo); - vm.set('allowEdit', !cryptKeyInfo); - - me.callParent([values]); - }, - - viewModel: { - data: { - allowEdit: true, - keepCryptVisible: false, - }, - formulas: { - showDangerousHint: (get) => { - let allowEdit = get('allowEdit'); - return get('keepCryptVisible') && allowEdit; - }, - }, - }, - - items: [ - { - xtype: 'displayfield', - name: 'crypt-key-fp', - fieldLabel: gettext('Encryption Key'), - padding: '2 0', - }, - { - xtype: 'checkbox', - name: 'crypt-allow-edit', - boxLabel: gettext('Edit existing encryption key (dangerous!)'), - hidden: true, - submitValue: false, - isDirty: () => false, - bind: { - hidden: '{!keepCryptVisible}', - value: '{allowEdit}', - }, - }, - { - xtype: 'radiofield', - name: 'cryptMode', - inputValue: 'keep', - boxLabel: gettext('Keep encryption key'), - padding: '0 0 0 25', - cbind: { - hidden: '{isCreate}', - }, - bind: { - hidden: '{!keepCryptVisible}', - disabled: '{!allowEdit}', - }, - }, - { - xtype: 'radiofield', - name: 'cryptMode', - inputValue: 'none', - checked: true, - padding: '0 0 0 25', - cbind: { - disabled: '{!isCreate}', - checked: '{isCreate}', - boxLabel: (get) => - get('isCreate') - ? gettext('Do not encrypt backups') - : gettext('Delete existing encryption key'), - }, - bind: { - disabled: '{!allowEdit}', - }, - }, - { - xtype: 'radiofield', - name: 'cryptMode', - inputValue: 'autogenerate', - boxLabel: gettext('Auto-generate a client encryption key'), - padding: '0 0 0 25', - cbind: { - disabled: '{!isCreate}', - }, - bind: { - disabled: '{!allowEdit}', - }, - }, - { - xtype: 'radiofield', - name: 'cryptMode', - inputValue: 'upload', - boxLabel: gettext('Upload an existing client encryption key'), - padding: '0 0 0 25', - cbind: { - disabled: '{!isCreate}', - }, - bind: { - disabled: '{!allowEdit}', - }, - listeners: { - change: function (f, value) { - let panel = this.up('inputpanel'); - if (!panel.rendered) { - return; - } - let uploadKeyField = panel.down('field[name=crypt-key-upload]'); - uploadKeyField.setDisabled(!value); - uploadKeyField.setHidden(!value); - - let uploadKeyButton = panel.down('filebutton[name=crypt-upload-button]'); - uploadKeyButton.setDisabled(!value); - uploadKeyButton.setHidden(!value); - - if (value) { - uploadKeyField.validate(); - } else { - uploadKeyField.reset(); - } - }, - }, - }, - { - xtype: 'fieldcontainer', - layout: 'hbox', - items: [ - { - xtype: 'proxmoxtextfield', - name: 'crypt-key-upload', - fieldLabel: gettext('Key'), - value: '', - disabled: true, - hidden: true, - allowBlank: false, - labelAlign: 'right', - flex: 1, - emptyText: gettext('You can drag-and-drop a key file here.'), - validator: function (value) { - if (value.length) { - let key; - try { - key = JSON.parse(value); - } catch (e) { - return 'Failed to parse key - ' + e; - } - if (key.data === undefined) { - return 'Does not seems like a valid Proxmox Backup key!'; - } - } - return true; - }, - afterRender: function () { - if (!window.FileReader) { - // No FileReader support in this browser - return; - } - let cancel = function (ev) { - ev = ev.event; - if (ev.preventDefault) { - ev.preventDefault(); - } - }; - this.inputEl.on('dragover', cancel); - this.inputEl.on('dragenter', cancel); - this.inputEl.on('drop', (ev) => { - cancel(ev); - let files = ev.event.dataTransfer.files; - PVE.Utils.loadTextFromFile(files[0], (v) => this.setValue(v)); - }); - }, - }, - { - xtype: 'filebutton', - name: 'crypt-upload-button', - iconCls: 'fa fa-fw fa-folder-open-o x-btn-icon-el-default-toolbar-small', - cls: 'x-btn-default-toolbar-small proxmox-inline-button', - margin: '0 0 0 4', - disabled: true, - hidden: true, - listeners: { - change: function (btn, e, value) { - let ev = e.event; - let field = btn.up().down('proxmoxtextfield[name=crypt-key-upload]'); - PVE.Utils.loadTextFromFile(ev.target.files[0], (v) => - field.setValue(v), - ); - btn.reset(); - }, - }, - }, - ], - }, - { - xtype: 'component', - border: false, - padding: '5 2', - userCls: 'pmx-hint', - html: // `${ngettext('Warning', 'Warnings', 1)}: ` + - ` ` + - gettext( - 'Deleting or replacing the encryption key will break restoring backups created with it!', - ), - hidden: true, - bind: { - hidden: '{!showDangerousHint}', - }, - }, - ], -}); - -Ext.define('PVE.storage.PBSInputPanel', { - extend: 'PVE.panel.StorageBase', - - onlineHelp: 'storage_pbs', - - apiCallDone: function (success, response, options) { - let res = response.result.data; - if (!(res && res.config && res.config['encryption-key'])) { - return; - } - let key = res.config['encryption-key']; - Ext.create('PVE.Storage.PBSKeyShow', { - autoShow: true, - sid: res.storage, - key: key, - }); - }, - - isPBS: true, // HACK - - extraTabs: [ - { - xtype: 'pvePBSEncryptionKeyTab', - title: gettext('Encryption'), - }, - ], - - setValues: function (values) { - let me = this; - - let server = values.server; - if (values.port !== undefined) { - if (Proxmox.Utils.IP6_match.test(server)) { - server = `[${server}]`; - } - server += `:${values.port}`; - } - values.hostport = server; - - return me.callParent([values]); - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield', - fieldLabel: gettext('Server'), - allowBlank: false, - name: 'hostport', - submitValue: false, - vtype: 'HostPort', - listeners: { - change: function (field, newvalue) { - let server = newvalue; - let port; - - let match = Proxmox.Utils.HostPort_match.exec(newvalue); - if (match === null) { - match = Proxmox.Utils.HostPortBrackets_match.exec(newvalue); - if (match === null) { - match = Proxmox.Utils.IP6_dotnotation_match.exec(newvalue); - } - } - - if (match !== null) { - server = match[1]; - if (match[2] !== undefined) { - port = match[2]; - } - } - - field.up('inputpanel').down('field[name=server]').setValue(server); - field.up('inputpanel').down('field[name=port]').setValue(port); - }, - }, - }, - { - xtype: 'proxmoxtextfield', - hidden: true, - name: 'server', - submitValue: me.isCreate, // it is fixed - }, - { - xtype: 'proxmoxtextfield', - hidden: true, - deleteEmpty: !me.isCreate, - name: 'port', - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'username', - value: '', - emptyText: gettext('Example') + ': admin@pbs', - fieldLabel: gettext('Username'), - regex: /\S+@\w+/, - regexText: gettext('Example') + ': admin@pbs', - allowBlank: false, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - inputType: 'password', - name: 'password', - value: me.isCreate ? '' : '********', - emptyText: me.isCreate ? gettext('None') : '', - fieldLabel: gettext('Password'), - allowBlank: false, - }, - ]; - - me.column2 = [ - { - xtype: 'displayfield', - name: 'content', - value: 'backup', - submitValue: true, - fieldLabel: gettext('Content'), - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'datastore', - value: '', - fieldLabel: 'Datastore', - allowBlank: false, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'namespace', - value: '', - emptyText: gettext('Root'), - fieldLabel: gettext('Namespace'), - allowBlank: true, - }, - ]; - - me.columnB = [ - { - xtype: 'pmxFingerprintField', - name: 'fingerprint', - value: me.isCreate ? null : undefined, - deleteEmpty: !me.isCreate, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.Ceph.Model', { - extend: 'Ext.app.ViewModel', - alias: 'viewmodel.cephstorage', - - data: { - pveceph: true, - pvecephPossible: true, - namespacePresent: false, - }, -}); - -Ext.define('PVE.storage.Ceph.Controller', { - extend: 'PVE.controller.StorageEdit', - alias: 'controller.cephstorage', - - control: { - '#': { - afterrender: 'queryMonitors', - }, - 'textfield[name=username]': { - disable: 'resetField', - }, - 'displayfield[name=monhost]': { - enable: 'queryMonitors', - }, - 'textfield[name=monhost]': { - disable: 'resetField', - enable: 'resetField', - }, - 'textfield[name=namespace]': { - change: 'updateNamespaceHint', - }, - }, - resetField: function (field) { - field.reset(); - }, - updateNamespaceHint: function (field, newVal, oldVal) { - this.getViewModel().set('namespacePresent', newVal); - }, - queryMonitors: function (field, newVal, oldVal) { - // we get called with two signatures, the above one for a field - // change event and the afterrender from the view, this check only - // can be true for the field change one and omit the API request if - // pveceph got unchecked - as it's not needed there. - if (field && !newVal && oldVal) { - return; - } - var view = this.getView(); - var vm = this.getViewModel(); - if (!(view.isCreate || vm.get('pveceph'))) { - return; // only query on create or if editing a pveceph store - } - - var monhostField = this.lookupReference('monhost'); - - Proxmox.Utils.API2Request({ - url: '/api2/json/nodes/localhost/ceph/mon', - method: 'GET', - scope: this, - callback: function (options, success, response) { - var data = response.result.data; - if (response.status === 200) { - if (data.length > 0) { - let monhost = Ext.Array.pluck(data, 'name').sort().join(','); - monhostField.setValue(monhost); - monhostField.resetOriginalValue(); - if (view.isCreate) { - vm.set('pvecephPossible', true); - } - } else { - vm.set('pveceph', false); - } - } else { - vm.set('pveceph', false); - vm.set('pvecephPossible', false); - } - }, - }); - }, -}); - -Ext.define('PVE.storage.RBDInputPanel', { - extend: 'PVE.panel.StorageBase', - controller: 'cephstorage', - - onlineHelp: 'ceph_rados_block_devices', - - viewModel: { - type: 'cephstorage', - }, - - setValues: function (values) { - if (values.monhost) { - this.viewModel.set('pveceph', false); - this.lookupReference('pvecephRef').setValue(false); - this.lookupReference('pvecephRef').resetOriginalValue(); - } - if (values.namespace) { - this.getViewModel().set('namespacePresent', true); - } - this.callParent([values]); - }, - - initComponent: function () { - var me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - me.type = 'rbd'; - - me.column1 = []; - - if (me.isCreate) { - me.column1.push( - { - xtype: 'pveCephPoolSelector', - nodename: me.nodename, - name: 'pool', - bind: { - disabled: '{!pveceph}', - submitValue: '{pveceph}', - hidden: '{!pveceph}', - }, - fieldLabel: gettext('Pool'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'pool', - value: 'rbd', - bind: { - disabled: '{pveceph}', - submitValue: '{!pveceph}', - hidden: '{pveceph}', - }, - fieldLabel: gettext('Pool'), - allowBlank: false, - }, - ); - } else { - me.column1.push({ - xtype: 'displayfield', - nodename: me.nodename, - name: 'pool', - fieldLabel: gettext('Pool'), - renderer: Ext.htmlEncode, - allowBlank: false, - }); - } - - me.column1.push( - { - xtype: 'textfield', - name: 'monhost', - vtype: 'HostList', - bind: { - disabled: '{pveceph}', - submitValue: '{!pveceph}', - hidden: '{pveceph}', - }, - value: '', - fieldLabel: 'Monitor(s)', - allowBlank: false, - }, - { - xtype: 'displayfield', - reference: 'monhost', - bind: { - disabled: '{!pveceph}', - hidden: '{!pveceph}', - }, - value: '', - fieldLabel: 'Monitor(s)', - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'username', - bind: { - disabled: '{pveceph}', - submitValue: '{!pveceph}', - }, - value: 'admin', - fieldLabel: gettext('User name'), - allowBlank: true, - }, - ); - - me.column2 = [ - { - xtype: 'pveContentTypeSelector', - cts: ['images', 'rootdir'], - fieldLabel: gettext('Content'), - name: 'content', - value: ['images'], - multiSelect: true, - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'krbd', - uncheckedValue: 0, - fieldLabel: 'KRBD', - }, - ]; - - me.columnB = [ - { - xtype: me.isCreate ? 'textarea' : 'displayfield', - name: 'keyring', - fieldLabel: 'Keyring', - value: me.isCreate ? '' : '***********', - allowBlank: false, - bind: { - hidden: '{pveceph}', - disabled: '{pveceph}', - }, - }, - { - xtype: 'proxmoxcheckbox', - name: 'pveceph', - reference: 'pvecephRef', - bind: { - disabled: '{!pvecephPossible}', - value: '{pveceph}', - }, - checked: true, - uncheckedValue: 0, - submitValue: false, - hidden: !me.isCreate, - boxLabel: gettext('Use Proxmox VE managed hyper-converged ceph pool'), - }, - ]; - - me.advancedColumn1 = [ - { - xtype: 'pmxDisplayEditField', - editable: me.isCreate, - name: 'namespace', - value: '', - fieldLabel: gettext('Namespace'), - allowBlank: true, - }, - ]; - me.advancedColumn2 = [ - { - xtype: 'displayfield', - name: 'namespace-hint', - userCls: 'pmx-hint', - value: gettext('RBD namespaces must be created manually!'), - bind: { - hidden: '{!namespacePresent}', - }, - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.StatusView', { - extend: 'Proxmox.panel.StatusView', - alias: 'widget.pveStorageStatusView', - - height: 230, - title: gettext('Status'), - - layout: { - type: 'vbox', - align: 'stretch', - }, - - defaults: { - xtype: 'pmxInfoWidget', - padding: '0 30 5 30', - }, - items: [ - { - xtype: 'box', - height: 30, - }, - { - itemId: 'enabled', - title: gettext('Enabled'), - printBar: false, - textField: 'disabled', - renderer: Proxmox.Utils.format_neg_boolean, - }, - { - itemId: 'active', - title: gettext('Active'), - printBar: false, - textField: 'active', - renderer: Proxmox.Utils.format_boolean, - }, - { - itemId: 'content', - title: gettext('Content'), - printBar: false, - textField: 'content', - renderer: PVE.Utils.format_content_types, - }, - { - itemId: 'type', - title: gettext('Type'), - printBar: false, - textField: 'type', - renderer: PVE.Utils.format_storage_type, - }, - { - xtype: 'box', - height: 10, - }, - { - itemId: 'usage', - title: gettext('Usage'), - valueField: 'used', - maxField: 'total', - renderer: (val, max) => { - if (max === undefined) { - return val; - } - return Proxmox.Utils.render_size_usage(val, max, true); - }, - }, - ], - - updateTitle: function () { - // nothing - }, -}); -Ext.define('PVE.storage.Summary', { - extend: 'Ext.panel.Panel', - alias: 'widget.pveStorageSummary', - scrollable: true, - bodyPadding: 5, - tbar: [ - '->', - { - xtype: 'proxmoxRRDTypeSelector', - }, - ], - layout: { - type: 'column', - }, - defaults: { - padding: 5, - columnWidth: 1, - }, - initComponent: function () { - var me = this; - - var nodename = me.pveSelNode.data.node; - if (!nodename) { - throw 'no node name specified'; - } - - var storage = me.pveSelNode.data.storage; - if (!storage) { - throw 'no storage ID specified'; - } - - var rstore = Ext.create('Proxmox.data.ObjectStore', { - url: '/api2/json/nodes/' + nodename + '/storage/' + storage + '/status', - interval: 1000, - }); - - var rrdstore = Ext.create('Proxmox.data.RRDStore', { - rrdurl: '/api2/json/nodes/' + nodename + '/storage/' + storage + '/rrddata', - model: 'pve-rrd-storage', - }); - - Ext.apply(me, { - items: [ - { - xtype: 'pveStorageStatusView', - pveSelNode: me.pveSelNode, - rstore: rstore, - }, - { - xtype: 'proxmoxRRDChart', - title: gettext('Usage'), - fields: ['total', 'used'], - fieldTitles: ['Total Size', 'Used Size'], - store: rrdstore, - unit: 'bytes', - }, - ], - listeners: { - activate: function () { - rstore.startUpdate(); - rrdstore.startUpdate(); - }, - destroy: function () { - rstore.stopUpdate(); - rrdstore.stopUpdate(); - }, - }, - }); - - me.callParent(); - }, -}); -Ext.define( - 'PVE.grid.TemplateSelector', - { - extend: 'Ext.grid.GridPanel', - - alias: 'widget.pveTemplateSelector', - - stateful: true, - stateId: 'grid-template-selector', - viewConfig: { - trackOver: false, - }, - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - var baseurl = '/nodes/' + me.nodename + '/aplinfo'; - var store = new Ext.data.Store({ - model: 'pve-aplinfo', - groupField: 'section', - proxy: { - type: 'proxmox', - url: '/api2/json' + baseurl, - }, - }); - - var sm = Ext.create('Ext.selection.RowModel', {}); - - var groupingFeature = Ext.create('Ext.grid.feature.Grouping', { - groupHeaderTpl: - '{[ "Section: " + values.name ]} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})', - }); - - var reload = function () { - store.load(); - }; - - Proxmox.Utils.monStoreErrors(me, store); - - Ext.apply(me, { - store: store, - selModel: sm, - tbar: [ - '->', - gettext('Search'), - { - xtype: 'textfield', - width: 200, - enableKeyEvents: true, - listeners: { - buffer: 500, - keyup: function (field) { - var value = field.getValue().toLowerCase(); - store.clearFilter(true); - store.filterBy(function (rec) { - return ( - rec.data.package.toLowerCase().indexOf(value) !== -1 || - rec.data.headline.toLowerCase().indexOf(value) !== -1 - ); - }); - }, - }, - }, - ], - features: [groupingFeature], - columns: [ - { - header: gettext('Type'), - width: 80, - dataIndex: 'type', - }, - { - header: gettext('Package'), - flex: 1, - dataIndex: 'package', - }, - { - header: gettext('Version'), - width: 80, - dataIndex: 'version', - }, - { - header: gettext('Description'), - flex: 1.5, - renderer: Ext.String.htmlEncode, - dataIndex: 'headline', - }, - ], - listeners: { - afterRender: reload, - }, - }); - - me.callParent(); - }, - }, - function () { - Ext.define('pve-aplinfo', { - extend: 'Ext.data.Model', - fields: [ - 'template', - 'type', - 'package', - 'version', - 'headline', - 'infopage', - 'description', - 'os', - 'section', - ], - idProperty: 'template', - }); - }, -); - -Ext.define('PVE.storage.TemplateDownload', { - extend: 'Ext.window.Window', - alias: 'widget.pveTemplateDownload', - - modal: true, - title: gettext('Templates'), - layout: 'fit', - width: 900, - height: 600, - initComponent: function () { - var me = this; - - var grid = Ext.create('PVE.grid.TemplateSelector', { - border: false, - scrollable: true, - nodename: me.nodename, - }); - - var sm = grid.getSelectionModel(); - - var submitBtn = Ext.create('Proxmox.button.Button', { - text: gettext('Download'), - disabled: true, - selModel: sm, - handler: function (button, event, rec) { - Proxmox.Utils.API2Request({ - url: '/nodes/' + me.nodename + '/aplinfo', - params: { - storage: me.storage, - template: rec.data.template, - }, - method: 'POST', - failure: function (response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - success: function (response, options) { - var upid = response.result.data; - - Ext.create('Proxmox.window.TaskViewer', { - upid: upid, - listeners: { - destroy: me.reloadGrid, - }, - }).show(); - - me.close(); - }, - }); - }, - }); - - Ext.apply(me, { - items: grid, - buttons: [submitBtn], - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.OciRegistryPull', { - extend: 'Proxmox.window.Edit', - alias: 'widget.pveOciRegistryPull', - mixins: ['Proxmox.Mixin.CBind'], - - method: 'POST', - - showTaskViewer: true, - - title: gettext('Pull from OCI Registry'), - submitText: gettext('Download'), - width: 450, - - cbind: { - url: '/nodes/{nodename}/storage/{storage}/oci-registry-pull', - }, - - controller: { - xclass: 'Ext.app.ViewController', - - onReferenceChange: function (field, value) { - let me = this; - let view = me.getView(); - let tagField = view.down('[name=tag]'); - tagField.setComboItems([]); - let matches = me.parseReference(value); - if (matches) { - let ref = matches[0]; - let tag = matches[1]; - - if (tag) { - field.setValue(ref); - tagField.setValue(tag); - tagField.focus(); - } else { - tagField.clearValue(); - } - } - }, - - parseReference: function (value) { - const re = new RegExp( - '^((?:[a-zA-Z\\d](?:[a-zA-Z\\d-]*[a-zA-Z\\d])?(?:\\.(?:[a-zA-Z\\d]' + - '(?:[a-zA-Z\\d-]*[a-zA-Z\\d])?))*(?::\\d+)?/)?[a-z\\d]+(?:(?:[._]|__|-+)' + - '[a-z\\d]+)*(?:/[a-z\\d]+(?:(?:[._]|__|-+)[a-z\\d]+)*)*)' + - '(?::(\\w[\\w.-]{0,127}))?$', - ); - let matches = value.match(re); - if (matches) { - let ref = matches[1]; - let tag = matches[2]; - return [ref, tag]; - } - return undefined; - }, - - queryTags: function (field) { - let me = this; - let view = me.getView(); - let refField = view.down('[name=reference]'); - let reference = refField.value.trim(); - let tagField = view.down('[name=tag]'); - - Proxmox.Utils.API2Request({ - url: `/nodes/${view.nodename}/query-oci-repo-tags`, - method: 'GET', - params: { - reference, - }, - waitMsgTarget: view, - failure: (res) => { - Ext.MessageBox.alert(gettext('Error'), res.htmlStatus); - }, - success: function (res, opt) { - let tags = res.result.data; - tagField.clearValue(); - tagField.setComboItems(tags.map((tag) => [tag, Ext.htmlEncode(tag)])); - }, - }); - }, - }, - - items: [ - { - xtype: 'inputpanel', - border: false, - onGetValues: function (values) { - values.reference = values.reference + ':' + values.tag; - delete values.tag; - if (!values.filename) { - delete values.filename; - } - return values; - }, - items: [ - { - xtype: 'fieldcontainer', - layout: 'hbox', - fieldLabel: gettext('Reference'), - items: [ - { - xtype: 'textfield', - name: 'reference', - allowBlank: false, - emptyText: 'registry.example.org/name', - flex: 1, - listeners: { - change: 'onReferenceChange', - }, - validator: function (value) { - let me = this; - let controller = me.up('pveOciRegistryPull').getController(); - if (controller.parseReference(value)) { - return true; - } - return gettext('Invalid OCI Registry Reference'); - }, - }, - { - xtype: 'button', - name: 'check', - text: gettext('Query Tags'), - margin: '0 0 0 5', - listeners: { - click: 'queryTags', - }, - }, - ], - }, - { - xtype: 'proxmoxKVComboBox', - name: 'tag', - allowBlank: false, - // TRANSLATORS: As in a version of an OCI container, e.g. debian:latest - emptyText: gettext("for example 'latest'"), - fieldLabel: gettext('Tag'), - forceSelection: false, - editable: true, - typeAhead: true, - comboItems: [], - }, - { - xtype: 'textfield', - name: 'filename', - emptyText: '_', - fieldLabel: gettext('File name'), - }, - ], - }, - ], - - initComponent: function () { - var me = this; - - if (!me.nodename) { - throw 'no node name specified'; - } - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.TemplateView', { - extend: 'PVE.storage.ContentView', - - alias: 'widget.pveStorageTemplateView', - - initComponent: function () { - var me = this; - - var nodename = (me.nodename = me.pveSelNode.data.node); - if (!nodename) { - throw 'no node name specified'; - } - - var storage = (me.storage = me.pveSelNode.data.storage); - if (!storage) { - throw 'no storage ID specified'; - } - - me.content = 'vztmpl'; - - var reload = function () { - me.store.load(); - }; - - var templateButton = Ext.create('Proxmox.button.Button', { - itemId: 'tmpl-btn', - text: gettext('Templates'), - handler: function () { - var win = Ext.create('PVE.storage.TemplateDownload', { - nodename: nodename, - storage: storage, - reloadGrid: reload, - }); - win.show(); - }, - }); - - var pullOciImageButton = Ext.create('Proxmox.button.Button', { - itemId: 'pull-oci-img-btn', - text: gettext('Pull from OCI Registry'), - handler: function () { - var win = Ext.create('PVE.storage.OciRegistryPull', { - nodename: nodename, - storage: storage, - taskDone: () => reload(), - }); - win.show(); - }, - }); - - me.tbar = [templateButton, pullOciImageButton]; - me.useUploadButton = true; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.ZFSInputPanel', { - extend: 'PVE.panel.StorageBase', - - viewModel: { - parent: null, - data: { - isLIO: false, - isComstar: true, - hasWriteCacheOption: true, - }, - }, - - controller: { - xclass: 'Ext.app.ViewController', - control: { - 'field[name=iscsiprovider]': { - change: 'changeISCSIProvider', - }, - }, - changeISCSIProvider: function (f, newVal, oldVal) { - var vm = this.getViewModel(); - vm.set('isLIO', newVal === 'LIO'); - vm.set('isComstar', newVal === 'comstar'); - vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'istgt'); - }, - }, - - onGetValues: function (values) { - var me = this; - - if (me.isCreate) { - values.content = 'images'; - } - - values.nowritecache = values.writecache ? 0 : 1; - delete values.writecache; - - return me.callParent([values]); - }, - - setValues: function (values) { - values.writecache = values.nowritecache ? 0 : 1; - this.callParent([values]); - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'portal', - value: '', - fieldLabel: gettext('Portal'), - allowBlank: false, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'pool', - value: '', - fieldLabel: gettext('Pool'), - allowBlank: false, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'blocksize', - value: '4k', - fieldLabel: gettext('Block Size'), - allowBlank: false, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'target', - value: '', - fieldLabel: gettext('Target'), - allowBlank: false, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'comstar_tg', - value: '', - fieldLabel: gettext('Target group'), - bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, - allowBlank: true, - }, - ]; - - me.column2 = [ - { - xtype: me.isCreate ? 'pveiScsiProviderSelector' : 'displayfield', - name: 'iscsiprovider', - value: 'comstar', - fieldLabel: gettext('iSCSI Provider'), - allowBlank: false, - }, - { - xtype: 'proxmoxcheckbox', - name: 'sparse', - checked: false, - uncheckedValue: 0, - fieldLabel: gettext('Thin provision'), - }, - { - xtype: 'proxmoxcheckbox', - name: 'writecache', - checked: true, - bind: me.isCreate - ? { disabled: '{!hasWriteCacheOption}' } - : { hidden: '{!hasWriteCacheOption}' }, - uncheckedValue: 0, - fieldLabel: gettext('Write cache'), - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'comstar_hg', - value: '', - bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, - fieldLabel: gettext('Host group'), - allowBlank: true, - }, - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'lio_tpg', - value: '', - bind: me.isCreate ? { disabled: '{!isLIO}' } : { hidden: '{!isLIO}' }, - allowBlank: false, - fieldLabel: gettext('Target portal group'), - }, - ]; - - me.callParent(); - }, -}); -Ext.define('PVE.storage.ZFSPoolSelector', { - extend: 'PVE.form.ComboBoxSetStoreNode', - alias: 'widget.pveZFSPoolSelector', - valueField: 'pool', - displayField: 'pool', - queryMode: 'local', - editable: false, - allowBlank: false, - - listConfig: { - columns: [ - { - dataIndex: 'pool', - flex: 1, - }, - ], - emptyText: PVE.Utils.renderNotFound(gettext('ZFS Pool')), - }, - - config: { - apiSuffix: '/scan/zfs', - }, - - showNodeSelector: true, - - setNodeName: function (value) { - let me = this; - me.callParent([value]); - me.getStore().load(); - }, - - initComponent: function () { - let me = this; - - if (!me.nodename) { - me.nodename = 'localhost'; - } - - let store = Ext.create('Ext.data.Store', { - autoLoad: {}, // true, - fields: ['pool', 'size', 'free'], - proxy: { - type: 'proxmox', - url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`, - }, - }); - store.sort('pool', 'ASC'); - - Ext.apply(me, { - store: store, - }); - - me.callParent(); - }, -}); - -Ext.define('PVE.storage.ZFSPoolInputPanel', { - extend: 'PVE.panel.StorageBase', - mixins: ['Proxmox.Mixin.CBind'], - - onlineHelp: 'storage_zfspool', - - column1: [ - { - xtype: 'pmxDisplayEditField', - cbind: { - editable: '{isCreate}', - }, - - name: 'pool', - fieldLabel: gettext('ZFS Pool'), - allowBlank: false, - - editConfig: { - xtype: 'pveZFSPoolSelector', - reference: 'zfsPoolSelector', - listeners: { - nodechanged: function (value) { - this.up('inputpanel').lookup('storageNodeRestriction').setValue(value); - }, - }, - }, - }, - { - xtype: 'pveContentTypeSelector', - cts: ['images', 'rootdir'], - fieldLabel: gettext('Content'), - name: 'content', - value: ['images', 'rootdir'], - multiSelect: true, - allowBlank: false, - }, - ], - - column2: [ - { - xtype: 'proxmoxcheckbox', - name: 'sparse', - checked: false, - uncheckedValue: 0, - fieldLabel: gettext('Thin provision'), - }, - { - xtype: 'textfield', - name: 'blocksize', - emptyText: '16k', - fieldLabel: gettext('Block Size'), - allowBlank: true, - }, - ], -}); -Ext.define('PVE.storage.ESXIInputPanel', { - extend: 'PVE.panel.StorageBase', - - setValues: function (values) { - let me = this; - - let server = values.server; - if (values.port !== undefined) { - if (Proxmox.Utils.IP6_match.test(server)) { - server = `[${server}]`; - } - server += `:${values.port}`; - } - values.server = server; - - return me.callParent([values]); - }, - - onGetValues: function (values) { - let me = this; - - if (values.password?.length === 0) { - delete values.password; - } - if (values.username?.length === 0) { - delete values.username; - } - - if (me.isCreate) { - let serverPortMatch = Proxmox.Utils.HostPort_match.exec(values.server); - if (serverPortMatch === null) { - serverPortMatch = Proxmox.Utils.HostPortBrackets_match.exec(values.server); - if (serverPortMatch === null) { - serverPortMatch = Proxmox.Utils.IP6_dotnotation_match.exec(values.server); - } - } - - if (serverPortMatch !== null) { - values.server = serverPortMatch[1]; - if (serverPortMatch[2] !== undefined) { - values.port = serverPortMatch[2]; - } - } - } - - return me.callParent([values]); - }, - - initComponent: function () { - var me = this; - - me.column1 = [ - { - xtype: 'pmxDisplayEditField', - name: 'server', - fieldLabel: gettext('Server'), - editable: me.isCreate, - emptyText: gettext('IP address or hostname'), - allowBlank: false, - }, - { - xtype: 'textfield', - name: 'username', - fieldLabel: gettext('Username'), - allowBlank: false, - }, - { - xtype: 'proxmoxtextfield', - name: 'password', - fieldLabel: gettext('Password'), - inputType: 'password', - emptyText: gettext('Unchanged'), - minLength: 1, - allowBlank: !me.isCreate, - }, - ]; - - me.column2 = [ - { - xtype: 'proxmoxcheckbox', - name: 'skip-cert-verification', - fieldLabel: gettext('Skip Certificate Verification'), - value: false, - uncheckedValue: 0, - defaultValue: 0, - deleteDefaultValue: !me.isCreate, - }, - ]; - - me.callParent(); - }, -}); -/* - * Workspace base class - * - * popup login window when auth fails (call onLogin handler) - * update (re-login) ticket every 15 minutes - * - */ - -Ext.define('PVE.Workspace', { - extend: 'Ext.container.Viewport', - - title: 'Proxmox Virtual Environment', - - loginData: null, // Data from last login call - - response401count: 0, - - onLogin: function (loginData) { - // override me - }, - - // private - updateLoginData: function (loginData) { - let me = this; - me.loginData = loginData; - Proxmox.Utils.setAuthData(loginData); - - PVE.ClusterName = loginData.clustername; - - if (loginData.cap) { - Ext.state.Manager.set('GuiCap', loginData.cap); - } - me.response401count = 0; - - me.onLogin(loginData); - }, - - // private - showLogin: function () { - let me = this; - - Proxmox.Utils.authClear(); - Ext.state.Manager.clear('GuiCap'); - Proxmox.UserName = null; - me.loginData = null; - - if (!me.login) { - me.login = Ext.create('PVE.window.LoginWindow', { - handler: function (data) { - me.login = null; - me.updateLoginData(data); - Proxmox.Utils.checked_command(Ext.emptyFn); // display subscription status - }, - }); - } - me.onLogin(null); - me.login.show(); - }, - - initComponent: function () { - let me = this; - - Ext.tip.QuickTipManager.init(); - - // fixme: what about other errors - Ext.Ajax.on('requestexception', function (conn, response, options) { - if ( - (response.status === 401 || response.status === '401') && - !PVE.Utils.silenceAuthFailures - ) { - // auth failure - // don't immediately show as logged out to cope better with some big - // upgrades, which may temporarily produce a false positive 401 err - me.response401count++; - if (me.response401count > 5) { - me.showLogin(); - } - } - }); - - me.callParent(); - - if (!Proxmox.Utils.authOK()) { - me.showLogin(); - } else if (me.loginData) { - me.onLogin(me.loginData); - } - - Ext.TaskManager.start({ - run: function () { - let ticket = Proxmox.Utils.authOK(); - if (!ticket || !Proxmox.UserName) { - return; - } - - Ext.Ajax.request({ - params: { - username: Proxmox.UserName, - password: ticket, - }, - url: '/api2/json/access/ticket', - method: 'POST', - success: function (response, opts) { - let obj = Ext.decode(response.responseText); - me.updateLoginData(obj.data); - }, - }); - }, - interval: 15 * 60 * 1000, - }); - }, -}); - -Ext.define('PVE.StdWorkspace', { - extend: 'PVE.Workspace', - - alias: ['widget.pveStdWorkspace'], - - // private - setContent: function (comp) { - let me = this; - - let view = me.child('#content'); - let layout = view.getLayout(); - let current = layout.getActiveItem(); - - if (comp) { - Proxmox.Utils.setErrorMask(view, false); - comp.border = false; - view.add(comp); - if (current !== null && layout.getNext()) { - layout.next(); - let task = Ext.create('Ext.util.DelayedTask', function () { - view.remove(current); - }); - task.delay(10); - } - } else { - view.removeAll(); // helper for cleaning the content when logging out - } - }, - - selectById: function (nodeid) { - let me = this; - me.down('pveResourceTree').selectById(nodeid); - }, - - onLogin: function (loginData) { - let me = this; - - me.updateUserInfo(); - - if (loginData) { - PVE.data.ResourceStore.startUpdate(); - - Proxmox.Utils.API2Request({ - url: '/version', - method: 'GET', - success: function (response) { - PVE.VersionInfo = response.result.data; - me.updateVersionInfo(); - }, - }); - - PVE.UIOptions.update(); - - Proxmox.Utils.API2Request({ - url: '/cluster/sdn', - method: 'GET', - success: function (response) { - PVE.SDNInfo = response.result.data; - }, - failure: function (response) { - PVE.SDNInfo = null; - let ui = Ext.ComponentQuery.query('treelistitem[text="SDN"]')[0]; - if (ui) { - ui.addCls('x-hidden-display'); - } - }, - }); - - Proxmox.Utils.API2Request({ - url: '/access/domains', - method: 'GET', - success: function (response) { - let [_username, realm] = Proxmox.Utils.parse_userid(Proxmox.UserName); - response.result.data.forEach((domain) => { - if (domain.realm === realm) { - let schema = PVE.Utils.authSchema[domain.type]; - if (schema) { - me.query('#tfaitem')[0].setHidden(!schema.tfa); - me.query('#passworditem')[0].setHidden(!schema.pwchange); - } - } - }); - }, - }); - } - }, - - updateUserInfo: function () { - let me = this; - let ui = me.query('#userinfo')[0]; - ui.setText(Ext.String.htmlEncode(Proxmox.UserName || '')); - ui.updateLayout(); - }, - - updateVersionInfo: function () { - let me = this; - - let ui = me.query('#versioninfo')[0]; - - if (PVE.VersionInfo) { - let version = PVE.VersionInfo.version; - ui.update('Virtual Environment ' + version); - } else { - ui.update('Virtual Environment'); - } - ui.updateLayout(); - }, - - initComponent: function () { - let me = this; - - Ext.History.init(); - - let appState = Ext.create('PVE.StateProvider'); - Ext.state.Manager.setProvider(appState); - - let selview = Ext.create('PVE.form.ViewSelector', { - flex: 1, - padding: '0 5 0 0', - }); - - let rtree = Ext.createWidget('pveResourceTree', { - viewFilter: selview.getViewFilter(), - flex: 1, - selModel: { - selType: 'treemodel', - listeners: { - selectionchange: function (sm, selected) { - if (selected.length <= 0) { - return; - } - let treeNode = selected[0]; - let treeTypeToClass = { - root: 'PVE.dc.Config', - node: 'PVE.node.Config', - qemu: 'PVE.qemu.Config', - lxc: 'pveLXCConfig', - storage: 'PVE.storage.Browser', - sdn: 'PVE.sdn.Browser', - network: 'PVE.network.Browser', - pool: 'pvePoolConfig', - tag: 'pveTagConfig', - }; - PVE.curSelectedNode = treeNode; - me.setContent({ - xtype: - treeTypeToClass[treeNode.data.type || 'root'] || 'pvePanelConfig', - showSearch: - treeNode.data.id === 'root' || - Ext.isDefined(treeNode.data.groupbyid), - pveSelNode: treeNode, - workspace: me, - viewFilter: selview.getViewFilter(), - }); - }, - }, - }, - }); - - selview.on('select', function (combo, records) { - if (records) { - let view = combo.getViewFilter(); - rtree.setViewFilter(view); - } - }); - - let caps = appState.get('GuiCap'); - - let createVM = Ext.createWidget('button', { - pack: 'end', - margin: '3 5 0 0', - baseCls: 'x-btn', - iconCls: 'fa fa-desktop', - text: gettext('Create VM'), - disabled: !caps.vms['VM.Allocate'], - handler: function () { - let wiz = Ext.create('PVE.qemu.CreateWizard', {}); - wiz.show(); - }, - }); - - let createCT = Ext.createWidget('button', { - pack: 'end', - margin: '3 5 0 0', - baseCls: 'x-btn', - iconCls: 'fa fa-cube', - text: gettext('Create CT'), - disabled: !caps.vms['VM.Allocate'], - handler: function () { - let wiz = Ext.create('PVE.lxc.CreateWizard', {}); - wiz.show(); - }, - }); - - appState.on('statechange', function (sp, key, value) { - if (key === 'GuiCap' && value) { - caps = value; - createVM.setDisabled(!caps.vms['VM.Allocate']); - createCT.setDisabled(!caps.vms['VM.Allocate']); - } else if (key === '_stripBeta') { - let betaLink = me.query('#betalink')?.[0]; - if (betaLink) { - betaLink.setHidden(true); - } - - const indexOfTilde = PVE.VersionInfo?.version.indexOf('~') ?? ''; - if (indexOfTilde !== -1) { - PVE.VersionInfo.version = PVE.VersionInfo.version.substring(0, indexOfTilde); - me.updateVersionInfo(); - } - } - }); - - Ext.apply(me, { - layout: { type: 'border' }, - border: false, - items: [ - { - region: 'north', - title: gettext('Header'), // for ARIA - header: false, // avoid rendering the title - layout: { - type: 'hbox', - align: 'middle', - }, - baseCls: 'x-plain', - defaults: { - baseCls: 'x-plain', - }, - border: false, - margin: '2 0 2 5', - items: [ - { - xtype: 'proxmoxLogoSvg', - prefix: 'pwt', - }, - { - minWidth: 150, - id: 'versioninfo', - html: 'Virtual Environment', - padding: '0 5', - style: { - 'font-size': '16px', - 'line-height': '20px', - }, - }, - { - flex: 2, - }, - { - xtype: 'pveGlobalSearchField', - tree: rtree, - minWidth: 150, - maxWidth: 600, - flex: 3, - }, - { - flex: 2, - }, - { - xtype: 'proxmoxHelpButton', - hidden: false, - baseCls: 'x-btn', - iconCls: 'fa fa-book x-btn-icon-el-default-toolbar-small ', - listenToGlobalEvent: false, - onlineHelp: 'pve_documentation_index', - text: gettext('Documentation'), - margin: '0 5 0 0', - }, - createVM, - createCT, - { - pack: 'end', - margin: '0 5 0 0', - id: 'userinfo', - xtype: 'button', - baseCls: 'x-btn', - style: { - // proxmox dark grey p light grey as border - backgroundColor: '#464d4d', - borderColor: '#ABBABA', - }, - iconCls: 'fa fa-user', - menu: [ - { - iconCls: 'fa fa-gear', - text: gettext('My Settings'), - handler: function () { - var win = Ext.create('PVE.window.Settings'); - win.show(); - }, - }, - { - text: gettext('Password'), - itemId: 'passworditem', - iconCls: 'fa fa-fw fa-key', - handler: function () { - var win = Ext.create('Proxmox.window.PasswordEdit', { - userid: Proxmox.UserName, - confirmCurrentPassword: Proxmox.UserName !== 'root@pam', - minLength: 8, - }); - win.show(); - }, - }, - { - text: 'TFA', - itemId: 'tfaitem', - iconCls: 'fa fa-fw fa-lock', - handler: function (btn, event, rec) { - Ext.state.Manager.getProvider().set( - 'dctab', - { value: 'tfa' }, - true, - ); - me.selectById('root'); - }, - }, - { - iconCls: 'fa fa-paint-brush', - text: gettext('Color Theme'), - handler: function () { - Ext.create('Proxmox.window.ThemeEditWindow').show(); - }, - }, - { - iconCls: 'fa fa-language', - text: gettext('Language'), - handler: function () { - Ext.create('Proxmox.window.LanguageEditWindow').show(); - }, - }, - '-', - { - iconCls: 'fa fa-fw fa-sign-out', - text: gettext('Logout'), - handler: function () { - PVE.data.ResourceStore.loadData([], false); - me.showLogin(); - me.setContent(null); - var rt = me.down('pveResourceTree'); - PVE.ClusterName = undefined; - rt.clearTree(); - - // empty the stores of the StatusPanel child items - var statusPanels = - Ext.ComponentQuery.query('pveStatusPanel grid'); - Ext.Array.forEach(statusPanels, function (comp) { - if (comp.getStore()) { - comp.getStore().loadData([], false); - } - }); - }, - }, - ], - }, - ], - }, - { - region: 'center', - stateful: true, - stateId: 'pvecenter', - minWidth: 100, - minHeight: 100, - id: 'content', - xtype: 'container', - layout: { type: 'card' }, - border: false, - margin: '0 5 0 0', - items: [], - }, - { - region: 'west', - stateful: true, - stateId: 'pvewest', - itemId: 'west', - xtype: 'container', - border: false, - layout: { type: 'vbox', align: 'stretch' }, - margin: '0 0 0 5', - split: true, - width: 300, - items: [ - { - xtype: 'container', - layout: 'hbox', - padding: '0 0 5 0', - items: [ - selview, - { - xtype: 'button', - cls: 'x-btn-default-toolbar-small', - iconCls: 'fa fa-fw fa-gear x-btn-icon-el-default-toolbar-small', - handler: () => { - Ext.create('PVE.window.TreeSettingsEdit', { - autoShow: true, - apiCallDone: () => PVE.UIOptions.fireUIConfigChanged(), - }); - }, - }, - ], - }, - rtree, - ], - listeners: { - resize: function (panel, width, height) { - var viewWidth = me.getSize().width; - if (width > viewWidth - 100 && viewWidth > 150) { - panel.setWidth(viewWidth - 100); - } - }, - }, - }, - { - xtype: 'pveStatusPanel', - stateful: true, - stateId: 'pvesouth', - itemId: 'south', - region: 'south', - margin: '0 5 5 5', - title: gettext('Logs'), - collapsible: true, - header: false, - height: 200, - split: true, - listeners: { - resize: function (panel, width, height) { - var viewHeight = me.getSize().height; - if (height > viewHeight - 150 && viewHeight > 200) { - panel.setHeight(viewHeight - 150); - } - }, - }, - }, - ], - }); - - me.callParent(); - - me.updateUserInfo(); - - // on resize, center all modal windows - Ext.on('resize', function () { - let modalWindows = Ext.ComponentQuery.query('window[modal]'); - if (modalWindows.length > 0) { - modalWindows.forEach((win) => win.alignTo(me, 'c-c')); - } - }); - - let tagSelectors = []; - ['circle', 'dense'].forEach((style) => { - ['dark', 'light'].forEach((variant) => { - let selector = `.proxmox-tags-${style} :not(.proxmox-tags-full) > .proxmox-tag-${variant}`; - tagSelectors.push(selector); - }); - }); - - Ext.create('Ext.tip.ToolTip', { - target: me.el, - delegate: tagSelectors.join(', '), - trackMouse: true, - renderTo: Ext.getBody(), - border: 0, - minWidth: 0, - padding: 0, - bodyBorder: 0, - bodyPadding: 0, - dismissDelay: 0, - userCls: 'pmx-tag-tooltip', - shadow: false, - listeners: { - beforeshow: function (tip) { - let tag = Ext.htmlEncode(tip.triggerElement.innerHTML); - let tagEl = Proxmox.Utils.getTagElement(tag, PVE.UIOptions.tagOverrides); - tip.update(`${tagEl}`); - }, - }, - }); - }, -}); From f88a0610745dae72d735218f6fe2af5db50b1856 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 14:23:35 +0200 Subject: [PATCH 36/48] remove unrelation subscription part --- PveMod_pvemanagerlib.js | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js index dd6c077..cd9ec7c 100644 --- a/PveMod_pvemanagerlib.js +++ b/PveMod_pvemanagerlib.js @@ -25,7 +25,7 @@ Ext.define('PVE.node.StatusView', { }, items: [ - // ========== PRIMARY KPIs (Tier 1) ========== + // ========== Primary Metrics ========== { xtype: 'box', colspan: 2, @@ -167,7 +167,7 @@ Ext.define('PVE.node.StatusView', { padding: 0, }, - // ========== SECONDARY DETAILS (Tier 2) ========== + // ========== Secondary Metrics ========== { xtype: 'box', colspan: 2, @@ -930,29 +930,4 @@ Ext.define('PVE.node.StatusView', { me.callParent(); }, -}); -Ext.define('PVE.node.SubscriptionKeyEdit', { - extend: 'Proxmox.window.Edit', - - title: gettext('Upload Subscription Key'), - width: 350, - - items: { - xtype: 'textfield', - name: 'key', - value: '', - fieldLabel: gettext('Subscription Key'), - labelWidth: 120, - getSubmitValue: function () { - return this.processRawValue(this.getRawValue())?.trim(); - }, - }, - - initComponent: function () { - var me = this; - - me.callParent(); - - me.load(); - }, }); \ No newline at end of file From a8b17038f7d45dc6b6428c660d742ff2cf56e70b Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 15:09:08 +0200 Subject: [PATCH 37/48] Migrate pve js to separate file. Update install to reflect this --- pve-mod-gui-sensors.sh | 1200 +++------------------------------------- 1 file changed, 75 insertions(+), 1125 deletions(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index c28345e..2daef36 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -38,8 +38,11 @@ JSON_EXPORT_FILENAME="sensorsdata.json" # File paths PVE_MANAGER_LIB_JS_FILE="/usr/share/pve-manager/js/pvemanagerlib.js" +PVE_MOD_JS_SOURCE_FILE="$SCRIPT_CWD/PveMod_PveNodeStatusView.js" +PVE_MOD_JS_TARGET_FILE="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm" PVE_SENSOR_INFO_MOD_FILE="/usr/share/perl5/PVE/API2/PveMod_SensorInfo.pm" +PVE_SENSOR_INFO_SOURCE_FILE="$SCRIPT_CWD/PveMod_SensorInfo.pm" #region message tools # Section header (bold) @@ -480,46 +483,9 @@ function install_mod { install_sensor_monitor_module insert_sensor_monitor_into_pve - exit - - #### Temperature helper parameters #### - msgb "\n=== Creating temperature conversion helper ===" - HELPERCTORPARAMS=$([[ "$TEMP_UNIT" = "F" ]] && \ - echo '{srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.FAHRENHEIT}' || \ - echo '{srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}') - info "Temperature helper configured for $TEMP_UNIT." - - #### Expand StatusView space #### - expand_statusview_space - - #### Insert temperature helper #### - generate_and_insert_temp_helper - - #### Generate and insert widgets #### - msgb "\n=== Making visual adjustments ===" - - generate_and_insert_widget "$ENABLE_SYSTEM_INFO" "generate_system_info" "system_info" - generate_and_insert_widget "$ENABLE_UPS" "generate_ups_widget" "ups" - generate_and_insert_widget "$ENABLE_GPU_INFO" "generate_gpu_widget" "gpu" - generate_and_insert_widget "$ENABLE_HDD_TEMP" "generate_hdd_widget" "hdd" - generate_and_insert_widget "$ENABLE_NVME_TEMP" "generate_nvme_widget" "nvme" - - if [[ "$ENABLE_HDD_TEMP" = true || "$ENABLE_NVME_TEMP" = true ]]; then - generate_drive_header - info "Drive headers added." - fi - - generate_and_insert_widget "$ENABLE_FAN_SPEED" "generate_fan_widget" "fan" - generate_and_insert_widget "$ENABLE_RAM_TEMP" "generate_ram_widget" "ram" - generate_and_insert_widget "$ENABLE_CPU" "generate_cpu_widget" "cpu" - - #### Visual separation #### - add_visual_separator - info "Added visual separator for modified items." - - #### Node summary #### - setup_node_summary_container - info "Node summary box moved into its own container." + #### Install UI modification module #### + msgb "\n=== Installing UI modification module ===" + install_node_status_view_module msgb "\n=== Finalizing installation ===" @@ -560,14 +526,13 @@ sanitize_sensors_output() { # Install and configure the Sensor Monitor Perl module install_sensor_monitor_module() { # Check if source file exists - if [[ ! -f "$GPU_MONITOR_SOURCE_FILE" ]]; then - err "Source file not found: $GPU_MONITOR_SOURCE_FILE" + if [[ ! -f "$PVE_SENSOR_INFO_SOURCE_FILE" ]]; then + err "Source file not found: $PVE_SENSOR_INFO_SOURCE_FILE" fi # Copy the module file - # todo - how to install module??!?! - #cp "$GPU_MONITOR_SOURCE_FILE" "$PVE_SENSOR_INFO_MOD_FILE" || err "Failed to copy $GPU_MONITOR_SOURCE_FILE to $PVE_SENSOR_INFO_MOD_FILE" - info "Copied GPU Monitor module to $PVE_SENSOR_INFO_MOD_FILE" + cp "$PVE_SENSOR_INFO_SOURCE_FILE" "$PVE_SENSOR_INFO_MOD_FILE" || err "Failed to copy $PVE_SENSOR_INFO_SOURCE_FILE to $PVE_SENSOR_INFO_MOD_FILE" + info "Copied Sensor Monitor module to $PVE_SENSOR_INFO_MOD_FILE" # Convert boolean flags to Perl format (1 or 0) local intel_enabled=$([[ "$ENABLE_INTEL_GPU_INFO" = true ]] && echo 1 || echo 0) @@ -606,10 +571,10 @@ install_sensor_monitor_module() { insert_sensor_monitor_into_pve() { #region PveSensorInfoMod heredoc sed -i '/my \$dinfo = df('\''\/'\'', 1);/i\ - # Collect sensor data from PveSensorInfoMod\ + # Collect sensor data from PveMod_SensorInfo\ # Bad practice to add use here, but cleaner implementation would require several extensive modifications.\ - use PVE::API2::GPUMonitor;\ - $res->{sensorsJSONOutput} = PVE::API2::GPUMonitor::get_sensors_stats();\ + use PVE::API2::PveMod_SensorInfo;\ + $res->{sensorsJSONOutput} = PVE::API2::PveMod_SensorInfo::get_sensors_stats();\ ' "$NODES_PM_FILE" #endregion PveSensorInfoMod heredoc info "Sensor data retriever added to \"$NODES_PM_FILE\"." @@ -641,1072 +606,47 @@ collect_system_info() { } #endregion node info insertion -#region widget generation functions -# Helper function to insert widget after thermal items -insert_widget_after_thermal() { - local widget_file="$1" - sed -i "/^Ext.define('PVE.node.StatusView',/ { - :a - /items:/!{N;ba;} - :b - /'cpus.*},/!{N;bb;} - r $widget_file - }" "$PVE_MANAGER_LIB_JS_FILE" -} - -# Helper function to generate widget and insert it -generate_and_insert_widget() { - local enable_flag="$1" - local generator_func="$2" - local widget_name="$3" - - if [ "$enable_flag" = true ]; then - local temp_js_file="/tmp/${widget_name}_widget.js" - "$generator_func" "$temp_js_file" - insert_widget_after_thermal "$temp_js_file" - rm "$temp_js_file" - info "Inserted $widget_name widget." - fi -} - -# Function to generate drive header -generate_drive_header() { - if [ "$ENABLE_NVME_TEMP" = true ] || [ "$ENABLE_HDD_TEMP" = true ]; then - local temp_js_file="/tmp/drive_header.js" - #region drive header heredoc - cat > "$temp_js_file" <<'EOF' - { - xtype: 'box', - colspan: 2, - html: gettext('Drive(s)'), - }, -EOF -#endregion drive header heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate drive header code" >&2 - exit 1 - fi - - insert_widget_after_thermal "$temp_js_file" - rm "$temp_js_file" - fi -} - -# Function to expand space and modify StatusView properties -expand_statusview_space() { - msgb "\n=== Expanding StatusView space ===" - - # Apply multiple modifications to the StatusView definition - sed -i "/Ext.define('PVE\.node\.StatusView'/,/\},/ { - s/\(bodyPadding:\) '[^']*'/\1 '20 15 20 15'/ - s/height: [0-9]\+/minHeight: 360,\n\tflex: 1,\n\tcollapsible: true,\n\ttitleCollapse: true/ - s/\(tableAttrs:.*$\)/trAttrs: \{ valign: 'top' \},\n\t\1/ - }" "$PVE_MANAGER_LIB_JS_FILE" - - if [[ $? -ne 0 ]]; then - echo "Error: Failed to expand StatusView space" >&2 - exit 1 +#region UI Module Installation +# Install the UI modification module +install_node_status_view_module() { + # Check if source file exists + if [[ ! -f "$PVE_MOD_JS_SOURCE_FILE" ]]; then + err "Source file not found: $PVE_MOD_JS_SOURCE_FILE" fi - - info "Expanded space in \"$PVE_MANAGER_LIB_JS_FILE\"." -} -# Function to move node summary into its own container -setup_node_summary_container() { - # Move the node summary box into its own container - local temp_js_file="/tmp/summary_container.js" - #region summary container heredoc - cat > "$temp_js_file" <<'EOF' -{ - xtype: 'container', - itemId: 'summarycontainer', - layout: 'column', - minWidth: 700, - defaults: { - minHeight: 350, - padding: 5, - columnWidth: 1, - }, - items: [ - nodeStatus, - ] -}, -EOF -#endregion summary container heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate summary container code" >&2 - exit 1 - fi - - # Insert the new container after finding the nodeStatus and items pattern - sed -i "/^\s*nodeStatus: nodeStatus,/ { - :a - /items: \[/ !{N;ba;} - r $temp_js_file - }" "$PVE_MANAGER_LIB_JS_FILE" - - rm "$temp_js_file" - - # Deactivate the original box instance - sed -i "/^\s*nodeStatus: nodeStatus,/ { - :a - /itemId: 'itemcontainer',/ !{N;ba;} - n; - :b - /nodeStatus,/ !{N;bb;} - s/nodeStatus/\/\/nodeStatus/ - }" "$PVE_MANAGER_LIB_JS_FILE" - - if [[ $? -ne 0 ]]; then - echo "Error: Failed to deactivate original nodeStatus instance" >&2 - exit 1 - fi -} + # Copy the JavaScript module to PVE manager directory + cp "$PVE_MOD_JS_SOURCE_FILE" "$PVE_MOD_JS_TARGET_FILE" || err "Failed to copy $PVE_MOD_JS_SOURCE_FILE to $PVE_MOD_JS_TARGET_FILE" + info "Copied UI module to $PVE_MOD_JS_TARGET_FILE" -# Function to add visual spacing separator after the last widget -add_visual_separator() { - # Check for the presence of items in the reverse order of display - local lastItemId="" - - if [ "$ENABLE_UPS" = true ]; then - lastItemId="upsc" - elif [ "$ENABLE_GPU" = true ]; then - lastItemId="gpuInfo" - elif [ "$ENABLE_HDD_TEMP" = true ]; then - lastItemId="thermalHdd" - elif [ "$ENABLE_NVME_TEMP" = true ]; then - lastItemId="thermalNvme" - elif [ "$ENABLE_FAN_SPEED" = true ]; then - lastItemId="speedFan" + # Comment out the original PVE.node.StatusView definition in pvemanagerlib.js + # This allows our custom module to provide the new definition + if grep -q "^Ext.define('PVE.node.StatusView'," "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then + # Find the start of the definition and comment it out until the matching closing brace + sed -i "/^Ext\.define('PVE\.node\.StatusView',/,/^});/s|^|// |" "$PVE_MANAGER_LIB_JS_FILE" + info "Commented out original StatusView definition in pvemanagerlib.js" else - lastItemId="thermalCpu" - fi - - if [ -n "$lastItemId" ]; then - local temp_js_file="/tmp/visual_separator.js" - - #region visual spacing heredoc - cat > "$temp_js_file" <<'EOF' - { - xtype: 'box', - colspan: 2, - padding: '0 0 20 0', - }, -EOF -#endregion visual spacing heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate visual separator code" >&2 - exit 1 - fi - - # Insert after the specific lastItemId (different pattern than thermal) - sed -i "/^Ext.define('PVE.node.StatusView',/ { - :a; - /^.*{.*'$lastItemId'.*},/!{N;ba;} - r $temp_js_file - }" "$PVE_MANAGER_LIB_JS_FILE" - - rm "$temp_js_file" + warn "Original StatusView definition not found in expected format in pvemanagerlib.js" fi -} -# Function to generate system info widget -generate_system_info() { - #region system info heredoc - cat > "$1" <<'EOF' - { - itemId: 'sysinfo', - colspan: 2, - printBar: false, - title: gettext('System Information'), - textField: 'systemInfo', - renderer: function(value){ - return value; - } - }, -EOF - #endregion system info heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate system info code" >&2 - exit 1 - fi -} - -# Function to generate and insert temperature conversion helper class -generate_and_insert_temp_helper() { - local temp_js_file="/tmp/temp_helper.js" - - msgb "\n=== Inserting temperature helper ===" - - #region temp helper heredoc - cat > "$temp_js_file" <<'EOF' -Ext.define('PVE.mod.TempHelper', { - //singleton: true, - - requires: ['Ext.util.Format'], - - statics: { - CELSIUS: 0, - FAHRENHEIT: 1 - }, - - srcUnit: null, - dstUnit: null, - - isValidUnit: function (unit) { - return ( - Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) - ); - }, - - constructor: function (config) { - this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; - this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; - }, - - toFahrenheit: function (tempCelsius) { - return Ext.isNumber(tempCelsius) - ? tempCelsius * 9 / 5 + 32 - : NaN; - }, - - toCelsius: function (tempFahrenheit) { - return Ext.isNumber(tempFahrenheit) - ? (tempFahrenheit - 32) * 5 / 9 - : NaN; - }, - - getTemp: function (value) { - if (this.srcUnit !== this.dstUnit) { - switch (this.srcUnit) { - case this.self.CELSIUS: - switch (this.dstUnit) { - case this.self.FAHRENHEIT: - return this.toFahrenheit(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - case this.self.FAHRENHEIT: - switch (this.dstUnit) { - case this.self.CELSIUS: - return this.toCelsius(value); - - default: - Ext.raise({ - msg: - 'Unsupported destination temperature unit: ' + this.dstUnit, - }); - } - default: - Ext.raise({ - msg: 'Unsupported source temperature unit: ' + this.srcUnit, - }); - } - } else { - return value; - } - }, - - getUnit: function(plainText) { - switch (this.dstUnit) { - case this.self.CELSIUS: - return plainText !== true ? '°C' : '\'C'; - - case this.self.FAHRENHEIT: - return plainText !== true ? '°F' : '\'F'; - - default: - Ext.raise({ - msg: 'Unsupported destination temperature unit: ' + this.srcUnit, - }); - } - }, -}); -EOF - #endregion temp helper heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate temp helper code" >&2 - exit 1 - fi - - sed -i "/^Ext.define('PVE.node.StatusView'/e cat /tmp/temp_helper.js" "$PVE_MANAGER_LIB_JS_FILE" - rm "$temp_js_file" - - info "Temperature helper inserted successfully." -} - -# Function to generate CPU widget -generate_cpu_widget() { - #region cpu widget heredoc - # use subshell to allow variable expansion - ( - export CPU_ITEMS_PER_ROW - export CPU_TEMP_TARGET - export HELPERCTORPARAMS - - cat <<'EOF' | envsubst '$CPU_ITEMS_PER_ROW $CPU_TEMP_TARGET $HELPERCTORPARAMS' > "$1" - { - itemId: 'thermalCpu', - colspan: 2, - printBar: false, - title: gettext('CPU Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value){ - // sensors configuration - const cpuTempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - // display configuration - const itemsPerRow = $CPU_ITEMS_PER_ROW; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - const cpuKeysI = Object.keys(objValue).filter(item => String(item).startsWith('coretemp-isa-')).sort(); - const cpuKeysA = Object.keys(objValue).filter(item => String(item).startsWith('k10temp-pci-')).sort(); - const bINTEL = cpuKeysI.length > 0 ? true : false; - const INTELPackagePrefix = '$CPU_TEMP_TARGET' == 'Core' ? 'Core ' : 'Package id'; - const INTELPackageCaption = '$CPU_TEMP_TARGET' == 'Core' ? 'Core' : 'Package'; - let AMDPackagePrefix = 'Tccd'; - let AMDPackageCaption = 'CCD'; - - if (cpuKeysA.length > 0) { - let bTccd = false; - let bTctl = false; - let bTdie = false; - let bCpuCoreTemp = false; - cpuKeysA.forEach((cpuKey, cpuIndex) => { - let items = objValue[cpuKey]; - bTccd = Object.keys(items).findIndex(item => { return String(item).startsWith('Tccd'); }) >= 0; - bTctl = Object.keys(items).findIndex(item => { return String(item).startsWith('Tctl'); }) >= 0; - bTdie = Object.keys(items).findIndex(item => { return String(item).startsWith('Tdie'); }) >= 0; - bCpuCoreTemp = Object.keys(items).findIndex(item => { return String(item) === 'CPU Core Temp'; }) >= 0; - }); - if (bTccd && '$CPU_TEMP_TARGET' == 'Core') { - AMDPackagePrefix = 'Tccd'; - AMDPackageCaption = 'ccd'; - } else if (bCpuCoreTemp && '$CPU_TEMP_TARGET' == 'Package') { - AMDPackagePrefix = 'CPU Core Temp'; - AMDPackageCaption = 'CPU Core Temp'; - } else if (bTdie) { - AMDPackagePrefix = 'Tdie'; - AMDPackageCaption = 'die'; - } else if (bTctl) { - AMDPackagePrefix = 'Tctl'; - AMDPackageCaption = 'ctl'; - } else { - AMDPackagePrefix = 'temp'; - AMDPackageCaption = 'Temp'; - } - } - - const cpuKeys = bINTEL ? cpuKeysI : cpuKeysA; - const cpuItemPrefix = bINTEL ? INTELPackagePrefix : AMDPackagePrefix; - const cpuTempCaption = bINTEL ? INTELPackageCaption : AMDPackageCaption; - const formatTemp = bINTEL ? '0' : '0.0'; - const cpuCount = cpuKeys.length; - let temps = []; - - cpuKeys.forEach((cpuKey, cpuIndex) => { - let cpuTemps = []; - const items = objValue[cpuKey]; - const itemKeys = Object.keys(items).filter(item => { - if ('$CPU_TEMP_TARGET' == 'Core') { - // In Core mode: only show individual cores/CCDs, exclude overall CPU temp - return String(item).includes(cpuItemPrefix) || String(item).startsWith('Tccd'); - } else { - // In Package mode: show overall CPU temp and package-level readings - return String(item).includes(cpuItemPrefix) || String(item) === 'CPU Core Temp'; - } - }); - - itemKeys.forEach((coreKey) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(items[coreKey]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = cpuTempHelper.getTemp(parseFloat(items[coreKey][secondLevelKey])); - } - }); - - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - - let tempStr = ''; - - // Enhanced parsing for AMD temperatures - if (coreKey.startsWith('Tccd')) { - let tempIndex = coreKey.match(/Tccd(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - tempStr = `${cpuTempCaption} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - // Handle CPU Core Temp (single overall temperature) - else if (coreKey === 'CPU Core Temp') { - tempStr = `${cpuTempCaption}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - // Enhanced parsing for Intel cores (P-Core, E-Core, regular Core) - else { - let tempIndex = coreKey.match(/(?:P\s+Core|E\s+Core|Core)\s*(\d+)/); - if (tempIndex !== null && tempIndex.length > 1) { - tempIndex = tempIndex[1]; - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType} ${tempIndex}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } else { - // fallback for CPUs which do not have a core index - let coreType = coreKey.startsWith('P Core') ? 'P Core' : - coreKey.startsWith('E Core') ? 'E Core' : - cpuTempCaption; - tempStr = `${coreType}: ${Ext.util.Format.number(tempVal, formatTemp)}${cpuTempHelper.getUnit()}`; - } - } - - cpuTemps.push(tempStr); - } - } catch (e) { /*_*/ } - }); - - if(cpuTemps.length > 0) { - temps.push(cpuTemps); - } - }); - - let result = ''; - temps.forEach((cpuTemps, cpuIndex) => { - const strCoreTemps = cpuTemps.map((strTemp, index, arr) => { - return strTemp + (index + 1 < arr.length ? (itemsPerRow > 0 && (index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); - }) - if(strCoreTemps.length > 0) { - result += (cpuCount > 1 ? `CPU ${cpuIndex+1}: ` : '') + strCoreTemps.join('') + (cpuIndex < cpuCount ? '
    ' : ''); - } - }); - - return '
    ' + (result.length > 0 ? result : 'N/A') + '
    '; - } - }, -EOF - ) - #endregion cpu widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate cpu widget code" >&2 - exit 1 - fi -} - -# Function to generate nvme widget -generate_nvme_widget() { - #region nvme widget heredoc - # use subshell to allow variable expansion - ( - export HELPERCTORPARAMS - export NVME_ITEMS_PER_ROW - cat <<'EOF' | envsubst '$HELPERCTORPARAMS $NVME_ITEMS_PER_ROW' > "$1" - { - itemId: 'thermalNvme', - colspan: 2, - printBar: false, - title: gettext('NVMe Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value) { - // sensors configuration - const addressPrefix = "nvme-pci-"; - const sensorName = "Composite"; - const tempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - // display configuration - const itemsPerRow = $NVME_ITEMS_PER_ROW; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - const nvmeKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); - let temps = []; - nvmeKeys.forEach((nvmeKey, index) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(objValue[nvmeKey][sensorName]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = tempHelper.getTemp(parseFloat(objValue[nvmeKey][sensorName][secondLevelKey])); - } - }); - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - const tempStr = `Drive ${index + 1}: ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`; - temps.push(tempStr); - } - } catch(e) { /*_*/ } - }); - const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); }); - return '
    ' + (result.length > 0 ? result.join('') : 'N/A') + '
    '; - } - }, -EOF - ) - #endregion nvme widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate nvme widget code" >&2 - exit 1 - fi -} - -# Function to generate Fan widget -generate_fan_widget() { - #region fan widget heredoc - # use subshell to allow variable expansion - ( - export DISPLAY_ZERO_SPEED_FANS - cat <<'EOF' | envsubst '$DISPLAY_ZERO_SPEED_FANS' > "$1" - { - xtype: 'box', - colspan: 2, - html: gettext('Cooling'), - }, - { - itemId: 'speedFan', - colspan: 2, - printBar: false, - title: gettext('Fan Speed(s)'), - iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'sensorsOutput', - renderer: function(value) { - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - - // Recursive function to find fan keys and values - function findFanKeys(obj, fanKeys, parentKey = null) { - Object.keys(obj).forEach(key => { - const value = obj[key]; - if (typeof value === 'object' && value !== null) { - // If the value is an object, recursively call the function - findFanKeys(value, fanKeys, key); - } else if (/^fan[0-9]+(_input)?$/.test(key)) { - if ($DISPLAY_ZERO_SPEED_FANS != true && value === 0) { - // Skip this fan if DISPLAY_ZERO_SPEED_FANS is false and value is 0 - return; - } - // If the key matches the pattern, add the parent key and value to the fanKeys array - fanKeys.push({ key: parentKey, value: value }); - } - }); - } - - let speeds = []; - // Loop through the parent keys - Object.keys(objValue).forEach(parentKey => { - const parentObj = objValue[parentKey]; - // Array to store fan keys and values - const fanKeys = []; - // Call the recursive function to find fan keys and values - findFanKeys(parentObj, fanKeys); - // Sort the fan keys - fanKeys.sort(); - // Process each fan key and value - fanKeys.forEach(({ key: fanKey, value: fanSpeed }) => { - try { - const fan = fanKey.charAt(0).toUpperCase() + fanKey.slice(1); // Capitalize the first letter of fanKey - speeds.push(`${fan}: ${fanSpeed} RPM`); - } catch(e) { - console.error(`Error retrieving fan speed for ${fanKey} in ${parentKey}:`, e); // Debug: Log specific error - } - }); - }); - return '
    ' + (speeds.length > 0 ? speeds.join(' | ') : 'N/A') + '
    '; - } - }, -EOF - ) - #endregion fan widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate fan widget code" >&2 - exit 1 - fi -} - -# Function to generate UPS widget -generate_hdd_widget() { - #region hdd widget heredoc - # use subshell to allow variable expansion - ( - export HELPERCTORPARAMS - export HDD_ITEMS_PER_ROW - cat <<'EOF' | envsubst '$HDD_ITEMS_PER_ROW $HELPERCTORPARAMS' > "$1" - { - itemId: 'thermalHdd', - colspan: 2, - printBar: false, - title: gettext('HDD/SSD Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value) { - // sensors configuration - const addressPrefix = "drivetemp-scsi-"; - const sensorName = "temp1"; - const tempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - // display configuration - const itemsPerRow = $HDD_ITEMS_PER_ROW; - // --- - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - const drvKeys = Object.keys(objValue).filter(item => String(item).startsWith(addressPrefix)).sort(); - let temps = []; - drvKeys.forEach((drvKey, index) => { - try { - let tempVal = NaN, tempMax = NaN, tempCrit = NaN; - Object.keys(objValue[drvKey][sensorName]).forEach((secondLevelKey) => { - if (secondLevelKey.endsWith('_input')) { - tempVal = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_max')) { - tempMax = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey])); - } else if (secondLevelKey.endsWith('_crit')) { - tempCrit = tempHelper.getTemp(parseFloat(objValue[drvKey][sensorName][secondLevelKey])); - } - }); - if (!isNaN(tempVal)) { - let tempStyle = ''; - if (!isNaN(tempMax) && tempVal >= tempMax) { - tempStyle = 'color: #FFC300; font-weight: bold;'; - } - if (!isNaN(tempCrit) && tempVal >= tempCrit) { - tempStyle = 'color: red; font-weight: bold;'; - } - const tempStr = `Drive ${index + 1}: ${Ext.util.Format.number(tempVal, '0.0')}${tempHelper.getUnit()}`; - temps.push(tempStr); - } - } catch(e) { /*_*/ } - }); - const result = temps.map((strTemp, index, arr) => { return strTemp + (index + 1 < arr.length ? ((index + 1) % itemsPerRow === 0 ? '
    ' : ' | ') : ''); }); - return '
    ' + (result.length > 0 ? result.join('') : 'N/A') + '
    '; - } - }, -EOF - ) - #endregion hdd widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate hhd widget code" >&2 - exit 1 - fi -} - -# Function to generate RAM widget -generate_ram_widget() { - #region ram widget heredoc - # use subshell to allow variable expansion - ( - export HELPERCTORPARAMS - cat <<'EOF' | envsubst '$HELPERCTORPARAMS' > "$1" - { - xtype: 'box', - colspan: 2, - html: gettext('RAM'), - }, - { - itemId: 'thermalRam', - colspan: 2, - printBar: false, - title: gettext('Thermal State'), - iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', - renderer: function(value) { - const cpuTempHelper = Ext.create('PVE.mod.TempHelper', $HELPERCTORPARAMS); - - let objValue; - try { - objValue = JSON.parse(value) || {}; - } catch(e) { - objValue = {}; - } - - // Recursive function to find ram keys and values - function findRamKeys(obj, ramKeys, parentKey = null) { - Object.keys(obj).forEach(key => { - const value = obj[key]; - if (typeof value === 'object' && value !== null) { - // If the value is an object, recursively call the function - findRamKeys(value, ramKeys, key); - } else if (/^temp\d+_input$/.test(key) && parentKey && parentKey.startsWith("SODIMM")) { - if (value !== 0) { - ramKeys.push({ key: parentKey, value: value}); - } - } - }); - } - - let ramTemps = []; - // Loop through the parent keys - Object.keys(objValue).forEach(parentKey => { - const parentObj = objValue[parentKey]; - // Array to store ram keys and values - const ramKeys = []; - // Call the recursive function to find ram keys and values - findRamKeys(parentObj, ramKeys); - // Sort the ramKeys keys - ramKeys.sort(); - // Process each ram key and value - ramKeys.forEach(({ key: ramKey, value: ramTemp }) => { - try { - ramTemps.push(`${ramKey}: ${ramTemp}${cpuTempHelper.getUnit()}`); - } catch(e) { - console.error(`Error retrieving Ram Temp for ${ramTemps} in ${parentKey}:`, e); // Debug: Log specific error - } - }); - }); - return '
    ' + (ramTemps.length > 0 ? ramTemps.join(' | ') : 'N/A') + '
    '; - } - }, -EOF - ) - #endregion ram widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate ram widget code" >&2 - exit 1 - fi -} - -# Function to generate UPS widget -generate_ups_widget() { - #region UPS widget heredoc - cat > "$1" <<'EOF' - { - xtype: 'box', - colspan: 2, - html: gettext('UPS'), - }, - { - itemId: 'upsc', - colspan: 2, - printBar: false, - title: gettext('Device'), - iconCls: 'fa fa-fw fa-battery-three-quarters', - textField: 'upsc', - renderer: function(value) { - let objValue = {}; - try { - // Parse the UPS data - if (typeof value === 'string') { - const lines = value.split('\n'); - lines.forEach(line => { - const colonIndex = line.indexOf(':'); - if (colonIndex > 0) { - const key = line.substring(0, colonIndex).trim(); - const val = line.substring(colonIndex + 1).trim(); - objValue[key] = val; - } - }); - } else if (typeof value === 'object') { - objValue = value || {}; - } - } catch(e) { - objValue = {}; - } - - // If objValue is null or empty, return N/A - if (!objValue || Object.keys(objValue).length === 0) { - return '
    N/A
    '; - } - - // Helper function to get status color - // Returns a CSS color string for non-default states, or null for default (no inline color) - function getStatusColor(status) { - if (!status) return '#999'; - const statusUpper = status.toUpperCase(); - if (statusUpper.includes('OL')) return null; // default (no explicit color) - if (statusUpper.includes('OB')) return '#d9534f'; // Red for on battery - if (statusUpper.includes('LB')) return '#d9534f'; // Red for low battery - return '#f0ad4e'; // Orange for other states - } - - // Helper function to get load/charge color - // Returns null for default/good values so no inline style is emitted - function getPercentageColor(value, isLoad = false) { - if (!value || isNaN(value)) return '#999'; - const num = parseFloat(value); - if (isLoad) { - if (num >= 80) return '#d9534f'; // Red for high load - if (num >= 60) return '#f0ad4e'; // Orange for medium load - return null; // default (no explicit color) - } else { - // For battery charge - if (num <= 20) return '#d9534f'; // Red for low charge - if (num <= 50) return '#f0ad4e'; // Orange for medium charge - return null; // default (no explicit color) - } - } - - // Helper function to format runtime - function formatRuntime(seconds) { - if (!seconds || isNaN(seconds)) return 'N/A'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}m ${secs}s`; - } - - // Extract key UPS information - const batteryCharge = objValue['battery.charge']; - const batteryRuntime = objValue['battery.runtime']; - const inputVoltage = objValue['input.voltage']; - const upsLoad = objValue['ups.load']; - const upsStatus = objValue['ups.status']; - const upsModel = objValue['ups.model'] || objValue['device.model']; - const testResult = objValue['ups.test.result']; - const batteryChargeLow = objValue['battery.charge.low']; - const batteryRuntimeLow = objValue['battery.runtime.low']; - const upsRealPowerNominal = objValue['ups.realpower.nominal']; - const batteryMfrDate = objValue['battery.mfr.date']; - - // Build the status display - let displayItems = []; - - // First line: Model info (no explicit color for default) - let modelLine = ''; - if (upsModel) { - modelLine = `${upsModel}`; - } else { - modelLine = `N/A`; - } - displayItems.push(modelLine); - - // Main status line with all metrics - let statusLine = ''; - - // Status - if (upsStatus) { - const statusUpper = upsStatus.toUpperCase(); - let statusText = 'Unknown'; - let statusColor = '#f0ad4e'; - - if (statusUpper.includes('OL')) { - statusText = 'Online'; - statusColor = null; // default (no explicit color) - } else if (statusUpper.includes('OB')) { - statusText = 'On Battery'; - statusColor = '#d9534f'; // Red for on battery - } else if (statusUpper.includes('LB')) { - statusText = 'Low Battery'; - statusColor = '#d9534f'; // Red for low battery - } else { - statusText = upsStatus; - statusColor = '#f0ad4e'; // Orange for unknown status - } - - let statusStyle = statusColor ? ('color: ' + statusColor + ';') : ''; - statusLine += 'Status: ' + statusText + ''; - } else { - statusLine += 'Status: N/A'; - } - - // Battery charge - if (statusLine) statusLine += ' | '; - if (batteryCharge) { - const chargeColor = getPercentageColor(batteryCharge, false); - let chargeStyle = chargeColor ? ('color: ' + chargeColor + ';') : ''; - statusLine += 'Battery: ' + batteryCharge + '%'; - } else { - statusLine += 'Battery: N/A'; - } - - // Load percentage - if (statusLine) statusLine += ' | '; - if (upsLoad) { - const loadColor = getPercentageColor(upsLoad, true); - let loadStyle = loadColor ? ('color: ' + loadColor + ';') : ''; - statusLine += 'Load: ' + upsLoad + '%'; - } else { - statusLine += 'Load: N/A'; - } - - // Runtime - if (statusLine) statusLine += ' | '; - if (batteryRuntime) { - const runtime = parseInt(batteryRuntime); - const runtimeLowThreshold = batteryRuntimeLow ? parseInt(batteryRuntimeLow) : 600; - let runtimeColor = null; - if (runtime <= runtimeLowThreshold / 2) runtimeColor = '#d9534f'; // Red if less than half of low threshold - else if (runtime <= runtimeLowThreshold) runtimeColor = '#f0ad4e'; // Orange if at low threshold - let runtimeStyle = runtimeColor ? ('color: ' + runtimeColor + ';') : ''; - statusLine += 'Runtime: ' + formatRuntime(runtime) + ''; - } else { - statusLine += 'Runtime: N/A'; - } - - // Input voltage - if (statusLine) statusLine += ' | '; - if (inputVoltage) { - statusLine += 'Input: ' + parseFloat(inputVoltage).toFixed(0) + 'V'; - } else { - statusLine += 'Input: N/A'; - } - - // Calculate actual watt usage - if (statusLine) statusLine += ' | '; - let actualWattage = null; - if (upsLoad && upsRealPowerNominal) { - const load = parseFloat(upsLoad); - const nominal = parseFloat(upsRealPowerNominal); - if (!isNaN(load) && !isNaN(nominal)) { - actualWattage = Math.round((load / 100) * nominal); - } - } - - // Real power (calculated watt usage) - if (actualWattage !== null) { - statusLine += 'Output: ' + actualWattage + 'W'; - } else { - statusLine += 'Output: N/A'; - } - - displayItems.push(statusLine); - - // Combined battery and test line - let batteryTestLine = ''; - if (batteryMfrDate) { - batteryTestLine += 'Battery MFD: ' + batteryMfrDate + ''; - } else { - batteryTestLine += 'Battery MFD: N/A'; - } - - if (testResult && !testResult.toLowerCase().includes('no test')) { - const testColor = testResult.toLowerCase().includes('passed') ? null : '#d9534f'; - let testStyle = testColor ? ('color: ' + testColor + ';') : ''; - batteryTestLine += ' | Test: ' + testResult + ''; - } else { - batteryTestLine += ' | Test: N/A'; - } - - displayItems.push(batteryTestLine); - - // Format the final output - return '
    ' + displayItems.join('
    ') + '
    '; - } - }, -EOF - #endregion UPS widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate UPS widget code" >&2 - exit 1 + # Add a dynamic script loader to load our custom module + # Insert before the commented-out Ext.define so it loads early + if ! grep -q "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then + # Use ExtJS Loader to dynamically load our custom module + sed -i "/^\/\/ Ext\.define('PVE\.node\.StatusView',/i\\ +// Load custom PVE.node.StatusView from external module\\ +Ext.Loader.loadScript({\\ + url: '/pve2/js/PveMod_PveNodeStatusView.js',\\ + onLoad: function() { console.log('Loaded PveMod_PveNodeStatusView.js'); },\\ + onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); }\\ +});\\ +" "$PVE_MANAGER_LIB_JS_FILE" + info "Added dynamic loader for custom UI module in pvemanagerlib.js" + else + info "Custom UI module loader already present in pvemanagerlib.js" fi } +#endregion UI Module Installation -# Function to generate GPU widget -generate_gpu_widget() { - #region gpu widget heredoc - # use subshell to allow variable expansion - ( - export CPU_ITEMS_PER_ROW - export CPU_TEMP_TARGET - export HELPERCTORPARAMS - - cat <<'EOF' | envsubst '$CPU_ITEMS_PER_ROW $CPU_TEMP_TARGET $HELPERCTORPARAMS' > "$1" - { - itemId: 'gpu', - colspan: 2, - iconCls: 'fa fa-desktop', - title: gettext('GPU(s)'), - printBar: false, - textField: 'gpuStats', - renderer: function(gpuStats) { - console.log(gpuStats); - if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.Intel) { - return 'N/A'; - } - - let html = ''; - - Object.keys(gpuStats.Graphics.Intel).forEach(key => { - const gpuData = gpuStats.Graphics.Intel[key]; - console.log("here1"); - html += `
    `; - html += `
    ${gpuData.name}
    `; - html += `
    `; - - if (gpuData.stats.engines) { - console.log("here2"); - // Render/3D - if (gpuData.stats.engines['Render/3D']) { - html += `Render/3D: ${gpuData.stats.engines['Render/3D'].busy}% | `; - } - - // Video - if (gpuData.stats.engines['Video']) { - html += `Video: ${gpuData.stats.engines['Video'].busy}% | `; - } - - // Blitter - if (gpuData.stats.engines['Blitter']) { - html += `Blitter: ${gpuData.stats.engines['Blitter'].busy}% | `; - } - - // VideoEnhance - if (gpuData.stats.engines['VideoEnhance']) { - html += `VideoEnhance: ${gpuData.stats.engines['VideoEnhance'].busy}% | `; - } - } - - // Power and Frequency info - html += `Power: ${gpuData.stats.power?.GPU ?? 'N/A'} / ${gpuData.stats.power?.Package ?? 'N/A'} ${gpuData.stats.power?.unit || 'W'}`; - html += ` | Freq: ${gpuData.stats.frequency?.actual ?? 'N/A'}/${gpuData.stats.frequency?.requested ?? 'N/A'} ${gpuData.frequency?.unit || 'MHz'}`; - - html += `
    `; - }); - - // todo add NVIDIA - - // todo add NVIDIA - - return html; - }, - }, -EOF - ) - #endregion cpu widget heredoc - if [[ $? -ne 0 ]]; then - echo "Error: Failed to generate cpu widget code" >&2 - exit 1 - fi -} -#endregion widget generation functions # Function to uninstall the modification function uninstall_mod { @@ -1714,7 +654,7 @@ function uninstall_mod { check_root_privileges - if [[ -z $(grep -e "$res->{sensorsOutput}" "$NODES_PM_FILE") ]] && [[ -z $(grep -e "$res->{systemInfo}" "$NODES_PM_FILE") ]]; then + if [[ -z $(grep -e "\$res->{sensorsOutput}" "$NODES_PM_FILE") ]] && [[ -z $(grep -e "\$res->{systemInfo}" "$NODES_PM_FILE") ]]; then err "Mod is not installed." fi @@ -1725,7 +665,7 @@ function uninstall_mod { local latest_nodes_pm=$(find "$BACKUP_DIR" -name "Nodes.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') if [ -n "$latest_nodes_pm" ]; then - # Remove the latest Nodes.pm file + # Restore the latest Nodes.pm file msgb "Restoring latest Nodes.pm from backup: $latest_nodes_pm to \"$NODES_PM_FILE\"." cp "$latest_nodes_pm" "$NODES_PM_FILE" info "Restored Nodes.pm successfully." @@ -1733,11 +673,11 @@ function uninstall_mod { warn "No Nodes.pm backup files found." fi - # Find the latest pvemanagerlib.js file using the find command + # Restore original pvemanagerlib.js (uncomment the StatusView definition and remove loader) local latest_pvemanagerlibjs=$(find "$BACKUP_DIR" -name "pvemanagerlib.js.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') if [ -n "$latest_pvemanagerlibjs" ]; then - # Remove the latest pvemanagerlib.js file + # Restore the latest pvemanagerlib.js file msgb "Restoring latest pvemanagerlib.js from backup: $latest_pvemanagerlibjs to \"$PVE_MANAGER_LIB_JS_FILE\"." cp "$latest_pvemanagerlibjs" "$PVE_MANAGER_LIB_JS_FILE" info "Restored pvemanagerlib.js successfully." @@ -1745,25 +685,34 @@ function uninstall_mod { warn "No pvemanagerlib.js backup files found." fi - # Find the latest GPUMonitor.pm file using the find command - local latest_gpumonitor_pm=$(find "$BACKUP_DIR" -name "GPUMonitor.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') + # Remove UI module files + if [ -f "$PVE_MOD_JS_TARGET_FILE" ]; then + msgb "Removing UI module: $PVE_MOD_JS_TARGET_FILE" + rm "$PVE_MOD_JS_TARGET_FILE" + info "Removed UI module successfully." + else + warn "UI module file not found: $PVE_MOD_JS_TARGET_FILE" + fi + + # Remove Sensor Info Perl module + local latest_sensor_info_pm=$(find "$BACKUP_DIR" -name "PveMod_SensorInfo.pm.*" -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n 1 | awk '{print $2}') - if [ -n "$latest_gpumonitor_pm" ]; then - # Restore the latest GPUMonitor.pm file - msgb "Restoring latest GPUMonitor.pm from backup: $latest_gpumonitor_pm to \"$PVE_SENSOR_INFO_MOD_FILE\"." - cp "$latest_gpumonitor_pm" "$PVE_SENSOR_INFO_MOD_FILE" - info "Restored GPUMonitor.pm successfully." + if [ -n "$latest_sensor_info_pm" ]; then + # Restore the latest PveMod_SensorInfo.pm file (if there's a backup) + msgb "Restoring latest PveMod_SensorInfo.pm from backup: $latest_sensor_info_pm to \"$PVE_SENSOR_INFO_MOD_FILE\"." + cp "$latest_sensor_info_pm" "$PVE_SENSOR_INFO_MOD_FILE" + info "Restored PveMod_SensorInfo.pm successfully." elif [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then # No backup found but file exists, remove it - msgb "No GPUMonitor.pm backup found. Removing installed module: $PVE_SENSOR_INFO_MOD_FILE" + msgb "No PveMod_SensorInfo.pm backup found. Removing installed module: $PVE_SENSOR_INFO_MOD_FILE" rm "$PVE_SENSOR_INFO_MOD_FILE" - info "Removed GPUMonitor.pm successfully." + info "Removed PveMod_SensorInfo.pm successfully." else - warn "No GPUMonitor.pm backup files found and module not installed." + warn "No PveMod_SensorInfo.pm backup files found and module not installed." fi - if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ] || [ -n "$latest_gpumonitor_pm" ] || [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then - # At least one of the variables is not empty, restart the proxy + if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ] || [ -f "$PVE_MOD_JS_TARGET_FILE" ] || [ -n "$latest_sensor_info_pm" ] || [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then + # At least one file was modified, restart the proxy restart_proxy fi @@ -1772,11 +721,12 @@ function uninstall_mod { # Function to check if the modification is installed check_mod_installation() { - if [[ -n $(grep -F 'use PVE::API2::GPUMonitor' "$NODES_PM_FILE") ]] || \ + if [[ -n $(grep -F 'use PVE::API2::PveMod_SensorInfo' "$NODES_PM_FILE") ]] || \ + [[ -n $(grep -F 'use PVE::API2::GPUMonitor' "$NODES_PM_FILE") ]] || \ [[ -n $(grep -F '$res->{sensorsJSONOutput}' "$NODES_PM_FILE") ]] || \ [[ -n $(grep -F '$res->{sensorsOutput}' "$NODES_PM_FILE") ]] || \ - [[ -n $(grep -F '$res->{systemInfo}' "$NODES_PM_FILE") ]] && \ - [[ -n $(grep -E "itemId: 'thermal[[:alnum:]]*'" "$PVE_MANAGER_LIB_JS_FILE") ]]; then + [[ -n $(grep -F '$res->{systemInfo}' "$NODES_PM_FILE") ]] || \ + [[ -f "$PVE_MOD_JS_TARGET_FILE" ]]; then err "Mod is already installed. Uninstall existing before installing." fi } @@ -1887,7 +837,7 @@ function perform_backup { create_file_backup "$NODES_PM_FILE" "$timestamp" create_file_backup "$PVE_MANAGER_LIB_JS_FILE" "$timestamp" - # Backup GPU Monitor module if it exists + # Backup Sensor Info module if it exists if [[ -f "$PVE_SENSOR_INFO_MOD_FILE" ]]; then create_file_backup "$PVE_SENSOR_INFO_MOD_FILE" "$timestamp" fi From 9d7634d7e7ec0ad0f8ed87d4c03e8bfb06a04259 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 15:13:02 +0200 Subject: [PATCH 38/48] Remove deprecated visual code --- pve-mod-gui-sensors.sh | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 2daef36..f2fa775 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -4,14 +4,6 @@ # ################### Configuration ############# - -# Display configuration for HDD, NVME, CPU -# Set to 0 to disable line breaks -# Note: use these settings only if the displayed layout is broken -CPU_ITEMS_PER_ROW=0 -NVME_ITEMS_PER_ROW=0 -HDD_ITEMS_PER_ROW=0 - # Known CPU sensor names. They can be full or partial but should ensure unambiguous identification. # Should new ones be added, also update logic in configure() function. KNOWN_CPU_SENSORS=("coretemp-isa-" "k10temp-pci-") From 758327569489e94ba973034939c21fc3a90f7d07 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 15:33:15 +0200 Subject: [PATCH 39/48] cleanup local variables --- pve-mod-gui-sensors.sh | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index f2fa775..640177c 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -283,9 +283,10 @@ function configure { #### RAM #### #region ram setup + local ramList ramCount msgb "\n=== Detecting RAM temperature sensors ===" - local ramList=$(echo "$sanitisedSensorsOutput" | grep -o '"SODIMM[^"]*"' | sed 's/"//g' | paste -sd, -) - local ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput") + ramList=$(echo "$sanitisedSensorsOutput" | grep -o '"SODIMM[^"]*"' | sed 's/"//g' | paste -sd, -) + ramCount=$(grep -c '"SODIMM[^"]*"' <<<"$sanitisedSensorsOutput") if [ "$ramCount" -gt 0 ]; then info "Detected RAM sensors ($ramCount): $ramList" @@ -517,6 +518,7 @@ sanitize_sensors_output() { #region Sensor Monitor Module Installation # Install and configure the Sensor Monitor Perl module install_sensor_monitor_module() { + local intel_enabled nvidia_enabled ups_enabled sensors_mode # Check if source file exists if [[ ! -f "$PVE_SENSOR_INFO_SOURCE_FILE" ]]; then err "Source file not found: $PVE_SENSOR_INFO_SOURCE_FILE" @@ -527,10 +529,10 @@ install_sensor_monitor_module() { info "Copied Sensor Monitor module to $PVE_SENSOR_INFO_MOD_FILE" # Convert boolean flags to Perl format (1 or 0) - local intel_enabled=$([[ "$ENABLE_INTEL_GPU_INFO" = true ]] && echo 1 || echo 0) - local nvidia_enabled=$([[ "$ENABLE_NVIDIA_GPU_INFO" = true ]] && echo 1 || echo 0) - local ups_enabled=$([[ "$ENABLE_UPS" = true ]] && echo 1 || echo 0) - local sensors_mode=$([[ "$DEBUG_REMOTE" = true ]] && echo 1 || echo 0) + intel_enabled=$([[ "$ENABLE_INTEL_GPU_INFO" = true ]] && echo 1 || echo 0) + nvidia_enabled=$([[ "$ENABLE_NVIDIA_GPU_INFO" = true ]] && echo 1 || echo 0) + ups_enabled=$([[ "$ENABLE_UPS" = true ]] && echo 1 || echo 0) + sensors_mode=$([[ "$DEBUG_REMOTE" = true ]] && echo 1 || echo 0) # Determine UPS device name local ups_device="${upsConnection:-ups@localhost}" @@ -621,7 +623,7 @@ install_node_status_view_module() { fi # Add a dynamic script loader to load our custom module - # Insert before the commented-out Ext.define so it loads early + # Insert before the commented-out Ext.define to load it if ! grep -q "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then # Use ExtJS Loader to dynamically load our custom module sed -i "/^\/\/ Ext\.define('PVE\.node\.StatusView',/i\\ @@ -639,7 +641,6 @@ Ext.Loader.loadScript({\\ } #endregion UI Module Installation - # Function to uninstall the modification function uninstall_mod { msgb "=== Uninstalling Mod ===" From e3a21e6cbdce3eb755e14da4f78debda2cc5b695 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 16:00:33 +0200 Subject: [PATCH 40/48] add missing temp helper function --- PveMod_pvemanagerlib.js | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js index cd9ec7c..d56b397 100644 --- a/PveMod_pvemanagerlib.js +++ b/PveMod_pvemanagerlib.js @@ -1,3 +1,89 @@ +Ext.define('PVE.mod.TempHelper', { + //singleton: true, + + requires: ['Ext.util.Format'], + + statics: { + CELSIUS: 0, + FAHRENHEIT: 1 + }, + + srcUnit: null, + dstUnit: null, + + isValidUnit: function (unit) { + return ( + Ext.isNumber(unit) && (unit === this.self.CELSIUS || unit === this.self.FAHRENHEIT) + ); + }, + + constructor: function (config) { + this.srcUnit = config && this.isValidUnit(config.srcUnit) ? config.srcUnit : this.self.CELSIUS; + this.dstUnit = config && this.isValidUnit(config.dstUnit) ? config.dstUnit : this.self.CELSIUS; + }, + + toFahrenheit: function (tempCelsius) { + return Ext.isNumber(tempCelsius) + ? tempCelsius * 9 / 5 + 32 + : NaN; + }, + + toCelsius: function (tempFahrenheit) { + return Ext.isNumber(tempFahrenheit) + ? (tempFahrenheit - 32) * 5 / 9 + : NaN; + }, + + getTemp: function (value) { + if (this.srcUnit !== this.dstUnit) { + switch (this.srcUnit) { + case this.self.CELSIUS: + switch (this.dstUnit) { + case this.self.FAHRENHEIT: + return this.toFahrenheit(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + case this.self.FAHRENHEIT: + switch (this.dstUnit) { + case this.self.CELSIUS: + return this.toCelsius(value); + + default: + Ext.raise({ + msg: + 'Unsupported destination temperature unit: ' + this.dstUnit, + }); + } + default: + Ext.raise({ + msg: 'Unsupported source temperature unit: ' + this.srcUnit, + }); + } + } else { + return value; + } + }, + + getUnit: function(plainText) { + switch (this.dstUnit) { + case this.self.CELSIUS: + return plainText !== true ? '°C' : '\'C'; + + case this.self.FAHRENHEIT: + return plainText !== true ? '°F' : '\'F'; + + default: + Ext.raise({ + msg: 'Unsupported destination temperature unit: ' + this.srcUnit, + }); + } + }, +}); Ext.define('PVE.node.StatusView', { extend: 'Proxmox.panel.StatusView', alias: 'widget.pveNodeStatus', From dab7e3b8b63ac8b4a1515d35e6328df80e16714c Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 16:09:43 +0200 Subject: [PATCH 41/48] Add missing system info --- PveMod_pvemanagerlib.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js index d56b397..7c1c2e9 100644 --- a/PveMod_pvemanagerlib.js +++ b/PveMod_pvemanagerlib.js @@ -987,7 +987,20 @@ Ext.define('PVE.node.StatusView', { title: gettext('Sensor Mod Version'), textField: 'pveModVersion', value: '', - }, + }, + { + itemId: 'sysinfo', + colspan: 2, + printBar: false, + title: gettext('Information'), + textField: 'systemInfo', + renderer: function(value) { + if (value === null || value === undefined) { + return ''; + } + return value; + } + }, ], updateTitle: function () { From edd4b6b6ac4cef98ccf52f385549ace430a8de54 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 16:21:18 +0200 Subject: [PATCH 42/48] fix variable references --- PveMod_SensorInfo.pm | 4 ++-- PveMod_pvemanagerlib.js | 12 ++++++------ pve-mod-gui-sensors.sh | 26 +++++++++++++------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/PveMod_SensorInfo.pm b/PveMod_SensorInfo.pm index 6ff9e82..baa96c6 100644 --- a/PveMod_SensorInfo.pm +++ b/PveMod_SensorInfo.pm @@ -1,4 +1,4 @@ -package PVE::API2::GPUMonitor; +package PVE::API2::PVEMod_SensorInfo; use strict; use warnings; @@ -10,7 +10,7 @@ use File::Path qw(remove_tree); # debug configuration - set to 0 to disable all _debug output my $DEBUG_ENABLED = 1; -my $VERSION = '1.0.0'; +my $VERSION = '1.0'; # ============================================================================ # Configuration diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js index 7c1c2e9..9342af0 100644 --- a/PveMod_pvemanagerlib.js +++ b/PveMod_pvemanagerlib.js @@ -279,7 +279,7 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('CPU Thermal State'), iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', + textField: 'pveMod_sensorInfo_json', renderer: function(value){ // sensors configuration const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); @@ -564,7 +564,7 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('NVMe Temperatures'), iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'sensorsOutput', + textField: 'pveMod_sensorInfo_json', renderer: function(value) { // sensors configuration const addressPrefix = "nvme-pci-"; @@ -649,7 +649,7 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('System Fans'), iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'sensorsOutput', + textField: 'pveMod_sensorInfo_json', renderer: function(value) { // --- let objValue; @@ -985,7 +985,7 @@ Ext.define('PVE.node.StatusView', { colspan: 2, printBar: false, title: gettext('Sensor Mod Version'), - textField: 'pveModVersion', + textField: 'pveMod_sensorInfo_version', value: '', }, { @@ -993,7 +993,7 @@ Ext.define('PVE.node.StatusView', { colspan: 2, printBar: false, title: gettext('Information'), - textField: 'systemInfo', + textField: 'pveMod_sensorInfo_systemInfo', renderer: function(value) { if (value === null || value === undefined) { return ''; @@ -1029,4 +1029,4 @@ Ext.define('PVE.node.StatusView', { me.callParent(); }, -}); \ No newline at end of file +}); diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 640177c..fda800b 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -9,7 +9,7 @@ KNOWN_CPU_SENSORS=("coretemp-isa-" "k10temp-pci-") # Overwrite default backup location -BACKUP_DIR="" +BACKUP_DIR="/root/PVE-MOD2/Backup" ##################### DO NOT EDIT BELOW ####################### # Only to be used to debug on other systems. Save the "sensor -j" output into a json file. @@ -475,6 +475,7 @@ function install_mod { msgb "\n=== Installing sensor info module ===" install_sensor_monitor_module insert_sensor_monitor_into_pve + insert_system_info_into_pve #### Install UI modification module #### msgb "\n=== Installing UI modification module ===" @@ -567,23 +568,23 @@ insert_sensor_monitor_into_pve() { sed -i '/my \$dinfo = df('\''\/'\'', 1);/i\ # Collect sensor data from PveMod_SensorInfo\ # Bad practice to add use here, but cleaner implementation would require several extensive modifications.\ - use PVE::API2::PveMod_SensorInfo;\ - $res->{sensorsJSONOutput} = PVE::API2::PveMod_SensorInfo::get_sensors_stats();\ + use PVE::API2::PVEMod_SensorInfo;\ + $res->{pveMod_sensorInfo_json} = PVE::API2::PVEMod_SensorInfo::get_sensors_stats();\ + $res->{pveMod_sensorInfo_version} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();\ ' "$NODES_PM_FILE" #endregion PveSensorInfoMod heredoc info "Sensor data retriever added to \"$NODES_PM_FILE\"." - - # Add system information if enabled - if [[ $ENABLE_SYSTEM_INFO == true ]]; then - collect_system_info "$NODES_PM_FILE" - fi } # Collect system information -collect_system_info() { +insert_system_info_into_pve() { local output_file="$1" local systemInfoCmd + if [[ $ENABLE_SYSTEM_INFO == false ]]; then + return + fi + systemInfoCmd=$(dmidecode -t "${SYSTEM_INFO_TYPE}" \ | awk -F': ' '/Manufacturer|Product Name|Serial Number/ {print $1": "$2}' \ | awk '{$1=$1};1' \ @@ -593,7 +594,7 @@ collect_system_info() { #region system info heredoc sed -i "/my \$dinfo = df('\/', 1);/i\\ # Add system information to response\\ - \$res->{systemInfo} = \"$(echo "$systemInfoCmd")\";\\ + \$res->{pveMod_sensorInfo_systemInfo} = \"$(echo "$systemInfoCmd")\";\\ " "$NODES_PM_FILE" #endregion system info heredoc info "System information retriever added to \"$output_file\"." @@ -647,7 +648,7 @@ function uninstall_mod { check_root_privileges - if [[ -z $(grep -e "\$res->{sensorsOutput}" "$NODES_PM_FILE") ]] && [[ -z $(grep -e "\$res->{systemInfo}" "$NODES_PM_FILE") ]]; then + if [[ -z $(grep -e "\$res->{PveMod_SensorInfo_JSON}" "$NODES_PM_FILE") ]] && [[ -z $(grep -e "\$res->{systemInfo}" "$NODES_PM_FILE") ]]; then err "Mod is not installed." fi @@ -715,9 +716,8 @@ function uninstall_mod { # Function to check if the modification is installed check_mod_installation() { if [[ -n $(grep -F 'use PVE::API2::PveMod_SensorInfo' "$NODES_PM_FILE") ]] || \ - [[ -n $(grep -F 'use PVE::API2::GPUMonitor' "$NODES_PM_FILE") ]] || \ + [[ -n $(grep -F 'use PVE::API2::PVEMod_SensorInfo' "$NODES_PM_FILE") ]] || \ [[ -n $(grep -F '$res->{sensorsJSONOutput}' "$NODES_PM_FILE") ]] || \ - [[ -n $(grep -F '$res->{sensorsOutput}' "$NODES_PM_FILE") ]] || \ [[ -n $(grep -F '$res->{systemInfo}' "$NODES_PM_FILE") ]] || \ [[ -f "$PVE_MOD_JS_TARGET_FILE" ]]; then err "Mod is already installed. Uninstall existing before installing." From 890b956f39019ad78ee667282fc74ff666121fb6 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 16:56:32 +0200 Subject: [PATCH 43/48] minor fixes for method call references --- PveMod_SensorInfo.pm | 26 +++++--------------------- PveMod_pvemanagerlib.js | 26 ++++++++++++++------------ pve-mod-gui-sensors.sh | 5 +++-- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/PveMod_SensorInfo.pm b/PveMod_SensorInfo.pm index baa96c6..b04609d 100644 --- a/PveMod_SensorInfo.pm +++ b/PveMod_SensorInfo.pm @@ -19,7 +19,7 @@ my %config = ( gpu => { intel_enabled => 1, amd_enabled => 0, - nvidia_enabled => 1, + nvidia_enabled => 0, }, debug => { nvidia_mode => 1, @@ -174,22 +174,6 @@ sub _acquire_exclusive_lock { return $fh; } -sub _is_lock_stale { - my ($lock_path) = @_; - - return 0 unless open(my $fh, '<', $lock_path); - - my $lock_pid = <$fh>; - chomp $lock_pid if defined $lock_pid; - close($fh); - - # Invalid or missing PID - return 1 unless defined $lock_pid && $lock_pid =~ /^\d+$/; - - # Valid PID but process is dead - return !_is_process_alive($lock_pid); -} - sub _ensure_pve_mod_directory_exists { unless (-d $pve_mod_working_dir) { _debug(__LINE__, "Creating directory $pve_mod_working_dir"); @@ -1383,7 +1367,7 @@ sub _parse_upsc_output { # API calls # ============================================================================ -sub get_graphic_stats { +sub get_graphic_info { # todo name the process without overruling other processes _debug(__LINE__, "get_graphic_stats called"); @@ -1483,7 +1467,7 @@ sub get_graphic_stats { return $last_snapshot; } -sub get_sensors_stats { +sub get_sensors_info { _debug(__LINE__, "get_sensors_stats called"); # Start PVE Mod @@ -1516,7 +1500,7 @@ sub get_sensors_stats { return $sensors_data; } -sub get_ups_stats { +sub get_ups_info { _debug(__LINE__, "get_ups_stats called"); # Start PVE Mod @@ -2006,4 +1990,4 @@ END { } } -1; +1; \ No newline at end of file diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js index 9342af0..77cb26a 100644 --- a/PveMod_pvemanagerlib.js +++ b/PveMod_pvemanagerlib.js @@ -158,7 +158,7 @@ Ext.define('PVE.node.StatusView', { iconCls: 'fa fa-fw fa-desktop', title: gettext('GPU Usage'), printBar: false, - textField: 'gpuStats', + textField: 'PveMod_graphicsInfo', renderer: function(gpuStats) { if (!gpuStats || !gpuStats.Graphics) { return ''; @@ -279,7 +279,7 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('CPU Thermal State'), iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'pveMod_sensorInfo_json', + textField: 'PveMod_JsonSensorInfo', renderer: function(value){ // sensors configuration const cpuTempHelper = Ext.create('PVE.mod.TempHelper', {srcUnit: PVE.mod.TempHelper.CELSIUS, dstUnit: PVE.mod.TempHelper.CELSIUS}); @@ -453,7 +453,7 @@ Ext.define('PVE.node.StatusView', { iconCls: 'fa fa-fw fa-desktop', title: gettext('GPU Details'), printBar: false, - textField: 'gpuStats', + textField: 'PveMod_graphicsInfo', renderer: function(gpuStats) { if (!gpuStats || !gpuStats.Graphics) { return ''; @@ -564,7 +564,7 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('NVMe Temperatures'), iconCls: 'fa fa-fw fa-thermometer-half', - textField: 'pveMod_sensorInfo_json', + textField: 'PveMod_JsonSensorInfo', renderer: function(value) { // sensors configuration const addressPrefix = "nvme-pci-"; @@ -649,7 +649,7 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('System Fans'), iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'pveMod_sensorInfo_json', + textField: 'PveMod_JsonSensorInfo', renderer: function(value) { // --- let objValue; @@ -711,13 +711,15 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('GPU Fans'), iconCls: 'fa fa-fw fa-snowflake-o', - textField: 'gpuStats', + textField: 'PveMod_graphicsInfo', renderer: function(gpuStats) { if (!gpuStats || !gpuStats.Graphics || !gpuStats.Graphics.NVIDIA) { - return 'N/A'; + return ''; } let rows = []; + + // todo: handle intel, amd Object.keys(gpuStats.Graphics.NVIDIA).sort().forEach(key => { const gpuData = gpuStats.Graphics.NVIDIA[key]; @@ -737,7 +739,7 @@ Ext.define('PVE.node.StatusView', { '', ); }); - + if (rows.length === 0) { return 'N/A'; } @@ -751,7 +753,7 @@ Ext.define('PVE.node.StatusView', { printBar: false, title: gettext('UPS Status'), iconCls: 'fa fa-fw fa-battery-three-quarters', - textField: 'upsStats', + textField: 'PveMod_upsInfo', renderer: function(value) { let objValue = {}; try { @@ -985,7 +987,7 @@ Ext.define('PVE.node.StatusView', { colspan: 2, printBar: false, title: gettext('Sensor Mod Version'), - textField: 'pveMod_sensorInfo_version', + textField: 'PveMod_Version', value: '', }, { @@ -993,7 +995,7 @@ Ext.define('PVE.node.StatusView', { colspan: 2, printBar: false, title: gettext('Information'), - textField: 'pveMod_sensorInfo_systemInfo', + textField: 'PveMod_systemInfo', renderer: function(value) { if (value === null || value === undefined) { return ''; @@ -1029,4 +1031,4 @@ Ext.define('PVE.node.StatusView', { me.callParent(); }, -}); +}); \ No newline at end of file diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index fda800b..bbdfd73 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -569,8 +569,9 @@ insert_sensor_monitor_into_pve() { # Collect sensor data from PveMod_SensorInfo\ # Bad practice to add use here, but cleaner implementation would require several extensive modifications.\ use PVE::API2::PVEMod_SensorInfo;\ - $res->{pveMod_sensorInfo_json} = PVE::API2::PVEMod_SensorInfo::get_sensors_stats();\ - $res->{pveMod_sensorInfo_version} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();\ + $res->{PveMod_JsonSensorInfo} = PVE::API2::PVEMod_SensorInfo::get_sensors_info();\ + $res->{PveMod_graphicsInfo} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();\ + $res->{PveMod_upsInfo} = PVE::API2::PVEMod_SensorInfo::get_ups_info();\ ' "$NODES_PM_FILE" #endregion PveSensorInfoMod heredoc info "Sensor data retriever added to \"$NODES_PM_FILE\"." From 368d91f569833955b5bc3344c415f38384b1ebb0 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 17:08:04 +0200 Subject: [PATCH 44/48] don't log starting of PveNodeStatusView module --- pve-mod-gui-sensors.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index bbdfd73..4e43457 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -632,7 +632,7 @@ install_node_status_view_module() { // Load custom PVE.node.StatusView from external module\\ Ext.Loader.loadScript({\\ url: '/pve2/js/PveMod_PveNodeStatusView.js',\\ - onLoad: function() { console.log('Loaded PveMod_PveNodeStatusView.js'); },\\ + onLoad: function() { },\\ onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); }\\ });\\ " "$PVE_MANAGER_LIB_JS_FILE" From e5ab4eb42affe5291c0aa1a96fc3ac93413430bd Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 17:20:41 +0200 Subject: [PATCH 45/48] change sub heading --- PveMod_pvemanagerlib.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js index 77cb26a..d87059b 100644 --- a/PveMod_pvemanagerlib.js +++ b/PveMod_pvemanagerlib.js @@ -258,7 +258,7 @@ Ext.define('PVE.node.StatusView', { xtype: 'box', colspan: 2, padding: '15 0 5 0', - html: '
    Secondary Details
    ', + html: '
    Secondary Metrics
    ', }, { itemId: 'load', From ec0ec5fc4ab38122a444b07160ccbf436e66f35e Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 20:31:33 +0200 Subject: [PATCH 46/48] first implementation of displaying graphed GPU data --- PveMod_SensorInfo.pm | 149 +++++++++++- PveMod_pvemanagerlib.js | 502 +++++++++++++++++++++++++++++++++++++++- pve-mod-gui-sensors.sh | 120 ++++++++++ 3 files changed, 763 insertions(+), 8 deletions(-) diff --git a/PveMod_SensorInfo.pm b/PveMod_SensorInfo.pm index b04609d..9cdb483 100644 --- a/PveMod_SensorInfo.pm +++ b/PveMod_SensorInfo.pm @@ -6,7 +6,9 @@ use JSON; use POSIX qw(WNOHANG); use Time::HiRes qw(time); use Fcntl qw(:flock O_CREAT O_EXCL O_WRONLY); -use File::Path qw(remove_tree); +use File::Path qw(remove_tree make_path); +use PVE::INotify; +use RRDs; # debug configuration - set to 0 to disable all _debug output my $DEBUG_ENABLED = 1; @@ -508,6 +510,7 @@ sub _collector_for_intel_device { # Write to device-specific file _safe_write_json($device_state_file, $device_data); + _update_intel_gpu_rrd($device->{card}, $stats); } } } @@ -517,14 +520,145 @@ sub _collector_for_intel_device { exit 0; } -# Parse information for graphical presentation. -sub _parse_graphic_info { - my ($line) = @_; +# ============================================================================ +# RRD Support for GPU metrics +# ============================================================================ - # Create a RRD Database (One-Time Setup) +my $RRD_SOCKET = '/var/run/rrdcached.sock'; +my $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu'; - # Collect intel GPU data and save it into the database +sub _get_nodename { + return PVE::INotify::nodename(); +} + +sub _gpu_rrd_path { + my ($card) = @_; + return "$RRD_BASE/" . _get_nodename() . "/$card"; +} + +sub _ensure_intel_gpu_rrd { + my ($card) = @_; + my $path = _gpu_rrd_path($card); + return if -f $path; + + my $dir = "$RRD_BASE/" . _get_nodename(); + make_path($dir, { mode => 0755 }) unless -d $dir; + + RRDs::create( + $path, + '--step', '1', + 'DS:freq_req:GAUGE:120:0:U', + 'DS:freq_act:GAUGE:120:0:U', + 'DS:rc6:GAUGE:120:0:100', + 'DS:power_gpu:GAUGE:120:0:U', + 'DS:power_pkg:GAUGE:120:0:U', + 'DS:render_busy:GAUGE:120:0:100', + 'DS:blitter_busy:GAUGE:120:0:100', + 'DS:video_busy:GAUGE:120:0:100', + 'DS:videnh_busy:GAUGE:120:0:100', + 'RRA:AVERAGE:0.5:1:1440', + 'RRA:AVERAGE:0.5:60:1440', + 'RRA:AVERAGE:0.5:1800:1344', + 'RRA:AVERAGE:0.5:21600:1464', + 'RRA:AVERAGE:0.5:604800:520', + 'RRA:MAX:0.5:1:1440', + 'RRA:MAX:0.5:60:1440', + 'RRA:MAX:0.5:1800:1344', + 'RRA:MAX:0.5:21600:1464', + 'RRA:MAX:0.5:604800:520', + ); + my $err = RRDs::error(); + _debug(__LINE__, "Created Intel GPU RRD $path: " . ($err // 'OK')); +} +sub _update_intel_gpu_rrd { + my ($card, $stats) = @_; + _ensure_intel_gpu_rrd($card); + my $path = _gpu_rrd_path($card); + + my $freq_req = $stats->{frequency}{requested} // 'U'; + my $freq_act = $stats->{frequency}{actual} // 'U'; + my $rc6 = $stats->{rc6}{value} // 'U'; + my $power_gpu = $stats->{power}{GPU} // 'U'; + my $power_pkg = $stats->{power}{Package} // 'U'; + my $render_busy = $stats->{engines}{'Render/3D'}{busy} // 'U'; + my $blitter = $stats->{engines}{Blitter}{busy} // 'U'; + my $video = $stats->{engines}{Video}{busy} // 'U'; + my $videnh = $stats->{engines}{VideoEnhance}{busy} // 'U'; + + my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); + RRDs::update( + $path, + @daemon_args, + "N:$freq_req:$freq_act:$rc6:$power_gpu:$power_pkg:$render_busy:$blitter:$video:$videnh", + ); + my $err = RRDs::error(); + _debug(__LINE__, "RRD update intel $card: $err") if $err; +} + +sub _ensure_nvidia_gpu_rrd { + my ($index) = @_; + my $card = "nvidia$index"; + my $path = _gpu_rrd_path($card); + return if -f $path; + + my $dir = "$RRD_BASE/" . _get_nodename(); + make_path($dir, { mode => 0755 }) unless -d $dir; + + RRDs::create( + $path, + '--step', '1', + 'DS:gpu_util:GAUGE:120:0:100', + 'DS:mem_util:GAUGE:120:0:100', + 'DS:mem_used:GAUGE:120:0:U', + 'DS:mem_total:GAUGE:120:0:U', + 'DS:power_draw:GAUGE:120:0:U', + 'DS:power_limit:GAUGE:120:0:U', + 'DS:temp_gpu:GAUGE:120:0:U', + 'DS:fan_speed:GAUGE:120:0:100', + 'RRA:AVERAGE:0.5:1:1440', + 'RRA:AVERAGE:0.5:60:1440', + 'RRA:AVERAGE:0.5:1800:1344', + 'RRA:AVERAGE:0.5:21600:1464', + 'RRA:AVERAGE:0.5:604800:520', + 'RRA:MAX:0.5:1:1440', + 'RRA:MAX:0.5:60:1440', + 'RRA:MAX:0.5:1800:1344', + 'RRA:MAX:0.5:21600:1464', + 'RRA:MAX:0.5:604800:520', + ); + my $err = RRDs::error(); + _debug(__LINE__, "Created NVIDIA GPU RRD $path: " . ($err // 'OK')); +} + +sub _update_nvidia_gpu_rrd { + my ($index, $stats) = @_; + _ensure_nvidia_gpu_rrd($index); + my $card = "nvidia$index"; + my $path = _gpu_rrd_path($card); + + my $gpu_util = $stats->{utilization}{gpu} // 'U'; + my $mem_util = $stats->{utilization}{memory} // 'U'; + my $mem_used = $stats->{memory}{used} // 'U'; + my $mem_total = $stats->{memory}{total} // 'U'; + my $power_draw = $stats->{power}{draw} // 'U'; + my $power_limit = $stats->{power}{limit} // 'U'; + my $temp_gpu = $stats->{temperature}{gpu} // 'U'; + my $fan_speed = $stats->{fan}{speed} // 'U'; + + my @daemon_args = (-S $RRD_SOCKET) ? ('--daemon', "unix:$RRD_SOCKET") : (); + RRDs::update( + $path, + @daemon_args, + "N:$gpu_util:$mem_util:$mem_used:$mem_total:$power_draw:$power_limit:$temp_gpu:$fan_speed", + ); + my $err = RRDs::error(); + _debug(__LINE__, "RRD update nvidia$index: $err") if $err; +} + +# Parse information for graphical presentation. +sub _parse_graphic_info { + my ($line) = @_; return undef; } @@ -752,6 +886,7 @@ sub _get_and_write_nvidia_stats { # Write to device-specific file _safe_write_json($device_state_file, $device_data); + _update_nvidia_gpu_rrd($device_index, $stats); } unless (@all_stats) { @@ -1990,4 +2125,4 @@ END { } } -1; \ No newline at end of file +1; diff --git a/PveMod_pvemanagerlib.js b/PveMod_pvemanagerlib.js index d87059b..39504d5 100644 --- a/PveMod_pvemanagerlib.js +++ b/PveMod_pvemanagerlib.js @@ -624,7 +624,7 @@ Ext.define('PVE.node.StatusView', { nvmeData.forEach((data) => { let deviceName = data.model; if (data.serial) { - deviceName += ` (${data.serial})`; + deviceName += ` (${data.serial})`; } html += ''; html += `${deviceName}`; @@ -1031,4 +1031,504 @@ Ext.define('PVE.node.StatusView', { me.callParent(); }, +}); + +Ext.define('pve-rrd-gpu', { + extend: 'Ext.data.Model', + fields: [ + 'freq_req', 'freq_act', 'rc6', + 'power_gpu', 'power_pkg', + 'render_busy', 'blitter_busy', 'video_busy', 'videnh_busy', + 'gpu_util', 'mem_util', 'mem_used', 'mem_total', + 'power_draw', 'power_limit', 'temp_gpu', 'fan_speed', + { type: 'date', dateFormat: 'timestamp', name: 'time' }, + ], +}); + +Ext.define('PVE.data.GpuRRDStore', { + extend: 'Proxmox.data.RRDStore', + alias: 'store.pveGpuRRDStore', + + model: 'pve-rrd-gpu', + card: undefined, + + setRRDUrl: function(timeframe, cf) { + var me = this; + if (!me.rrdurl) { return; } + if (!timeframe) { timeframe = me.timeframe; } + if (!cf) { cf = me.cf; } + me.proxy.url = me.rrdurl + + '?card=' + encodeURIComponent(me.card) + + '&timeframe=' + timeframe + + '&cf=' + cf; + }, +}); + +Ext.define('PVE.node.GpuRRD', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeGpuRRD', + + layout: 'fit', + title: 'GPU', + + initComponent: function() { + var me = this; + + var nodename = me.nodename; + var card = me.card || 'card0'; + var baseurl = '/api2/json/nodes/' + nodename + '/gpurrddata'; + var isNvidia = card.indexOf('nvidia') === 0; + + var store = Ext.create('PVE.data.GpuRRDStore', { + rrdurl: baseurl, + card: card, + }); + + var items; + if (isNvidia) { + items = [ + { + xtype: 'proxmoxRRDChart', + title: 'GPU & Memory Utilization', + fields: ['gpu_util', 'mem_util'], + fieldTitles: ['GPU %', 'Memory %'], + unit: 'percent', + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Memory Usage (MiB)', + fields: ['mem_used', 'mem_total'], + fieldTitles: ['Used', 'Total'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Power Draw (W)', + fields: ['power_draw', 'power_limit'], + fieldTitles: ['Draw', 'Limit'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Temperature & Fan', + fields: ['temp_gpu', 'fan_speed'], + fieldTitles: ['Temp (°C)', 'Fan %'], + store: store, + }, + ]; + } else { + items = [ + { + xtype: 'proxmoxRRDChart', + title: 'GPU Frequency (MHz)', + fields: ['freq_req', 'freq_act'], + fieldTitles: ['Requested', 'Actual'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Engine Busy', + fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], + fieldTitles: ['Render/3D %', 'Blitter %', 'Video %', 'VideoEnh %'], + unit: 'percent', + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'Power (W)', + fields: ['power_gpu', 'power_pkg'], + fieldTitles: ['GPU', 'Package'], + store: store, + }, + { + xtype: 'proxmoxRRDChart', + title: 'RC6 Residency', + fields: ['rc6'], + fieldTitles: ['RC6 %'], + unit: 'percent', + store: store, + }, + ]; + } + + Ext.apply(me, { + items: [{ + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + items: items, + }], + }); + + me.callParent(); + + me.on('activate', function() { store.startUpdate(); }); + me.on('deactivate', function() { store.stopUpdate(); }); + me.on('destroy', function() { store.stopUpdate(); }); + }, +}); + +Ext.define('PVE.node.Summary', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveNodeSummary', + + scrollable: true, + bodyPadding: 5, + + showVersions: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + + var view = Ext.createWidget('component', { + autoScroll: true, + id: 'pkgversions', + padding: 5, + style: { + 'white-space': 'pre', + 'font-family': 'monospace', + }, + }); + + var win = Ext.create('Ext.window.Window', { + title: gettext('Package versions'), + width: 600, + height: 600, + layout: 'fit', + modal: true, + items: [view], + buttons: [ + { + xtype: 'button', + iconCls: 'fa fa-clipboard', + handler: function (button) { + window + .getSelection() + .selectAllChildren(document.getElementById('pkgversions')); + document.execCommand('copy'); + }, + text: gettext('Copy'), + }, + { + text: gettext('Ok'), + handler: function () { + this.up('window').close(); + }, + }, + ], + }); + + Proxmox.Utils.API2Request({ + waitMsgTarget: me, + url: `/nodes/${nodename}/apt/versions`, + method: 'GET', + failure: function (response, opts) { + win.close(); + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function (response, opts) { + win.show(); + let text = ''; + Ext.Array.each(response.result.data, function (rec) { + let version = 'not correctly installed'; + let pkg = rec.Package; + if (rec.OldVersion && rec.CurrentState === 'Installed') { + version = rec.OldVersion; + } + if (rec.RunningKernel) { + text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; + } else if (rec.ManagerVersion) { + text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; + } else { + text += `${pkg}: ${version}\n`; + } + }); + + view.update(Ext.htmlEncode(text)); + }, + }); + }, + + updateRepositoryStatus: function () { + let me = this; + let repoStatus = me.nodeStatus.down('#repositoryStatus'); + + let nodename = me.pveSelNode.data.node; + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/apt/repositories`, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: (response) => + repoStatus.setRepositoryInfo(response.result.data['standard-repos']), + }); + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/subscription`, + method: 'GET', + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function (response, opts) { + const res = response.result; + const subscription = res?.data?.status.toLowerCase() === 'active'; + repoStatus.setSubscriptionStatus(subscription); + }, + }); + }, + + initComponent: function () { + var me = this; + + var nodename = me.pveSelNode.data.node; + if (!nodename) { + throw 'no node name specified'; + } + + if (!me.statusStore) { + throw 'no status storage specified'; + } + + var rstore = me.statusStore; + + var version_btn = new Ext.Button({ + text: gettext('Package versions'), + handler: function () { + Proxmox.Utils.checked_command(function () { + me.showVersions(); + }); + }, + }); + + var rrdstore = Ext.create('Proxmox.data.RRDStore', { + rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', + model: 'pve-rrd-node', + }); + + var gpurrdstore = Ext.create('PVE.data.GpuRRDStore', { + rrdurl: '/api2/json/nodes/' + nodename + '/gpurrddata', + card: 'card0', + }); + + let nodeStatus = Ext.create('PVE.node.StatusView', { + xtype: 'pveNodeStatus', + rstore: rstore, + width: 770, + pveSelNode: me.pveSelNode, + }); + + Ext.apply(me, { + tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], + nodeStatus: nodeStatus, + items: [ + { + xtype: 'container', + itemId: 'itemcontainer', + layout: 'column', + minWidth: 700, + defaults: { + minHeight: 360, + padding: 5, + columnWidth: 1, + }, + items: [ + nodeStatus, + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Usage'), + fields: ['cpu', 'iowait'], + fieldTitles: [gettext('CPU usage'), gettext('IO delay')], + unit: 'percent', + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Server Load'), + fields: ['loadavg'], + fieldTitles: [gettext('Load average')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory usage'), + fields: [ + { + yField: 'memtotal', + title: gettext('Total'), + tooltip: { + trackMouse: true, + renderer: function (toolTip, record, item) { + let value = record.get('memtotal'); + + if (value === null) { + toolTip.setHtml(gettext('No Data')); + } else { + let total = Proxmox.Utils.format_size(value); + let time = new Date(record.get('time')); + + let avail = record.get('memavailable'); + let availText = ''; + if (Ext.isNumeric(avail)) { + let v = Proxmox.Utils.format_size(avail); + availText = ` (${gettext('Available')}: ${v})`; + } + + toolTip.setHtml( + `${gettext('Total')}: ${total}${availText}
    ${time}`, + ); + } + }, + }, + }, + { + yField: 'memused', + title: gettext('Used'), + tooltip: { + trackMouse: true, + renderer: function (toolTip, record, item) { + let value = record.get('memused'); + + if (value === null) { + toolTip.setHtml(gettext('No Data')); + } else { + let total = Proxmox.Utils.format_size(value); + let time = new Date(record.get('time')); + + let arc = record.get('arcsize'); + let arcText = ''; + if (Ext.isNumeric(arc) && arc > 1024 * 1024) { + let v = Proxmox.Utils.format_size(value - arc); + arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; + } + + toolTip.setHtml( + `${gettext('Used')}: ${total}${arcText}
    ${time}`, + ); + } + }, + }, + }, + 'arcsize', + { + type: 'line', + fill: false, + yField: 'memavailable', + title: gettext('Available'), + style: { + lineWidth: 2.5, + opacity: 1, + }, + }, + ], + fieldTitles: [ + gettext('Total'), + gettext('Used'), + gettext('ZFS ARC'), + gettext('Available'), + ], + colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], + unit: 'bytes', + powerOfTwo: true, + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Network Traffic'), + fields: ['netin', 'netout'], + fieldTitles: [gettext('Incoming'), gettext('Outgoing')], + store: rrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('CPU Pressure Stall'), + fieldTitles: ['Some'], + fields: ['pressurecpusome'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('IO Pressure Stall'), + fieldTitles: ['Some', 'Full'], + fields: ['pressureiosome', 'pressureiofull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('Memory Pressure Stall'), + fieldTitles: ['Some', 'Full'], + fields: ['pressurememorysome', 'pressurememoryfull'], + colors: ['#FFD13E', '#A61120'], + store: rrdstore, + unit: 'percent', + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Frequency (MHz)'), + fields: ['freq_req', 'freq_act'], + fieldTitles: [gettext('Requested'), gettext('Actual')], + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Engine Busy'), + fields: ['render_busy', 'blitter_busy', 'video_busy', 'videnh_busy'], + fieldTitles: [gettext('Render/3D'), gettext('Blitter'), gettext('Video'), gettext('VideoEnh')], + unit: 'percent', + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU Power (W)'), + fields: ['power_gpu', 'power_pkg'], + fieldTitles: [gettext('GPU'), gettext('Package')], + store: gpurrdstore, + }, + { + xtype: 'proxmoxRRDChart', + title: gettext('GPU RC6 Residency'), + fields: ['rc6'], + fieldTitles: [gettext('RC6 %')], + unit: 'percent', + store: gpurrdstore, + }, + ], + listeners: { + resize: function (panel) { + Proxmox.Utils.updateColumns(panel); + }, + }, + }, + ], + listeners: { + activate: function () { + rstore.setInterval(1000); + rstore.startUpdate(); + rrdstore.startUpdate(); + gpurrdstore.startUpdate(); + }, + destroy: function () { + rstore.setInterval(5000); + rrdstore.stopUpdate(); + gpurrdstore.stopUpdate(); + }, + }, + }); + + me.updateRepositoryStatus(); + + me.callParent(); + + let sp = Ext.state.Manager.getProvider(); + me.mon(sp, 'statechange', function (provider, key, value) { + if (key !== 'summarycolumns') { + return; + } + Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); + }); + }, }); \ No newline at end of file diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 4e43457..22e0e86 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -35,6 +35,7 @@ PVE_MOD_JS_TARGET_FILE="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" NODES_PM_FILE="/usr/share/perl5/PVE/API2/Nodes.pm" PVE_SENSOR_INFO_MOD_FILE="/usr/share/perl5/PVE/API2/PveMod_SensorInfo.pm" PVE_SENSOR_INFO_SOURCE_FILE="$SCRIPT_CWD/PveMod_SensorInfo.pm" +GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" #region message tools # Section header (bold) @@ -281,6 +282,28 @@ function configure { #endregion Graphics setup + #### GPU Historical Data #### + #region gpu history setup + ENABLE_GPU_HISTORY=false + if [[ "$ENABLE_GPU_INFO" == true ]]; then + msgb "\n=== GPU Historical Data ===" + local choiceGpuHistory + choiceGpuHistory=$(ask "Store historical GPU data for graphs? (y/N)") + case "$choiceGpuHistory" in + [yY]) + ENABLE_GPU_HISTORY=true + info "Historical GPU data will be stored." + ;; + [nN]|"") + info "Historical GPU data will not be stored." + ;; + *) + warn "Invalid selection. Historical GPU data will not be stored." + ;; + esac + fi + #endregion gpu history setup + #### RAM #### #region ram setup local ramList ramCount @@ -477,6 +500,11 @@ function install_mod { insert_sensor_monitor_into_pve insert_system_info_into_pve + ## Historical GPU data ## + if [[ "$ENABLE_GPU_HISTORY" == true ]]; then + install_gpu_history + fi + #### Install UI modification module #### msgb "\n=== Installing UI modification module ===" install_node_status_view_module @@ -624,6 +652,14 @@ install_node_status_view_module() { warn "Original StatusView definition not found in expected format in pvemanagerlib.js" fi + # Comment out the original PVE.node.Summary definition in pvemanagerlib.js + if grep -q "^Ext.define('PVE.node.Summary'," "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then + sed -i "/^Ext\.define('PVE\.node\.Summary',/,/^});/s|^|// |" "$PVE_MANAGER_LIB_JS_FILE" + info "Commented out original Summary definition in pvemanagerlib.js" + else + warn "Original Summary definition not found in expected format in pvemanagerlib.js" + fi + # Add a dynamic script loader to load our custom module # Insert before the commented-out Ext.define to load it if ! grep -q "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_LIB_JS_FILE" 2>/dev/null; then @@ -643,6 +679,83 @@ Ext.Loader.loadScript({\\ } #endregion UI Module Installation + +#region historical GPU data +# Install GPU historical data storage: RRD directory + API endpoint in Nodes.pm +install_gpu_history() { + msgb "\n=== Installing GPU historical data support ===" + + # Create and configure the RRD directory + mkdir -p "$GPU_RRD_DIR" || err "Failed to create GPU RRD directory: $GPU_RRD_DIR" + chown www-data:www-data "$GPU_RRD_DIR" || err "Failed to set ownership on: $GPU_RRD_DIR" + info "GPU RRD directory ready: $GPU_RRD_DIR" + + # Register gpurrddata in the node method list + sed -i "s/{ name => 'rrddata' },/{ name => 'rrddata' },\n { name => 'gpurrddata' },/" "$NODES_PM_FILE" \ + || err "Failed to register gpurrddata method in $NODES_PM_FILE" + + # Insert the gpurrddata API method definition after the rrddata method + sed -i "/\"pve-node-9\.0\/\$param->{node}\", \$param->{timeframe}, \$param->{cf},/{ +n +n +a\\ +\\ +__PACKAGE__->register_method({\\ + name => 'gpurrddata',\\ + path => 'gpurrddata',\\ + method => 'GET',\\ + protected => 1,\\ + proxyto => 'node',\\ + permissions => {\\ + check => ['perm', '/nodes/{node}', ['Sys.Audit']],\\ + },\\ + description => \"Read GPU RRD statistics\",\\ + parameters => {\\ + additionalProperties => 0,\\ + properties => {\\ + node => get_standard_option('pve-node'),\\ + card => {\\ + description => \"The GPU card identifier (e.g. card0, nvidia0).\",\\ + type => 'string',\\ + pattern => '[a-zA-Z0-9]+',\\ + },\\ + timeframe => {\\ + description => \"Specify the time frame you are interested in.\",\\ + type => 'string',\\ + enum => ['hour', 'day', 'week', 'month', 'year', 'decade'],\\ + },\\ + cf => {\\ + description => \"The RRD consolidation function\",\\ + type => 'string',\\ + enum => ['AVERAGE', 'MAX'],\\ + optional => 1,\\ + },\\ + },\\ + },\\ + returns => {\\ + type => \"array\",\\ + items => {\\ + type => \"object\",\\ + properties => {},\\ + },\\ + },\\ + code => sub {\\ + my (\$param) = \@_;\\ + my \$nodename = PVE::INotify::nodename();\\ + my \$card = \$param->{card};\\ + die \"invalid card name\\n\" unless \$card =~ /^[a-zA-Z0-9]+\$/;\\ + return PVE::RRD::create_rrd_data(\\ + \"pve-mod-gpu/\$nodename/\$card\", \$param->{timeframe}, \$param->{cf},\\ + );\\ + },\\ +}); +}" "$NODES_PM_FILE" \ + || err "Failed to insert gpurrddata method in $NODES_PM_FILE" + + info "GPU historical data API endpoint added to $NODES_PM_FILE" +} +#endregion historical GPU data + # Function to uninstall the modification function uninstall_mod { msgb "=== Uninstalling Mod ===" @@ -706,6 +819,13 @@ function uninstall_mod { warn "No PveMod_SensorInfo.pm backup files found and module not installed." fi + # Remove GPU RRD directory (contains historical GPU data — destroyed permanently on uninstall) + if [[ -d "$GPU_RRD_DIR" ]]; then + msgb "Removing GPU RRD directory: $GPU_RRD_DIR" + rm -rf "$GPU_RRD_DIR" + info "Removed GPU RRD directory." + fi + if [ -n "$latest_nodes_pm" ] || [ -n "$latest_pvemanagerlibjs" ] || [ -f "$PVE_MOD_JS_TARGET_FILE" ] || [ -n "$latest_sensor_info_pm" ] || [ -f "$PVE_SENSOR_INFO_MOD_FILE" ]; then # At least one file was modified, restart the proxy restart_proxy From 6d5b87e2025d4216f8bbab52abc7eedd86e45579 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 20:39:11 +0200 Subject: [PATCH 47/48] remove unused code --- PveMod_SensorInfo.pm | 6 ------ 1 file changed, 6 deletions(-) diff --git a/PveMod_SensorInfo.pm b/PveMod_SensorInfo.pm index 9cdb483..90bbef7 100644 --- a/PveMod_SensorInfo.pm +++ b/PveMod_SensorInfo.pm @@ -656,12 +656,6 @@ sub _update_nvidia_gpu_rrd { _debug(__LINE__, "RRD update nvidia$index: $err") if $err; } -# Parse information for graphical presentation. -sub _parse_graphic_info { - my ($line) = @_; - return undef; -} - # ============================================================================ # AMD GPU Support (Placeholder) # ============================================================================ From d3e3d68a814f9480d5dc75c501fcdb765b40aaad Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 6 Apr 2026 21:00:26 +0200 Subject: [PATCH 48/48] minor changes --- PveMod_SensorInfo.pm | 5 ++--- pve-mod-gui-sensors.sh | 5 +++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/PveMod_SensorInfo.pm b/PveMod_SensorInfo.pm index 90bbef7..6ecd528 100644 --- a/PveMod_SensorInfo.pm +++ b/PveMod_SensorInfo.pm @@ -55,6 +55,8 @@ my $sensors_state_file = "$pve_mod_working_dir/sensors.json"; my $ups_state_file = "$pve_mod_working_dir/ups.json"; my $pve_mod_worker_lock = "$pve_mod_working_dir/pve_mod_worker.lock"; my $startup_lock = "$pve_mod_working_dir/startup.lock"; +my $RRD_SOCKET = '/var/run/rrdcached.sock'; +my $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu'; # Runtime state variables my $process_type = 'main'; # 'main', 'worker', or 'collector' @@ -524,9 +526,6 @@ sub _collector_for_intel_device { # RRD Support for GPU metrics # ============================================================================ -my $RRD_SOCKET = '/var/run/rrdcached.sock'; -my $RRD_BASE = '/var/lib/rrdcached/db/pve-mod-gpu'; - sub _get_nodename { return PVE::INotify::nodename(); } diff --git a/pve-mod-gui-sensors.sh b/pve-mod-gui-sensors.sh index 22e0e86..d7beb34 100644 --- a/pve-mod-gui-sensors.sh +++ b/pve-mod-gui-sensors.sh @@ -503,6 +503,11 @@ function install_mod { ## Historical GPU data ## if [[ "$ENABLE_GPU_HISTORY" == true ]]; then install_gpu_history + + # todo + + # add nodes.pm index list — add gpumeta after gpurrddata: + fi #### Install UI modification module ####