Showing 1 changed files with 168 additions and 22 deletions
+168 -22
scripts/host_manager.pl
@@ -206,6 +206,14 @@ sub handle_client {
206 206
             my $payload = request_payload(\%headers, $body);
207 207
             return reassign_vhost($client, $payload);
208 208
         }
209
+        if ($path eq '/api/vhosts/upsert') {
210
+            my $payload = request_payload(\%headers, $body);
211
+            return upsert_vhost($client, $payload);
212
+        }
213
+        if ($path eq '/api/vhosts/delete') {
214
+            my $payload = request_payload(\%headers, $body);
215
+            return delete_vhost($client, $payload);
216
+        }
209 217
         if ($path eq '/api/work-orders/confirm') {
210 218
             my $payload = request_payload(\%headers, $body);
211 219
             return confirm_work_order($client, $payload);
@@ -417,9 +425,12 @@ sub upsert_host {
417 425
     my $fqdn = canonical_host_fqdn($payload);
418 426
     return send_json($client, 400, { error => 'missing_fqdn' }) unless $fqdn;
419 427
     my @aliases = clean_alias_names($payload);
420
-    my @vhosts = clean_vhost_names($payload);
421 428
 
422 429
     my $registry = load_registry();
430
+    my ($existing_host) = grep { ($_->{id} || '') eq $id } @{ $registry->{hosts} || [] };
431
+    my @vhosts = defined $payload->{vhosts}
432
+        ? clean_vhost_names($payload)
433
+        : ($existing_host ? declared_vhost_names($existing_host) : ());
423 434
     my %host = (
424 435
         id => $id,
425 436
         fqdn => $fqdn,
@@ -500,6 +511,7 @@ sub reassign_vhost {
500 511
 
501 512
             upsert_host_to_db($dbh, $target_host) if $target_host;
502 513
             upsert_host_to_db($dbh, $current_host) if $current_host;
514
+            set_schema_meta($dbh, 'registry_updated_at', iso_now());
503 515
         });
504 516
         1;
505 517
     };
@@ -510,6 +522,81 @@ sub reassign_vhost {
510 522
     return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
511 523
 }
512 524
 
525
+sub upsert_vhost {
526
+    my ($client, $payload) = @_;
527
+    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
528
+    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
529
+    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
530
+    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
531
+
532
+    my $dbh = dbh();
533
+    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
534
+    my ($current_fqdn) = $dbh->selectrow_array(
535
+        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
536
+        undef,
537
+        $vhost,
538
+    );
539
+
540
+    my $result = eval {
541
+        with_transaction($dbh, sub {
542
+            my $now = iso_now();
543
+            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
544
+
545
+            my $registry = load_registry_from_db();
546
+            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
547
+            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
548
+
549
+            upsert_host_to_db($dbh, $target_host) if $target_host;
550
+            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
551
+            set_schema_meta($dbh, 'registry_updated_at', iso_now());
552
+        });
553
+        1;
554
+    };
555
+    if (!$result) {
556
+        my $err = $@ || 'vhost_upsert_failed';
557
+        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
558
+    }
559
+    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn || '' });
560
+}
561
+
562
+sub delete_vhost {
563
+    my ($client, $payload) = @_;
564
+    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
565
+    my $confirm = normalize_dns_name($payload->{confirm} || '');
566
+    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
567
+    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
568
+
569
+    my $dbh = dbh();
570
+    my ($current_fqdn) = $dbh->selectrow_array(
571
+        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
572
+        undef,
573
+        $vhost,
574
+    );
575
+    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
576
+
577
+    my $result = eval {
578
+        with_transaction($dbh, sub {
579
+            my $now = iso_now();
580
+            $dbh->do(
581
+                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
582
+                undef,
583
+                $now, $vhost,
584
+            );
585
+
586
+            my $registry = load_registry_from_db();
587
+            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
588
+            upsert_host_to_db($dbh, $current_host) if $current_host;
589
+            set_schema_meta($dbh, 'registry_updated_at', iso_now());
590
+        });
591
+        1;
592
+    };
593
+    if (!$result) {
594
+        my $err = $@ || 'vhost_delete_failed';
595
+        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
596
+    }
597
+    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
598
+}
599
+
513 600
 sub analyze_hosts {
514 601
     my ($hosts) = @_;
515 602
     my @problems;
@@ -2761,12 +2848,11 @@ sub app_html {
2761 2848
     #page-vhosts .host-tools input { max-width: 280px; }
2762 2849
     #page-vhosts .stats { justify-content: flex-end; }
2763 2850
     .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; }
2766
-    .vhost-host .mono { font-size: 11px; line-height: 1.2; color: var(--muted); }
2767 2851
     .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
2768 2852
     .vhost-pill-row .pill { margin: 0; }
2769 2853
     .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
2854
+    .vhost-inline-editor { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--line); background: #fff; }
2855
+    .vhost-delete { color: var(--bad); }
2770 2856
     .modal-backdrop {
2771 2857
       position: fixed;
2772 2858
       inset: 0;
@@ -2812,6 +2898,7 @@ sub app_html {
2812 2898
       .panel-head { align-items: stretch; flex-direction: column; }
2813 2899
       .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2814 2900
       .host-tools input { max-width: none; }
2901
+      .vhost-inline-editor { grid-template-columns: 1fr; }
2815 2902
       .debug-controls { align-items: stretch; }
2816 2903
       .modal-backdrop { padding-top: 16px; }
2817 2904
       .modal { max-height: calc(100dvh - 32px); }
@@ -2925,6 +3012,11 @@ sub app_html {
2925 3012
               <div class="stats" id="vhost-stats"></div>
2926 3013
             </div>
2927 3014
           </div>
3015
+          <div class="vhost-inline-editor">
3016
+            <input id="vhost-new-name" placeholder="vhost fqdn">
3017
+            <select id="vhost-new-host"></select>
3018
+            <button type="button" id="vhost-add">Add</button>
3019
+          </div>
2928 3020
           <div class="table-wrap">
2929 3021
             <table>
2930 3022
               <thead>
@@ -2935,6 +3027,7 @@ sub app_html {
2935 3027
                   <th style="width: 180px">Derived aliases</th>
2936 3028
                   <th style="width: 120px">Monitoring</th>
2937 3029
                   <th style="width: 90px">Status</th>
3030
+                  <th style="width: 90px">Actions</th>
2938 3031
                 </tr>
2939 3032
               </thead>
2940 3033
               <tbody id="vhosts"></tbody>
@@ -3055,7 +3148,6 @@ sub app_html {
3055 3148
           <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3056 3149
           <label>IP<input name="ip" required></label>
3057 3150
           <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3058
-          <label class="span2">Vhosts<textarea name="vhosts"></textarea></label>
3059 3151
           <label>Roles<input name="roles"></label>
3060 3152
           <label>Sources<input name="sources"></label>
3061 3153
           <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
@@ -3212,6 +3304,7 @@ sub app_html {
3212 3304
         : '<div class="muted" style="padding: 8px 0">No registry problems detected.</div>';
3213 3305
 
3214 3306
       renderHosts();
3307
+      renderVhostEditor();
3215 3308
       renderVhosts();
3216 3309
     }
3217 3310
 
@@ -3574,28 +3667,35 @@ sub app_html {
3574 3667
             <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
3575 3668
               ${renderVhostHostOptions(row.host_fqdn)}
3576 3669
             </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 3670
           </div>
3582 3671
         </td>
3583 3672
         <td>${escapeHtml(row.ip)}</td>
3584 3673
         <td><div class="vhost-pill-row">${row.derived_aliases.map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('')}</div></td>
3585 3674
         <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3586 3675
         <td>${escapeHtml(row.status)}</td>
3587
-      </tr>`).join('') : '<tr><td colspan="6" class="muted">No vhosts.</td></tr>';
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 => {
3676
+        <td><button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}">Delete</button></td>
3677
+      </tr>`).join('') : '<tr><td colspan="7" class="muted">No vhosts.</td></tr>';
3678
+      document.querySelectorAll('[data-vhost-select]').forEach(select => {
3679
+        select.addEventListener('change', () => {
3680
+          reassignVhostFromSelect(select).catch(e => {
3594 3681
             if (!isAuthLost(e)) msg(e.message);
3595 3682
             select.value = select.dataset.currentHost || '';
3596 3683
           });
3597 3684
         });
3598 3685
       });
3686
+      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
3687
+        button.addEventListener('click', () => {
3688
+          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
3689
+            if (!isAuthLost(e)) msg(e.message);
3690
+          });
3691
+        });
3692
+      });
3693
+    }
3694
+
3695
+    function renderVhostEditor() {
3696
+      const select = $('vhost-new-host');
3697
+      const current = select.value || '';
3698
+      select.innerHTML = renderVhostHostOptions(current);
3599 3699
     }
3600 3700
 
3601 3701
     function renderVhostHostOptions(selectedHostFqdn) {
@@ -3605,9 +3705,8 @@ sub app_html {
3605 3705
         .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3606 3706
         .map(host => {
3607 3707
           const fqdn = host.fqdn || '';
3608
-          const label = [host.id || '', fqdn].filter(Boolean).join(' — ');
3609 3708
           const selected = fqdn === selectedHostFqdn ? ' selected' : '';
3610
-          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(label)}</option>`;
3709
+          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
3611 3710
         }).join('');
3612 3711
     }
3613 3712
 
@@ -3617,13 +3716,12 @@ sub app_html {
3617 3716
       return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
3618 3717
     }
3619 3718
 
3620
-    async function reassignVhostFromSelect(select, button) {
3719
+    async function reassignVhostFromSelect(select) {
3621 3720
       const vhost = select.dataset.vhostSelect || '';
3622 3721
       const fromHost = select.dataset.currentHost || '';
3623 3722
       const toHost = select.value || '';
3624 3723
       if (!vhost || !toHost || toHost === fromHost) return;
3625 3724
       select.disabled = true;
3626
-      if (button) button.disabled = true;
3627 3725
       try {
3628 3726
         await api('/api/vhosts/reassign', {
3629 3727
           method: 'POST',
@@ -3634,10 +3732,47 @@ sub app_html {
3634 3732
         await refresh();
3635 3733
       } finally {
3636 3734
         select.disabled = false;
3637
-        if (button) button.disabled = false;
3638 3735
       }
3639 3736
     }
3640 3737
 
3738
+    async function addVhostInline() {
3739
+      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
3740
+      const nameInput = $('vhost-new-name');
3741
+      const hostSelect = $('vhost-new-host');
3742
+      const vhost = (nameInput.value || '').trim().toLowerCase();
3743
+      const hostFqdn = hostSelect.value || '';
3744
+      if (!vhost || !hostFqdn) return;
3745
+      $('vhost-add').disabled = true;
3746
+      nameInput.disabled = true;
3747
+      hostSelect.disabled = true;
3748
+      try {
3749
+        await api('/api/vhosts/upsert', {
3750
+          method: 'POST',
3751
+          headers: { 'Content-Type': 'application/json' },
3752
+          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
3753
+        });
3754
+        nameInput.value = '';
3755
+        msg(`vhost ${vhost} saved`);
3756
+        await refresh();
3757
+      } finally {
3758
+        $('vhost-add').disabled = false;
3759
+        nameInput.disabled = false;
3760
+        hostSelect.disabled = false;
3761
+      }
3762
+    }
3763
+
3764
+    async function deleteVhostInline(vhost) {
3765
+      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
3766
+      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
3767
+      await api('/api/vhosts/delete', {
3768
+        method: 'POST',
3769
+        headers: { 'Content-Type': 'application/json' },
3770
+        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
3771
+      });
3772
+      msg(`vhost ${vhost} deleted`);
3773
+      await refresh();
3774
+    }
3775
+
3641 3776
     async function editHost(id) {
3642 3777
       if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
3643 3778
       const host = state.hosts.find(h => h.id === id);
@@ -3646,7 +3781,6 @@ sub app_html {
3646 3781
       clearHostFormMessage();
3647 3782
       for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3648 3783
       hostField('aliases').value = (host.aliases || []).join('\n');
3649
-      hostField('vhosts').value = (host.vhosts || []).join('\n');
3650 3784
       hostField('roles').value = (host.roles || []).join(' ');
3651 3785
       hostField('sources').value = (host.sources || []).join(' ');
3652 3786
       openHostModal('Edit host');
@@ -3895,6 +4029,18 @@ sub app_html {
3895 4029
     }));
3896 4030
     $('filter').addEventListener('input', renderHosts);
3897 4031
     $('vhost-filter').addEventListener('input', renderVhosts);
4032
+    $('vhost-add').addEventListener('click', () => {
4033
+      addVhostInline().catch(e => {
4034
+        if (!isAuthLost(e)) msg(e.message);
4035
+      });
4036
+    });
4037
+    $('vhost-new-name').addEventListener('keydown', (event) => {
4038
+      if (event.key !== 'Enter') return;
4039
+      event.preventDefault();
4040
+      addVhostInline().catch(e => {
4041
+        if (!isAuthLost(e)) msg(e.message);
4042
+      });
4043
+    });
3898 4044
     $('new-host').addEventListener('click', () => {
3899 4045
       newHost().catch(e => {
3900 4046
         if (!isAuthLost(e)) msg(e.message);