Showing 2 changed files with 323 additions and 60 deletions
+21 -0
scripts/ca_manager.sh
@@ -97,6 +97,26 @@ cert_field() {
97 97
     esac
98 98
 }
99 99
 
100
+cert_dns_names_json() {
101
+    local cert="$1"
102
+    local names name first=1
103
+    names="$("$OPENSSL" x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \
104
+        | sed -n '/DNS:/,$p' \
105
+        | tr ',' '\n' \
106
+        | sed -n 's/^[[:space:]]*DNS://p')"
107
+
108
+    printf '['
109
+    while IFS= read -r name; do
110
+        [[ -n "$name" ]] || continue
111
+        if [[ "$first" -eq 0 ]]; then
112
+            printf ','
113
+        fi
114
+        first=0
115
+        json_escape "$name"
116
+    done <<< "$names"
117
+    printf ']'
118
+}
119
+
100 120
 status_json() {
101 121
     need_openssl
102 122
     if [[ ! -f "$ca_cert" ]]; then
@@ -137,6 +157,7 @@ list_json() {
137 157
         first=0
138 158
         printf '{"name":'; json_escape "$name"
139 159
         printf ',"subject":'; json_escape "$(cert_field "$cert" subject)"
160
+        printf ',"dns_names":'; cert_dns_names_json "$cert"
140 161
         printf ',"issuer":'; json_escape "$(cert_field "$cert" issuer)"
141 162
         printf ',"serial":'; json_escape "$(cert_field "$cert" serial)"
142 163
         printf ',"not_before":'; json_escape "$(cert_field "$cert" not_before)"
+302 -60
scripts/host_manager.pl
@@ -110,7 +110,7 @@ sub handle_client {
110 110
     my ($path, $query) = split /\?/, $target, 2;
111 111
     my %query = parse_params($query || '');
112 112
 
113
-    if ($method eq 'GET' && $path eq '/') {
113
+    if ($method eq 'GET' && app_page_path($path)) {
114 114
         return send_html($client, 200, app_html());
115 115
     }
116 116
     if ($method eq 'GET' && $path eq '/healthz') {
@@ -163,6 +163,10 @@ sub handle_client {
163 163
     if ($method eq 'GET' && $path eq '/download/ca.crt') {
164 164
         return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt');
165 165
     }
166
+    if ($method eq 'GET' && $path =~ m{\A/download/ca/cert/([A-Za-z0-9_.-]+)\.crt\z}) {
167
+        my $name = $1;
168
+        return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt");
169
+    }
166 170
 
167 171
     if ($method eq 'POST' && $path =~ m{^/api/}) {
168 172
         if ($path eq '/api/hosts/upsert') {
@@ -193,6 +197,11 @@ sub handle_client {
193 197
     return send_json($client, 404, { error => 'not_found' });
194 198
 }
195 199
 
200
+sub app_page_path {
201
+    my ($path) = @_;
202
+    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca)\z};
203
+}
204
+
196 205
 sub load_registry {
197 206
     return parse_hosts_yaml(read_file($opt{data}));
198 207
 }
@@ -549,6 +558,12 @@ sub ca_cert_path {
549 558
     return ca_dir() . "/certs/ca.cert.pem";
550 559
 }
551 560
 
561
+sub ca_issued_cert_path {
562
+    my ($name) = @_;
563
+    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
564
+    return ca_dir() . "/issued/$name.cert.pem";
565
+}
566
+
552 567
 sub ca_manager_json {
553 568
     my ($command) = @_;
554 569
     my $script = ca_script_path();
@@ -1326,10 +1341,17 @@ sub app_html {
1326 1341
 
1327 1342
     /* ── App shell (hidden until authenticated) ── */
1328 1343
     #app { display: none; }
1329
-    header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
1344
+    header { display: grid; grid-template-columns: minmax(180px, auto) 1fr auto; align-items: center; gap: 16px; padding: 12px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
1330 1345
     h1 { margin: 0; font-size: 17px; font-weight: 700; }
1331
-    .header-right { display: flex; align-items: center; gap: 10px; }
1346
+    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
1347
+    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
1348
+    nav a:hover { color: var(--ink); background: var(--soft); }
1349
+    nav a.active { color: var(--accent); background: #e8f0fe; }
1350
+    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
1351
+    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1332 1352
     main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
1353
+    .page { display: grid; gap: 16px; }
1354
+    .page[hidden] { display: none; }
1333 1355
     .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
1334 1356
     .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
1335 1357
     .panel { overflow: hidden; }
@@ -1357,6 +1379,10 @@ sub app_html {
1357 1379
     .span2 { grid-column: 1 / -1; }
1358 1380
     label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
1359 1381
     .muted { color: var(--muted); }
1382
+    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
1383
+    .ca-detail { display: grid; gap: 6px; min-width: 0; }
1384
+    .ca-fingerprint { overflow-wrap: anywhere; }
1385
+    .ca-empty { padding: 12px 14px; }
1360 1386
     .build-badge {
1361 1387
       position: fixed;
1362 1388
       right: 10px;
@@ -1387,7 +1413,53 @@ sub app_html {
1387 1413
     .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
1388 1414
     .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
1389 1415
     .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
1416
+    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
1417
+    .host-tools input { max-width: 240px; }
1418
+    .modal-backdrop {
1419
+      position: fixed;
1420
+      inset: 0;
1421
+      z-index: 10;
1422
+      display: grid;
1423
+      align-items: start;
1424
+      justify-items: center;
1425
+      padding: 72px 16px 24px;
1426
+      background: rgba(21,32,51,.48);
1427
+      overflow: auto;
1428
+    }
1429
+    .modal-backdrop[hidden] { display: none; }
1430
+    .modal {
1431
+      width: min(840px, 100%);
1432
+      max-height: calc(100dvh - 96px);
1433
+      overflow: auto;
1434
+      background: var(--panel);
1435
+      border: 1px solid var(--line);
1436
+      border-radius: 8px;
1437
+      box-shadow: 0 20px 60px rgba(21,32,51,.26);
1438
+    }
1439
+    .modal-head {
1440
+      position: sticky;
1441
+      top: 0;
1442
+      z-index: 1;
1443
+      display: flex;
1444
+      align-items: center;
1445
+      justify-content: space-between;
1446
+      gap: 12px;
1447
+      padding: 12px 14px;
1448
+      border-bottom: 1px solid var(--line);
1449
+      background: #fafbfc;
1450
+    }
1451
+    .modal-head h2 { margin: 0; font-size: 14px; }
1452
+    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
1453
+    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
1390 1454
     @media (max-width: 760px) {
1455
+      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
1456
+      .header-right { justify-content: flex-start; flex-wrap: wrap; }
1457
+      #message { max-width: 100%; }
1458
+      .panel-head { align-items: stretch; flex-direction: column; }
1459
+      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
1460
+      .host-tools input { max-width: none; }
1461
+      .modal-backdrop { padding-top: 16px; }
1462
+      .modal { max-height: calc(100dvh - 32px); }
1391 1463
       .grid { grid-template-columns: 1fr; }
1392 1464
       table { min-width: 760px; }
1393 1465
       .table-wrap { overflow-x: auto; }
@@ -1436,71 +1508,115 @@ sub app_html {
1436 1508
   <div id="app">
1437 1509
     <header>
1438 1510
       <h1>Madagascar Local Authority</h1>
1511
+      <nav aria-label="Sections">
1512
+        <a href="/overview" data-page-link="overview">Overview</a>
1513
+        <a href="/hosts" data-page-link="hosts">Hosts</a>
1514
+        <a href="/dns" data-page-link="dns">DNS</a>
1515
+        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
1516
+        <a href="/ca" data-page-link="ca">Local CA</a>
1517
+      </nav>
1439 1518
       <div class="header-right">
1440 1519
         <span class="muted" id="app-updated"></span>
1520
+        <span id="message" class="muted"></span>
1521
+        <button id="refresh">Refresh</button>
1441 1522
         <button type="button" id="logout">Logout</button>
1442 1523
       </div>
1443 1524
     </header>
1444 1525
     <main>
1445
-      <section class="toolbar">
1446
-        <button id="refresh">Refresh</button>
1447
-        <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
1448
-        <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
1449
-        <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
1450
-        <button id="write-tsv">Write local-hosts.tsv</button>
1451
-        <span id="message" class="muted"></span>
1526
+      <section class="page" id="page-overview" data-page="overview">
1527
+        <section class="panel">
1528
+          <div class="panel-head">
1529
+            <h2>Overview</h2>
1530
+            <div class="stats" id="stats"></div>
1531
+          </div>
1532
+          <div class="problems" id="problems"></div>
1533
+        </section>
1452 1534
       </section>
1453 1535
 
1454
-      <section class="panel">
1455
-        <div class="panel-head">
1456
-          <h2>Overview</h2>
1457
-          <div class="stats" id="stats"></div>
1458
-        </div>
1459
-        <div class="problems" id="problems"></div>
1536
+      <section class="page" id="page-hosts" data-page="hosts" hidden>
1537
+        <section class="panel">
1538
+          <div class="panel-head">
1539
+            <h2>Hosts</h2>
1540
+            <div class="host-tools">
1541
+              <input id="filter" placeholder="filter">
1542
+              <button type="button" id="new-host">New host</button>
1543
+            </div>
1544
+          </div>
1545
+          <div class="table-wrap">
1546
+            <table>
1547
+              <thead>
1548
+                <tr>
1549
+                  <th style="width: 120px">ID</th>
1550
+                  <th style="width: 130px">hosts_ip</th>
1551
+                  <th style="width: 130px">dns_ip</th>
1552
+                  <th>Names</th>
1553
+                  <th style="width: 150px">Roles</th>
1554
+                  <th style="width: 110px">Monitoring</th>
1555
+                  <th style="width: 90px">Status</th>
1556
+                </tr>
1557
+              </thead>
1558
+              <tbody id="hosts"></tbody>
1559
+            </table>
1560
+          </div>
1561
+        </section>
1460 1562
       </section>
1461 1563
 
1462
-      <section class="panel">
1463
-        <div class="panel-head">
1464
-          <h2>Local Certificate Authority</h2>
1465
-          <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
1466
-        </div>
1467
-        <div class="problems" id="ca-status"></div>
1564
+      <section class="page" id="page-dns" data-page="dns" hidden>
1565
+        <section class="toolbar">
1566
+          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
1567
+          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
1568
+          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
1569
+          <button id="write-tsv">Write local-hosts.tsv</button>
1570
+        </section>
1468 1571
       </section>
1469 1572
 
1470
-      <section class="panel">
1471
-        <div class="panel-head">
1472
-          <h2>Work Orders</h2>
1473
-          <div class="stats" id="wo-stats"></div>
1474
-        </div>
1475
-        <div class="problems" id="work-orders"></div>
1573
+      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
1574
+        <section class="panel">
1575
+          <div class="panel-head">
1576
+            <h2>Work Orders</h2>
1577
+            <div class="stats" id="wo-stats"></div>
1578
+          </div>
1579
+          <div class="problems" id="work-orders"></div>
1580
+        </section>
1476 1581
       </section>
1477 1582
 
1478
-      <section class="panel">
1479
-        <div class="panel-head">
1480
-          <h2>Hosts</h2>
1481
-          <input id="filter" placeholder="filter" style="max-width: 240px">
1482
-        </div>
1483
-        <div class="table-wrap">
1484
-          <table>
1485
-            <thead>
1486
-              <tr>
1487
-                <th style="width: 120px">ID</th>
1488
-                <th style="width: 130px">hosts_ip</th>
1489
-                <th style="width: 130px">dns_ip</th>
1490
-                <th>Names</th>
1491
-                <th style="width: 150px">Roles</th>
1492
-                <th style="width: 110px">Monitoring</th>
1493
-                <th style="width: 90px">Status</th>
1494
-              </tr>
1495
-            </thead>
1496
-            <tbody id="hosts"></tbody>
1497
-          </table>
1498
-        </div>
1583
+      <section class="page" id="page-ca" data-page="ca" hidden>
1584
+        <section class="panel">
1585
+          <div class="panel-head">
1586
+            <h2>Local Certificate Authority</h2>
1587
+            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
1588
+          </div>
1589
+          <div class="problems" id="ca-status"></div>
1590
+        </section>
1591
+        <section class="panel">
1592
+          <div class="panel-head">
1593
+            <h2>Issued Certificates</h2>
1594
+            <div class="stats" id="ca-certs-summary"></div>
1595
+          </div>
1596
+          <div class="table-wrap">
1597
+            <table>
1598
+              <thead>
1599
+                <tr>
1600
+                  <th style="width: 150px">Name</th>
1601
+                  <th>DNS names</th>
1602
+                  <th style="width: 210px">Validity</th>
1603
+                  <th style="width: 180px">Serial</th>
1604
+                  <th>Fingerprint</th>
1605
+                  <th style="width: 110px">Download</th>
1606
+                </tr>
1607
+              </thead>
1608
+              <tbody id="ca-certs"></tbody>
1609
+            </table>
1610
+          </div>
1611
+        </section>
1499 1612
       </section>
1613
+    </main>
1500 1614
 
1501
-      <section class="panel">
1502
-        <div class="panel-head">
1503
-          <h2>Edit host</h2>
1615
+    <div id="host-modal" class="modal-backdrop" hidden>
1616
+      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
1617
+        <div class="modal-head">
1618
+          <h2 id="host-modal-title">Edit host</h2>
1619
+          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
1504 1620
         </div>
1505 1621
         <form id="host-form" class="grid">
1506 1622
           <label>ID<input name="id" required></label>
@@ -1512,13 +1628,13 @@ sub app_html {
1512 1628
           <label>Sources<input name="sources"></label>
1513 1629
           <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
1514 1630
           <label>Notes<input name="notes"></label>
1515
-          <div class="span2">
1631
+          <div class="span2 form-actions">
1516 1632
             <button class="primary" type="submit">Save host</button>
1517 1633
             <button class="danger" type="button" id="delete-host">Delete host</button>
1518 1634
           </div>
1519 1635
         </form>
1520 1636
       </section>
1521
-    </main>
1637
+    </div>
1522 1638
   </div>
1523 1639
 
1524 1640
   <div class="build-badge" title="Running build __HOST_MANAGER_BUILD_TITLE__">build __HOST_MANAGER_BUILD__</div>
@@ -1528,6 +1644,14 @@ sub app_html {
1528 1644
 
1529 1645
     const $ = (id) => document.getElementById(id);
1530 1646
     const msg = (text) => { $('message').textContent = text || ''; };
1647
+    const PAGE_PATHS = {
1648
+      '/': 'overview',
1649
+      '/overview': 'overview',
1650
+      '/hosts': 'hosts',
1651
+      '/dns': 'dns',
1652
+      '/work-orders': 'work-orders',
1653
+      '/ca': 'ca',
1654
+    };
1531 1655
 
1532 1656
     async function api(path, options = {}) {
1533 1657
       const res = await fetch(path, options);
@@ -1536,6 +1660,25 @@ sub app_html {
1536 1660
       return body;
1537 1661
     }
1538 1662
 
1663
+    function currentPage() {
1664
+      return PAGE_PATHS[window.location.pathname] || 'overview';
1665
+    }
1666
+
1667
+    function showPage(page, push = false) {
1668
+      const target = page || 'overview';
1669
+      document.querySelectorAll('[data-page]').forEach(section => {
1670
+        section.hidden = section.dataset.page !== target;
1671
+      });
1672
+      document.querySelectorAll('[data-page-link]').forEach(link => {
1673
+        link.classList.toggle('active', link.dataset.pageLink === target);
1674
+        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
1675
+      });
1676
+      if (push) {
1677
+        const href = target === 'overview' ? '/overview' : '/' + target;
1678
+        history.pushState({ page: target }, '', href);
1679
+      }
1680
+    }
1681
+
1539 1682
     function showLogin(errorText) {
1540 1683
       document.body.classList.remove('is-app');
1541 1684
       document.body.classList.add('is-login');
@@ -1550,6 +1693,7 @@ sub app_html {
1550 1693
       document.body.classList.add('is-app');
1551 1694
       $('login-screen').style.display = 'none';
1552 1695
       $('app').style.display = 'block';
1696
+      showPage(currentPage());
1553 1697
     }
1554 1698
 
1555 1699
     async function refresh() {
@@ -1585,21 +1729,80 @@ sub app_html {
1585 1729
         const status = await api('/api/ca/status');
1586 1730
         if (!status.initialized) {
1587 1731
           $('ca-status').innerHTML = '<div class="problem"><strong>not initialized</strong> Run <code>sudo scripts/ca_manager.sh init</code> on jumper.</div>';
1732
+          $('ca-certs-summary').innerHTML = '';
1733
+          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
1588 1734
           return;
1589 1735
         }
1590 1736
         const certs = await api('/api/ca/certificates');
1737
+        const caDays = daysUntil(status.not_after);
1591 1738
         $('ca-status').innerHTML = `
1592
-          <div class="muted" style="display:grid;gap:6px">
1739
+          <div class="muted ca-detail">
1593 1740
             <div><strong>${escapeHtml(status.subject || '')}</strong></div>
1594
-            <div>SHA256 ${escapeHtml(status.fingerprint_sha256 || '')}</div>
1741
+            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
1595 1742
             <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
1596
-            <div>${certs.length} issued certificate(s)</div>
1743
+            <div>
1744
+              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
1745
+              <span>${certs.length} issued certificate(s)</span>
1746
+            </div>
1597 1747
           </div>`;
1748
+        $('ca-certs-summary').innerHTML = [
1749
+          ['issued', certs.length],
1750
+          ['expiring', certs.filter(cert => {
1751
+            const days = daysUntil(cert.not_after);
1752
+            return days !== null && days >= 0 && days <= 30;
1753
+          }).length],
1754
+          ['expired', certs.filter(cert => {
1755
+            const days = daysUntil(cert.not_after);
1756
+            return days !== null && days < 0;
1757
+          }).length],
1758
+        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
1759
+        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
1760
+          const days = daysUntil(cert.not_after);
1761
+          const dnsNames = cert.dns_names || [];
1762
+          const dnsHtml = dnsNames.length
1763
+            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
1764
+            : '<span class="muted">No DNS SANs reported.</span>';
1765
+          return `<tr>
1766
+            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
1767
+            <td>${dnsHtml}</td>
1768
+            <td>
1769
+              <div class="ca-detail">
1770
+                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
1771
+                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
1772
+              </div>
1773
+            </td>
1774
+            <td class="mono">${escapeHtml(cert.serial || '')}</td>
1775
+            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
1776
+            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
1777
+          </tr>`;
1778
+        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
1598 1779
       } catch (e) {
1599 1780
         $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
1781
+        $('ca-certs-summary').innerHTML = '';
1782
+        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
1600 1783
       }
1601 1784
     }
1602 1785
 
1786
+    function daysUntil(dateText) {
1787
+      const time = Date.parse(dateText || '');
1788
+      if (!Number.isFinite(time)) return null;
1789
+      return Math.ceil((time - Date.now()) / 86400000);
1790
+    }
1791
+
1792
+    function certStatusClass(days) {
1793
+      if (days === null) return '';
1794
+      if (days < 0) return 'bad';
1795
+      if (days <= 30) return 'warn';
1796
+      return 'ok';
1797
+    }
1798
+
1799
+    function certStatusLabel(days) {
1800
+      if (days === null) return 'validity unknown';
1801
+      if (days < 0) return 'expired';
1802
+      if (days === 0) return 'expires today';
1803
+      return `${days}d remaining`;
1804
+    }
1805
+
1603 1806
     async function renderWorkOrders() {
1604 1807
       try {
1605 1808
         const data = await api('/api/work-orders');
@@ -1706,7 +1909,27 @@ sub app_html {
1706 1909
       form.elements.names.value = (host.names || []).join('\n');
1707 1910
       form.elements.roles.value = (host.roles || []).join(' ');
1708 1911
       form.elements.sources.value = (host.sources || []).join(' ');
1709
-      form.scrollIntoView({ behavior: 'smooth', block: 'start' });
1912
+      openHostModal('Edit host');
1913
+    }
1914
+
1915
+    function newHost() {
1916
+      const form = $('host-form');
1917
+      form.reset();
1918
+      form.elements.status.value = 'active';
1919
+      form.elements.monitoring.value = 'pending';
1920
+      openHostModal('New host');
1921
+    }
1922
+
1923
+    function openHostModal(title) {
1924
+      $('host-modal-title').textContent = title || 'Edit host';
1925
+      $('host-modal').hidden = false;
1926
+      document.body.style.overflow = 'hidden';
1927
+      $('host-form').elements.id.focus();
1928
+    }
1929
+
1930
+    function closeHostModal() {
1931
+      $('host-modal').hidden = true;
1932
+      document.body.style.overflow = '';
1710 1933
     }
1711 1934
 
1712 1935
     function formObject(form) {
@@ -1714,6 +1937,7 @@ sub app_html {
1714 1937
     }
1715 1938
 
1716 1939
     function escapeHtml(value) {
1940
+      value = value == null ? '' : String(value);
1717 1941
       return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
1718 1942
     }
1719 1943
 
@@ -1807,6 +2031,15 @@ sub app_html {
1807 2031
       otpAutofill.addEventListener('change', handleAutofill);
1808 2032
     }
1809 2033
 
2034
+    document.querySelectorAll('[data-page-link]').forEach(link => {
2035
+      link.addEventListener('click', (event) => {
2036
+        event.preventDefault();
2037
+        showPage(link.dataset.pageLink, true);
2038
+      });
2039
+    });
2040
+
2041
+    window.addEventListener('popstate', () => showPage(currentPage()));
2042
+
1810 2043
     $('login-form').addEventListener('submit', async (event) => {
1811 2044
       event.preventDefault();
1812 2045
       if (!otpReady()) return;
@@ -1824,17 +2057,25 @@ sub app_html {
1824 2057
 
1825 2058
     $('logout').addEventListener('click', async () => {
1826 2059
       await api('/api/logout', { method: 'POST' }).catch(() => {});
1827
-      clearOtp();
1828
-      showLogin();
2060
+      window.location.replace('/?logged_out=' + Date.now());
1829 2061
     });
1830 2062
 
1831 2063
     $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
1832 2064
     $('filter').addEventListener('input', renderHosts);
2065
+    $('new-host').addEventListener('click', newHost);
2066
+    $('close-host-modal').addEventListener('click', closeHostModal);
2067
+    $('host-modal').addEventListener('click', (event) => {
2068
+      if (event.target === $('host-modal')) closeHostModal();
2069
+    });
2070
+    window.addEventListener('keydown', (event) => {
2071
+      if (event.key === 'Escape' && !$('host-modal').hidden) closeHostModal();
2072
+    });
1833 2073
 
1834 2074
     $('host-form').addEventListener('submit', async (event) => {
1835 2075
       event.preventDefault();
1836 2076
       try {
1837 2077
         await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
2078
+        closeHostModal();
1838 2079
         msg('host saved');
1839 2080
         await refresh();
1840 2081
       } catch (e) { msg(e.message); }
@@ -1846,6 +2087,7 @@ sub app_html {
1846 2087
       try {
1847 2088
         await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
1848 2089
         $('host-form').reset();
2090
+        closeHostModal();
1849 2091
         msg('host deleted');
1850 2092
         await refresh();
1851 2093
       } catch (e) { msg(e.message); }