@@ -192,6 +192,10 @@ sub handle_client {
|
||
| 192 | 192 |
my $name = $1; |
| 193 | 193 |
return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt"); |
| 194 | 194 |
} |
| 195 |
+ if ($method eq 'GET' && $path =~ m{\A/download/ca/key/([A-Za-z0-9_.-]+)\.key\z}) {
|
|
| 196 |
+ my $name = $1; |
|
| 197 |
+ return send_file($client, ca_issued_key_path($name), 'application/x-pem-file; charset=utf-8', "$name.key"); |
|
| 198 |
+ } |
|
| 195 | 199 |
|
| 196 | 200 |
if ($method eq 'POST' && $path =~ m{^/api/}) {
|
| 197 | 201 |
if ($path eq '/api/hosts/upsert') {
|
@@ -214,6 +218,14 @@ sub handle_client {
|
||
| 214 | 218 |
my $payload = request_payload(\%headers, $body); |
| 215 | 219 |
return delete_vhost($client, $payload); |
| 216 | 220 |
} |
| 221 |
+ if ($path eq '/api/vhosts/certificate') {
|
|
| 222 |
+ my $payload = request_payload(\%headers, $body); |
|
| 223 |
+ return set_vhost_certificate($client, $payload); |
|
| 224 |
+ } |
|
| 225 |
+ if ($path eq '/api/vhosts/issue-certificate') {
|
|
| 226 |
+ my $payload = request_payload(\%headers, $body); |
|
| 227 |
+ return issue_vhost_certificate($client, $payload); |
|
| 228 |
+ } |
|
| 217 | 229 |
if ($path eq '/api/work-orders/confirm') {
|
| 218 | 230 |
my $payload = request_payload(\%headers, $body); |
| 219 | 231 |
return confirm_work_order($client, $payload); |
@@ -399,21 +411,117 @@ sub registry_payload {
|
||
| 399 | 411 |
my ($registry) = @_; |
| 400 | 412 |
my $problems = analyze_hosts($registry->{hosts});
|
| 401 | 413 |
my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
|
| 414 |
+ my $dbh = dbh(); |
|
| 415 |
+ my @vhosts = vhost_payloads($dbh); |
|
| 416 |
+ my @certificates = certificate_payloads($dbh); |
|
| 402 | 417 |
my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
|
| 403 | 418 |
return {
|
| 404 | 419 |
version => $registry->{version},
|
| 405 | 420 |
updated_at => $registry->{updated_at},
|
| 406 | 421 |
policy => $registry->{policy},
|
| 407 | 422 |
hosts => \@hosts, |
| 423 |
+ vhosts => \@vhosts, |
|
| 424 |
+ certificates => \@certificates, |
|
| 408 | 425 |
problems => $problems, |
| 409 | 426 |
counts => {
|
| 410 | 427 |
hosts => scalar @{ $registry->{hosts} },
|
| 411 |
- vhosts => $vhost_count, |
|
| 428 |
+ vhosts => scalar(@vhosts) || $vhost_count, |
|
| 412 | 429 |
problems => scalar @$problems, |
| 413 | 430 |
}, |
| 414 | 431 |
}; |
| 415 | 432 |
} |
| 416 | 433 |
|
| 434 |
+sub vhost_payloads {
|
|
| 435 |
+ my ($dbh) = @_; |
|
| 436 |
+ my @rows; |
|
| 437 |
+ my $sth = $dbh->prepare(<<'SQL'); |
|
| 438 |
+SELECT |
|
| 439 |
+ v.vhost_fqdn, |
|
| 440 |
+ v.host_fqdn, |
|
| 441 |
+ v.status AS vhost_status, |
|
| 442 |
+ v.certificate_id, |
|
| 443 |
+ h.legacy_id, |
|
| 444 |
+ h.hosts_ip, |
|
| 445 |
+ h.dns_ip, |
|
| 446 |
+ h.monitoring, |
|
| 447 |
+ h.status AS host_status, |
|
| 448 |
+ c.common_name, |
|
| 449 |
+ c.not_after, |
|
| 450 |
+ c.fingerprint_sha256, |
|
| 451 |
+ c.status AS certificate_status |
|
| 452 |
+FROM vhosts v |
|
| 453 |
+JOIN hosts h ON h.fqdn = v.host_fqdn |
|
| 454 |
+LEFT JOIN certificates c ON c.certificate_id = v.certificate_id |
|
| 455 |
+WHERE v.status = 'active' |
|
| 456 |
+ORDER BY v.vhost_fqdn |
|
| 457 |
+SQL |
|
| 458 |
+ $sth->execute; |
|
| 459 |
+ while (my $row = $sth->fetchrow_hashref) {
|
|
| 460 |
+ my $cert_id = clean_scalar($row->{certificate_id} || '');
|
|
| 461 |
+ my %certificate = $cert_id ? ( |
|
| 462 |
+ id => $cert_id, |
|
| 463 |
+ name => $cert_id, |
|
| 464 |
+ common_name => clean_scalar($row->{common_name} || ''),
|
|
| 465 |
+ status => clean_scalar($row->{certificate_status} || ''),
|
|
| 466 |
+ not_after => clean_scalar($row->{not_after} || ''),
|
|
| 467 |
+ fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
|
|
| 468 |
+ has_private_key => json_bool(-f ca_issued_key_path($cert_id) ? 1 : 0), |
|
| 469 |
+ ) : (); |
|
| 470 |
+ push @rows, {
|
|
| 471 |
+ vhost => $row->{vhost_fqdn},
|
|
| 472 |
+ vhost_fqdn => $row->{vhost_fqdn},
|
|
| 473 |
+ host_id => $row->{legacy_id} || '',
|
|
| 474 |
+ host_fqdn => $row->{host_fqdn},
|
|
| 475 |
+ ip => $row->{hosts_ip} || $row->{dns_ip} || '',
|
|
| 476 |
+ derived_aliases => short_alias_for_fqdn($row->{vhost_fqdn}) ? [ short_alias_for_fqdn($row->{vhost_fqdn}) ] : [],
|
|
| 477 |
+ monitoring => $row->{monitoring} || '',
|
|
| 478 |
+ status => $row->{host_status} || $row->{vhost_status} || '',
|
|
| 479 |
+ vhost_status => $row->{vhost_status} || '',
|
|
| 480 |
+ certificate_id => $cert_id, |
|
| 481 |
+ certificate => $cert_id ? \%certificate : undef, |
|
| 482 |
+ }; |
|
| 483 |
+ } |
|
| 484 |
+ return @rows; |
|
| 485 |
+} |
|
| 486 |
+ |
|
| 487 |
+sub certificate_payloads {
|
|
| 488 |
+ my ($dbh) = @_; |
|
| 489 |
+ my @certificates; |
|
| 490 |
+ my $sth = $dbh->prepare('SELECT * FROM certificates WHERE status <> ? ORDER BY certificate_id');
|
|
| 491 |
+ $sth->execute('retired');
|
|
| 492 |
+ while (my $row = $sth->fetchrow_hashref) {
|
|
| 493 |
+ my $id = clean_scalar($row->{certificate_id} || '');
|
|
| 494 |
+ next unless $id; |
|
| 495 |
+ push @certificates, {
|
|
| 496 |
+ id => $id, |
|
| 497 |
+ name => $id, |
|
| 498 |
+ host_fqdn => $row->{host_fqdn} || '',
|
|
| 499 |
+ common_name => $row->{common_name} || '',
|
|
| 500 |
+ subject => $row->{subject} || '',
|
|
| 501 |
+ issuer => $row->{issuer} || '',
|
|
| 502 |
+ serial => $row->{serial} || '',
|
|
| 503 |
+ status => $row->{status} || '',
|
|
| 504 |
+ not_before => $row->{not_before} || '',
|
|
| 505 |
+ not_after => $row->{not_after} || '',
|
|
| 506 |
+ fingerprint_sha256 => $row->{fingerprint_sha256} || '',
|
|
| 507 |
+ dns_names => [ certificate_dns_names($dbh, $id) ], |
|
| 508 |
+ has_private_key => json_bool(-f ca_issued_key_path($id) ? 1 : 0), |
|
| 509 |
+ }; |
|
| 510 |
+ } |
|
| 511 |
+ return @certificates; |
|
| 512 |
+} |
|
| 513 |
+ |
|
| 514 |
+sub certificate_dns_names {
|
|
| 515 |
+ my ($dbh, $certificate_id) = @_; |
|
| 516 |
+ my @names; |
|
| 517 |
+ my $sth = $dbh->prepare('SELECT dns_name FROM certificate_dns_names WHERE certificate_id = ? ORDER BY dns_name');
|
|
| 518 |
+ $sth->execute($certificate_id); |
|
| 519 |
+ while (my ($name) = $sth->fetchrow_array) {
|
|
| 520 |
+ push @names, $name; |
|
| 521 |
+ } |
|
| 522 |
+ return @names; |
|
| 523 |
+} |
|
| 524 |
+ |
|
| 417 | 525 |
sub upsert_host {
|
| 418 | 526 |
my ($client, $payload) = @_; |
| 419 | 527 |
my $id = clean_id($payload->{id} || '');
|
@@ -597,6 +705,82 @@ sub delete_vhost {
|
||
| 597 | 705 |
return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
|
| 598 | 706 |
} |
| 599 | 707 |
|
| 708 |
+sub set_vhost_certificate {
|
|
| 709 |
+ my ($client, $payload) = @_; |
|
| 710 |
+ my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
|
|
| 711 |
+ my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
|
|
| 712 |
+ my $certificate_id = clean_certificate_id($raw_certificate_id); |
|
| 713 |
+ return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
|
|
| 714 |
+ return send_json($client, 400, { error => 'invalid_certificate' })
|
|
| 715 |
+ if length($raw_certificate_id) && !length($certificate_id); |
|
| 716 |
+ |
|
| 717 |
+ my $dbh = dbh(); |
|
| 718 |
+ return send_json($client, 404, { error => 'vhost_not_found' })
|
|
| 719 |
+ unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost); |
|
| 720 |
+ if (length $certificate_id) {
|
|
| 721 |
+ return send_json($client, 400, { error => 'invalid_certificate' })
|
|
| 722 |
+ unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id); |
|
| 723 |
+ } |
|
| 724 |
+ |
|
| 725 |
+ my $now = iso_now(); |
|
| 726 |
+ $dbh->do( |
|
| 727 |
+ 'UPDATE vhosts SET certificate_id = ?, tls_mode = ?, updated_at = ? WHERE vhost_fqdn = ? AND status = ?', |
|
| 728 |
+ undef, |
|
| 729 |
+ length($certificate_id) ? $certificate_id : undef, |
|
| 730 |
+ length($certificate_id) ? 'local-ca' : 'none', |
|
| 731 |
+ $now, |
|
| 732 |
+ $vhost, |
|
| 733 |
+ 'active', |
|
| 734 |
+ ); |
|
| 735 |
+ set_schema_meta($dbh, 'registry_updated_at', $now); |
|
| 736 |
+ return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, certificate_id => $certificate_id });
|
|
| 737 |
+} |
|
| 738 |
+ |
|
| 739 |
+sub issue_vhost_certificate {
|
|
| 740 |
+ my ($client, $payload) = @_; |
|
| 741 |
+ my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
|
|
| 742 |
+ return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
|
|
| 743 |
+ |
|
| 744 |
+ my $dbh = dbh(); |
|
| 745 |
+ my ($host_fqdn) = $dbh->selectrow_array( |
|
| 746 |
+ "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", |
|
| 747 |
+ undef, |
|
| 748 |
+ $vhost, |
|
| 749 |
+ ); |
|
| 750 |
+ return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
|
|
| 751 |
+ |
|
| 752 |
+ my @dns_names = unique_preserve(grep { length $_ } ($vhost, short_alias_for_fqdn($vhost)));
|
|
| 753 |
+ my $certificate_id = clean_certificate_id($vhost . '-' . strftime('%Y%m%d%H%M%S', localtime));
|
|
| 754 |
+ my $issued = eval {
|
|
| 755 |
+ ca_manager_output('issue', $certificate_id, @dns_names);
|
|
| 756 |
+ ca_manager_json('list-json');
|
|
| 757 |
+ with_transaction($dbh, sub {
|
|
| 758 |
+ my $now = iso_now(); |
|
| 759 |
+ $dbh->do( |
|
| 760 |
+ "UPDATE vhosts SET certificate_id = ?, tls_mode = 'local-ca', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'", |
|
| 761 |
+ undef, |
|
| 762 |
+ $certificate_id, |
|
| 763 |
+ $now, |
|
| 764 |
+ $vhost, |
|
| 765 |
+ ); |
|
| 766 |
+ set_schema_meta($dbh, 'registry_updated_at', $now); |
|
| 767 |
+ }); |
|
| 768 |
+ 1; |
|
| 769 |
+ }; |
|
| 770 |
+ if (!$issued) {
|
|
| 771 |
+ return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
|
|
| 772 |
+ } |
|
| 773 |
+ |
|
| 774 |
+ my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
|
|
| 775 |
+ return send_json($client, 200, {
|
|
| 776 |
+ ok => json_bool(1), |
|
| 777 |
+ vhost_fqdn => $vhost, |
|
| 778 |
+ host_fqdn => $host_fqdn, |
|
| 779 |
+ certificate_id => $certificate_id, |
|
| 780 |
+ certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
|
|
| 781 |
+ }); |
|
| 782 |
+} |
|
| 783 |
+ |
|
| 600 | 784 |
sub analyze_hosts {
|
| 601 | 785 |
my ($hosts) = @_; |
| 602 | 786 |
my @problems; |
@@ -980,15 +1164,27 @@ sub ca_issued_cert_path {
|
||
| 980 | 1164 |
return ca_dir() . "/issued/$name.cert.pem"; |
| 981 | 1165 |
} |
| 982 | 1166 |
|
| 983 |
-sub ca_manager_json {
|
|
| 984 |
- my ($command) = @_; |
|
| 1167 |
+sub ca_issued_key_path {
|
|
| 1168 |
+ my ($name) = @_; |
|
| 1169 |
+ die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/; |
|
| 1170 |
+ return ca_dir() . "/issued/$name.key.pem"; |
|
| 1171 |
+} |
|
| 1172 |
+ |
|
| 1173 |
+sub ca_manager_output {
|
|
| 1174 |
+ my (@args) = @_; |
|
| 985 | 1175 |
my $script = ca_script_path(); |
| 986 | 1176 |
die "CA manager script is missing\n" unless -x $script; |
| 987 | 1177 |
local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
|
| 988 |
- open my $fh, '-|', $script, $command or die "Cannot run CA manager\n"; |
|
| 1178 |
+ open my $fh, '-|', $script, @args or die "Cannot run CA manager\n"; |
|
| 989 | 1179 |
local $/; |
| 990 | 1180 |
my $out = <$fh>; |
| 991 | 1181 |
close $fh or die "CA manager failed\n"; |
| 1182 |
+ return $out || ''; |
|
| 1183 |
+} |
|
| 1184 |
+ |
|
| 1185 |
+sub ca_manager_json {
|
|
| 1186 |
+ my ($command) = @_; |
|
| 1187 |
+ my $out = ca_manager_output($command); |
|
| 992 | 1188 |
$out ||= $command eq 'list-json' ? '[]' : '{}';
|
| 993 | 1189 |
sync_certificates_from_json($out) if $command eq 'list-json'; |
| 994 | 1190 |
return $out; |
@@ -1443,6 +1639,13 @@ sub clean_id {
|
||
| 1443 | 1639 |
return $value; |
| 1444 | 1640 |
} |
| 1445 | 1641 |
|
| 1642 |
+sub clean_certificate_id {
|
|
| 1643 |
+ my ($value) = @_; |
|
| 1644 |
+ $value = clean_scalar($value); |
|
| 1645 |
+ return '' unless length $value; |
|
| 1646 |
+ return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : ''; |
|
| 1647 |
+} |
|
| 1648 |
+ |
|
| 1446 | 1649 |
sub clean_scalar {
|
| 1447 | 1650 |
my ($value) = @_; |
| 1448 | 1651 |
$value = '' unless defined $value; |
@@ -2853,9 +3056,11 @@ sub app_html {
|
||
| 2853 | 3056 |
.vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
|
| 2854 | 3057 |
.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 | 3058 |
.vhost-delete { color: var(--bad); }
|
| 2856 |
- .host-editor-panel { margin-top: 16px; }
|
|
| 2857 |
- .host-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
| 2858 |
- .host-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
| 3059 |
+ .host-inline-row td { padding: 0; background: #fff; }
|
|
| 3060 |
+ .host-inline-editor-shell { background: #fff; }
|
|
| 3061 |
+ .host-inline-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fafbfc; }
|
|
| 3062 |
+ .host-inline-editor-head h2 { margin: 0; font-size: 14px; }
|
|
| 3063 |
+ .host-inline-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
| 2859 | 3064 |
.form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
|
| 2860 | 3065 |
.form-message.error { color: var(--bad); }
|
| 2861 | 3066 |
.form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
@@ -2867,8 +3072,8 @@ sub app_html {
|
||
| 2867 | 3072 |
.host-tools { justify-content: flex-start; flex-wrap: wrap; }
|
| 2868 | 3073 |
.host-tools input { max-width: none; }
|
| 2869 | 3074 |
.vhost-inline-editor { grid-template-columns: 1fr; }
|
| 2870 |
- .host-editor-head { align-items: stretch; flex-direction: column; }
|
|
| 2871 |
- .host-editor-tools { justify-content: flex-start; }
|
|
| 3075 |
+ .host-inline-editor-head { align-items: stretch; flex-direction: column; }
|
|
| 3076 |
+ .host-inline-editor-tools { justify-content: flex-start; }
|
|
| 2872 | 3077 |
.debug-controls { align-items: stretch; }
|
| 2873 | 3078 |
.grid { grid-template-columns: 1fr; }
|
| 2874 | 3079 |
table { min-width: 760px; }
|
@@ -2963,36 +3168,13 @@ sub app_html {
|
||
| 2963 | 3168 |
<th style="width: 150px">Roles</th> |
| 2964 | 3169 |
<th style="width: 110px">Monitoring</th> |
| 2965 | 3170 |
<th style="width: 90px">Status</th> |
| 3171 |
+ <th style="width: 90px">Actions</th> |
|
| 2966 | 3172 |
</tr> |
| 2967 | 3173 |
</thead> |
| 2968 | 3174 |
<tbody id="hosts"></tbody> |
| 2969 | 3175 |
</table> |
| 2970 | 3176 |
</div> |
| 2971 | 3177 |
</section> |
| 2972 |
- <section class="panel host-editor-panel"> |
|
| 2973 |
- <div class="panel-head host-editor-head"> |
|
| 2974 |
- <h2 id="host-form-title">New host</h2> |
|
| 2975 |
- <div class="host-editor-tools"> |
|
| 2976 |
- <button type="button" id="reset-host-form">Reset</button> |
|
| 2977 |
- </div> |
|
| 2978 |
- </div> |
|
| 2979 |
- <form id="host-form" class="grid"> |
|
| 2980 |
- <label>ID<input name="id" required></label> |
|
| 2981 |
- <label>FQDN<input name="fqdn" required></label> |
|
| 2982 |
- <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label> |
|
| 2983 |
- <label>IP<input name="ip" required></label> |
|
| 2984 |
- <label class="span2">Aliases<textarea name="aliases"></textarea></label> |
|
| 2985 |
- <label>Roles<input name="roles"></label> |
|
| 2986 |
- <label>Sources<input name="sources"></label> |
|
| 2987 |
- <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label> |
|
| 2988 |
- <label>Notes<input name="notes"></label> |
|
| 2989 |
- <div id="host-form-message" class="span2 form-message" aria-live="polite"></div> |
|
| 2990 |
- <div class="span2 form-actions"> |
|
| 2991 |
- <button class="primary" type="submit" id="save-host">Save host</button> |
|
| 2992 |
- <button class="danger" type="button" id="delete-host">Delete host</button> |
|
| 2993 |
- </div> |
|
| 2994 |
- </form> |
|
| 2995 |
- </section> |
|
| 2996 | 3178 |
</section> |
| 2997 | 3179 |
|
| 2998 | 3180 |
<section class="page" id="page-vhosts" data-page="vhosts" hidden> |
@@ -3140,9 +3322,49 @@ sub app_html {
|
||
| 3140 | 3322 |
let hostFormSnapshot = ''; |
| 3141 | 3323 |
let hostFormBusy = false; |
| 3142 | 3324 |
let hostFormMode = 'new'; |
| 3325 |
+ let hostEditorTarget = ''; |
|
| 3143 | 3326 |
|
| 3144 | 3327 |
const $ = (id) => document.getElementById(id); |
| 3145 | 3328 |
const msg = (text) => { $('message').textContent = text || ''; };
|
| 3329 |
+ const hostFormShell = document.createElement('div');
|
|
| 3330 |
+ hostFormShell.id = 'host-form-shell'; |
|
| 3331 |
+ hostFormShell.className = 'host-inline-editor-shell'; |
|
| 3332 |
+ hostFormShell.hidden = true; |
|
| 3333 |
+ hostFormShell.innerHTML = ` |
|
| 3334 |
+ <div class="host-inline-editor-head"> |
|
| 3335 |
+ <h2 id="host-form-title">New host</h2> |
|
| 3336 |
+ <div class="host-inline-editor-tools"> |
|
| 3337 |
+ <button type="button" id="cancel-host-form">Close</button> |
|
| 3338 |
+ </div> |
|
| 3339 |
+ </div> |
|
| 3340 |
+ <form id="host-form" class="grid"> |
|
| 3341 |
+ <label>ID<input name="id" required></label> |
|
| 3342 |
+ <label>FQDN<input name="fqdn" required></label> |
|
| 3343 |
+ <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label> |
|
| 3344 |
+ <label>IP<input name="ip" required></label> |
|
| 3345 |
+ <label class="span2">Aliases<textarea name="aliases"></textarea></label> |
|
| 3346 |
+ <label>Roles<input name="roles"></label> |
|
| 3347 |
+ <label>Sources<input name="sources"></label> |
|
| 3348 |
+ <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label> |
|
| 3349 |
+ <label>Notes<input name="notes"></label> |
|
| 3350 |
+ <div id="host-form-message" class="span2 form-message" aria-live="polite"></div> |
|
| 3351 |
+ <div class="span2 form-actions"> |
|
| 3352 |
+ <button class="primary" type="submit" id="save-host">Save host</button> |
|
| 3353 |
+ <button class="danger" type="button" id="delete-host">Delete host</button> |
|
| 3354 |
+ </div> |
|
| 3355 |
+ </form>`; |
|
| 3356 |
+ const hostForm = hostFormShell.querySelector('#host-form');
|
|
| 3357 |
+ const hostFormTitle = hostFormShell.querySelector('#host-form-title');
|
|
| 3358 |
+ const hostFormMessage = hostFormShell.querySelector('#host-form-message');
|
|
| 3359 |
+ const saveHostButton = hostFormShell.querySelector('#save-host');
|
|
| 3360 |
+ const deleteHostButton = hostFormShell.querySelector('#delete-host');
|
|
| 3361 |
+ const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
|
|
| 3362 |
+ const hostEditorRow = document.createElement('tr');
|
|
| 3363 |
+ hostEditorRow.className = 'host-inline-row'; |
|
| 3364 |
+ const hostEditorCell = document.createElement('td');
|
|
| 3365 |
+ hostEditorCell.colSpan = 7; |
|
| 3366 |
+ hostEditorRow.appendChild(hostEditorCell); |
|
| 3367 |
+ hostEditorCell.appendChild(hostFormShell); |
|
| 3146 | 3368 |
const PAGE_PATHS = {
|
| 3147 | 3369 |
'/': 'overview', |
| 3148 | 3370 |
'/overview': 'overview', |
@@ -3586,12 +3808,13 @@ sub app_html {
|
||
| 3586 | 3808 |
const problems = state.problems.filter(p => p.host_id === h.id); |
| 3587 | 3809 |
const cls = problems.length ? 'warn' : 'ok'; |
| 3588 | 3810 |
return `<tr data-id="${escapeHtml(h.id)}">
|
| 3589 |
- <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
|
|
| 3811 |
+ <td>${escapeHtml(h.id)}</td>
|
|
| 3590 | 3812 |
<td>${escapeHtml(h.ip || '')}</td>
|
| 3591 | 3813 |
<td>${renderNamePills(h)}</td>
|
| 3592 | 3814 |
<td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
|
| 3593 | 3815 |
<td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
|
| 3594 | 3816 |
<td>${escapeHtml(h.status || '')}</td>
|
| 3817 |
+ <td><button type="button" data-edit="${escapeHtml(h.id)}">Edit</button></td>
|
|
| 3595 | 3818 |
</tr>`; |
| 3596 | 3819 |
}).join('');
|
| 3597 | 3820 |
document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
|
@@ -3599,6 +3822,7 @@ sub app_html {
|
||
| 3599 | 3822 |
if (!isAuthLost(e)) msg(e.message); |
| 3600 | 3823 |
}); |
| 3601 | 3824 |
})); |
| 3825 |
+ mountHostEditor(); |
|
| 3602 | 3826 |
} |
| 3603 | 3827 |
|
| 3604 | 3828 |
function renderNamePills(host) {
|
@@ -3747,44 +3971,87 @@ sub app_html {
|
||
| 3747 | 3971 |
if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
|
| 3748 | 3972 |
const host = state.hosts.find(h => h.id === id); |
| 3749 | 3973 |
if (!host) return; |
| 3974 |
+ if (!canSwitchHostEditor(id)) return; |
|
| 3750 | 3975 |
clearHostFormMessage(); |
| 3751 | 3976 |
for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || ''; |
| 3752 | 3977 |
hostField('aliases').value = (host.aliases || []).join('\n');
|
| 3753 | 3978 |
hostField('roles').value = (host.roles || []).join(' ');
|
| 3754 | 3979 |
hostField('sources').value = (host.sources || []).join(' ');
|
| 3755 |
- activateHostForm('Edit host', 'edit', 'fqdn');
|
|
| 3980 |
+ activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', id, 'fqdn');
|
|
| 3756 | 3981 |
} |
| 3757 | 3982 |
|
| 3758 | 3983 |
async function newHost() {
|
| 3759 | 3984 |
if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
|
| 3760 |
- resetHostForm(false, true); |
|
| 3985 |
+ if (!canSwitchHostEditor('__new__')) return;
|
|
| 3986 |
+ resetHostForm(true); |
|
| 3987 |
+ activateHostForm('New host', 'new', '__new__', 'id');
|
|
| 3761 | 3988 |
} |
| 3762 | 3989 |
|
| 3763 |
- function activateHostForm(title, mode, focusField = 'id', scroll = true) {
|
|
| 3990 |
+ function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
|
|
| 3764 | 3991 |
hostFormMode = mode || 'new'; |
| 3765 |
- $('host-form-title').textContent = title || 'New host';
|
|
| 3766 |
- hostFormSnapshot = hostFormState(); |
|
| 3992 |
+ hostEditorTarget = target || ''; |
|
| 3993 |
+ hostFormTitle.textContent = title || 'New host'; |
|
| 3767 | 3994 |
syncHostFormActions(); |
| 3768 |
- if (scroll) $('host-form').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
| 3995 |
+ renderHosts(); |
|
| 3996 |
+ hostFormSnapshot = hostFormState(); |
|
| 3997 |
+ if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
| 3769 | 3998 |
hostField(focusField).focus(); |
| 3770 | 3999 |
} |
| 3771 | 4000 |
|
| 3772 |
- function resetHostForm(force = false, scroll = false) {
|
|
| 4001 |
+ function resetHostForm(force = false) {
|
|
| 3773 | 4002 |
if (hostFormBusy && !force) return; |
| 3774 |
- if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
|
|
| 3775 |
- $('host-form').reset();
|
|
| 4003 |
+ hostForm.reset(); |
|
| 3776 | 4004 |
clearHostFormMessage(); |
| 3777 | 4005 |
hostField('status').value = 'active';
|
| 3778 | 4006 |
hostField('monitoring').value = 'pending';
|
| 3779 |
- activateHostForm('New host', 'new', 'id', scroll);
|
|
| 4007 |
+ hostFormSnapshot = force ? '' : hostFormState(); |
|
| 4008 |
+ } |
|
| 4009 |
+ |
|
| 4010 |
+ function closeHostForm(force = false) {
|
|
| 4011 |
+ if (hostFormBusy && !force) return; |
|
| 4012 |
+ if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
|
|
| 4013 |
+ hostEditorTarget = ''; |
|
| 4014 |
+ hostFormMode = 'new'; |
|
| 4015 |
+ hostFormSnapshot = ''; |
|
| 4016 |
+ clearHostFormMessage(); |
|
| 4017 |
+ syncHostFormActions(); |
|
| 4018 |
+ mountHostEditor(); |
|
| 4019 |
+ } |
|
| 4020 |
+ |
|
| 4021 |
+ function canSwitchHostEditor(target) {
|
|
| 4022 |
+ if (hostFormBusy) return false; |
|
| 4023 |
+ if (!hostEditorTarget) return true; |
|
| 4024 |
+ if (!hostFormDirty()) return true; |
|
| 4025 |
+ if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
|
|
| 4026 |
+ return confirm('Discard unsaved host changes?');
|
|
| 4027 |
+ } |
|
| 4028 |
+ |
|
| 4029 |
+ function mountHostEditor() {
|
|
| 4030 |
+ hostEditorRow.remove(); |
|
| 4031 |
+ if (!hostEditorTarget) {
|
|
| 4032 |
+ hostFormShell.hidden = true; |
|
| 4033 |
+ return; |
|
| 4034 |
+ } |
|
| 4035 |
+ hostEditorCell.colSpan = 7; |
|
| 4036 |
+ const tbody = $('hosts');
|
|
| 4037 |
+ if (!tbody) return; |
|
| 4038 |
+ if (hostEditorTarget === '__new__') {
|
|
| 4039 |
+ tbody.prepend(hostEditorRow); |
|
| 4040 |
+ } else {
|
|
| 4041 |
+ const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
|
|
| 4042 |
+ const targetRow = rows.find(row => row.dataset.id === hostEditorTarget); |
|
| 4043 |
+ if (targetRow) targetRow.after(hostEditorRow); |
|
| 4044 |
+ else tbody.prepend(hostEditorRow); |
|
| 4045 |
+ } |
|
| 4046 |
+ hostFormShell.hidden = false; |
|
| 3780 | 4047 |
} |
| 3781 | 4048 |
|
| 3782 | 4049 |
function hostField(name) {
|
| 3783 |
- return $('host-form').elements.namedItem(name);
|
|
| 4050 |
+ return hostForm.elements.namedItem(name); |
|
| 3784 | 4051 |
} |
| 3785 | 4052 |
|
| 3786 | 4053 |
function hostFormState() {
|
| 3787 |
- return JSON.stringify(formObject($('host-form')));
|
|
| 4054 |
+ return JSON.stringify(formObject(hostForm)); |
|
| 3788 | 4055 |
} |
| 3789 | 4056 |
|
| 3790 | 4057 |
function hostFormDirty() {
|
@@ -3797,15 +4064,14 @@ sub app_html {
|
||
| 3797 | 4064 |
} |
| 3798 | 4065 |
|
| 3799 | 4066 |
function syncHostFormActions() {
|
| 3800 |
- $('save-host').disabled = hostFormBusy;
|
|
| 3801 |
- $('delete-host').disabled = hostFormBusy || hostFormMode !== 'edit';
|
|
| 3802 |
- $('reset-host-form').disabled = hostFormBusy;
|
|
| 4067 |
+ saveHostButton.disabled = hostFormBusy; |
|
| 4068 |
+ deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit'; |
|
| 4069 |
+ cancelHostButton.disabled = hostFormBusy; |
|
| 3803 | 4070 |
} |
| 3804 | 4071 |
|
| 3805 | 4072 |
function setHostFormMessage(text, isError = false) {
|
| 3806 |
- const message = $('host-form-message');
|
|
| 3807 |
- message.textContent = text || ''; |
|
| 3808 |
- message.classList.toggle('error', !!isError);
|
|
| 4073 |
+ hostFormMessage.textContent = text || ''; |
|
| 4074 |
+ hostFormMessage.classList.toggle('error', !!isError);
|
|
| 3809 | 4075 |
} |
| 3810 | 4076 |
|
| 3811 | 4077 |
function clearHostFormMessage() {
|
@@ -4015,9 +4281,9 @@ sub app_html {
|
||
| 4015 | 4281 |
$('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
|
| 4016 | 4282 |
if (!isAuthLost(e)) msg(e.message); |
| 4017 | 4283 |
})); |
| 4018 |
- $('reset-host-form').addEventListener('click', () => resetHostForm());
|
|
| 4284 |
+ cancelHostButton.addEventListener('click', () => closeHostForm());
|
|
| 4019 | 4285 |
|
| 4020 |
- $('host-form').addEventListener('submit', async (event) => {
|
|
| 4286 |
+ hostForm.addEventListener('submit', async (event) => {
|
|
| 4021 | 4287 |
event.preventDefault(); |
| 4022 | 4288 |
if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
|
| 4023 | 4289 |
setHostFormBusy(true); |
@@ -4034,9 +4300,9 @@ sub app_html {
|
||
| 4034 | 4300 |
hostField('aliases').value = (host.aliases || []).join('\n');
|
| 4035 | 4301 |
hostField('roles').value = (host.roles || []).join(' ');
|
| 4036 | 4302 |
hostField('sources').value = (host.sources || []).join(' ');
|
| 4037 |
- activateHostForm('Edit host', 'edit', 'fqdn', false);
|
|
| 4303 |
+ activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
|
|
| 4038 | 4304 |
} else {
|
| 4039 |
- resetHostForm(true, false); |
|
| 4305 |
+ closeHostForm(true); |
|
| 4040 | 4306 |
} |
| 4041 | 4307 |
} catch (e) {
|
| 4042 | 4308 |
if (isAuthLost(e)) return; |
@@ -4047,15 +4313,15 @@ sub app_html {
|
||
| 4047 | 4313 |
} |
| 4048 | 4314 |
}); |
| 4049 | 4315 |
|
| 4050 |
- $('host-form').addEventListener('invalid', (event) => {
|
|
| 4316 |
+ hostForm.addEventListener('invalid', (event) => {
|
|
| 4051 | 4317 |
setHostFormMessage('Complete the required host fields before saving.', true);
|
| 4052 | 4318 |
}, true); |
| 4053 | 4319 |
|
| 4054 |
- $('host-form').addEventListener('input', () => {
|
|
| 4055 |
- if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
|
|
| 4320 |
+ hostForm.addEventListener('input', () => {
|
|
| 4321 |
+ if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
|
|
| 4056 | 4322 |
}); |
| 4057 | 4323 |
|
| 4058 |
- $('delete-host').addEventListener('click', async () => {
|
|
| 4324 |
+ deleteHostButton.addEventListener('click', async () => {
|
|
| 4059 | 4325 |
const id = hostField('id').value;
|
| 4060 | 4326 |
if (!id || !confirm(`Delete ${id}?`)) return;
|
| 4061 | 4327 |
setHostFormBusy(true); |
@@ -4064,7 +4330,7 @@ sub app_html {
|
||
| 4064 | 4330 |
await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
| 4065 | 4331 |
msg('host deleted');
|
| 4066 | 4332 |
await refresh(); |
| 4067 |
- resetHostForm(true, false); |
|
| 4333 |
+ closeHostForm(true); |
|
| 4068 | 4334 |
} catch (e) {
|
| 4069 | 4335 |
if (isAuthLost(e)) return; |
| 4070 | 4336 |
setHostFormMessage(e.message, true); |
@@ -4074,7 +4340,8 @@ sub app_html {
|
||
| 4074 | 4340 |
} |
| 4075 | 4341 |
}); |
| 4076 | 4342 |
|
| 4077 |
- resetHostForm(true, false); |
|
| 4343 |
+ resetHostForm(true); |
|
| 4344 |
+ closeHostForm(true); |
|
| 4078 | 4345 |
|
| 4079 | 4346 |
$('write-tsv').addEventListener('click', async () => {
|
| 4080 | 4347 |
if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
|