Showing 1 changed files with 104 additions and 5 deletions
+104 -5
scripts/host_manager.pl
@@ -202,6 +202,10 @@ sub handle_client {
202 202
             my $payload = request_payload(\%headers, $body);
203 203
             return delete_host($client, $payload->{id} || '');
204 204
         }
205
+        if ($path eq '/api/vhosts/reassign') {
206
+            my $payload = request_payload(\%headers, $body);
207
+            return reassign_vhost($client, $payload);
208
+        }
205 209
         if ($path eq '/api/work-orders/confirm') {
206 210
             my $payload = request_payload(\%headers, $body);
207 211
             return confirm_work_order($client, $payload);
@@ -464,6 +468,48 @@ sub delete_host {
464 468
     return send_json($client, 200, { ok => json_bool(1) });
465 469
 }
466 470
 
471
+sub reassign_vhost {
472
+    my ($client, $payload) = @_;
473
+    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
474
+    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
475
+    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
476
+    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
477
+
478
+    my $dbh = dbh();
479
+    my ($current_fqdn) = $dbh->selectrow_array(
480
+        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
481
+        undef,
482
+        $vhost,
483
+    );
484
+    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
485
+    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
486
+    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
487
+
488
+    my $result = eval {
489
+        with_transaction($dbh, sub {
490
+            my $now = iso_now();
491
+            $dbh->do(
492
+                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
493
+                undef,
494
+                $target_fqdn, $now, $vhost,
495
+            );
496
+
497
+            my $registry = load_registry_from_db();
498
+            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
499
+            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
500
+
501
+            upsert_host_to_db($dbh, $target_host) if $target_host;
502
+            upsert_host_to_db($dbh, $current_host) if $current_host;
503
+        });
504
+        1;
505
+    };
506
+    if (!$result) {
507
+        my $err = $@ || 'vhost_reassign_failed';
508
+        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
509
+    }
510
+    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
511
+}
512
+
467 513
 sub analyze_hosts {
468 514
     my ($hosts) = @_;
469 515
     my @problems;
@@ -2715,9 +2761,12 @@ sub app_html {
2715 2761
     #page-vhosts .host-tools input { max-width: 280px; }
2716 2762
     #page-vhosts .stats { justify-content: flex-end; }
2717 2763
     .vhost-host { display: grid; gap: 2px; }
2764
+    .vhost-host-actions { display: flex; align-items: center; gap: 6px; }
2765
+    .vhost-host-actions button { min-height: 28px; padding: 4px 8px; font-size: 12px; }
2718 2766
     .vhost-host .mono { font-size: 11px; line-height: 1.2; color: var(--muted); }
2719 2767
     .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
2720 2768
     .vhost-pill-row .pill { margin: 0; }
2769
+    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
2721 2770
     .modal-backdrop {
2722 2771
       position: fixed;
2723 2772
       inset: 0;
@@ -3520,17 +3569,46 @@ sub app_html {
3520 3569
       ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3521 3570
       $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3522 3571
         <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
3523
-        <td><div class="vhost-host"><button type="button" data-edit-vhost-host="${escapeHtml(row.host_id)}">${escapeHtml(row.host_id)}</button><div class="mono">${escapeHtml(row.host_fqdn)}</div></div></td>
3572
+        <td>
3573
+          <div class="vhost-host">
3574
+            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
3575
+              ${renderVhostHostOptions(row.host_fqdn)}
3576
+            </select>
3577
+            <div class="vhost-host-actions">
3578
+              <button type="button" data-vhost-apply="${escapeHtml(row.vhost)}">Move</button>
3579
+            </div>
3580
+            <div class="mono">${escapeHtml(row.host_id)}</div>
3581
+          </div>
3582
+        </td>
3524 3583
         <td>${escapeHtml(row.ip)}</td>
3525 3584
         <td><div class="vhost-pill-row">${row.derived_aliases.map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('')}</div></td>
3526 3585
         <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3527 3586
         <td>${escapeHtml(row.status)}</td>
3528 3587
       </tr>`).join('') : '<tr><td colspan="6" class="muted">No vhosts.</td></tr>';
3529
-      document.querySelectorAll('[data-edit-vhost-host]').forEach(button => button.addEventListener('click', () => {
3530
-        editHost(button.dataset.editVhostHost).catch(e => {
3531
-          if (!isAuthLost(e)) msg(e.message);
3588
+      document.querySelectorAll('[data-vhost-apply]').forEach(button => {
3589
+        button.addEventListener('click', () => {
3590
+          const vhost = button.dataset.vhostApply || '';
3591
+          const select = document.querySelector(`[data-vhost-select="${vhost}"]`);
3592
+          if (!select) return;
3593
+          reassignVhostFromSelect(select, button).catch(e => {
3594
+            if (!isAuthLost(e)) msg(e.message);
3595
+            select.value = select.dataset.currentHost || '';
3596
+          });
3532 3597
         });
3533
-      }));
3598
+      });
3599
+    }
3600
+
3601
+    function renderVhostHostOptions(selectedHostFqdn) {
3602
+      return state.hosts
3603
+        .slice()
3604
+        .filter(host => (host.status || '') !== 'retired')
3605
+        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3606
+        .map(host => {
3607
+          const fqdn = host.fqdn || '';
3608
+          const label = [host.id || '', fqdn].filter(Boolean).join(' — ');
3609
+          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
3610
+          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(label)}</option>`;
3611
+        }).join('');
3534 3612
     }
3535 3613
 
3536 3614
     function shortAliasForFqdn(name) {
@@ -3539,6 +3617,27 @@ sub app_html {
3539 3617
       return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
3540 3618
     }
3541 3619
 
3620
+    async function reassignVhostFromSelect(select, button) {
3621
+      const vhost = select.dataset.vhostSelect || '';
3622
+      const fromHost = select.dataset.currentHost || '';
3623
+      const toHost = select.value || '';
3624
+      if (!vhost || !toHost || toHost === fromHost) return;
3625
+      select.disabled = true;
3626
+      if (button) button.disabled = true;
3627
+      try {
3628
+        await api('/api/vhosts/reassign', {
3629
+          method: 'POST',
3630
+          headers: { 'Content-Type': 'application/json' },
3631
+          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
3632
+        });
3633
+        msg(`vhost ${vhost} moved`);
3634
+        await refresh();
3635
+      } finally {
3636
+        select.disabled = false;
3637
+        if (button) button.disabled = false;
3638
+      }
3639
+    }
3640
+
3542 3641
     async function editHost(id) {
3543 3642
       if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
3544 3643
       const host = state.hosts.find(h => h.id === id);