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