@@ -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); |