landfall 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -32,6 +32,8 @@ button {
32
32
  padding: 5px 11px; border-radius: 7px; cursor: pointer; white-space: nowrap;
33
33
  }
34
34
  button:hover { border-color: var(--faint); }
35
+ button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
36
+ button.primary:hover { filter: brightness(1.08); }
35
37
  button:disabled { opacity: .5; cursor: default; }
36
38
  button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
37
39
  button.danger { color: var(--bad); }
@@ -46,26 +48,37 @@ input.secret { -webkit-text-security: disc; text-security: disc; }
46
48
  input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-bg); border-color: var(--accent); }
47
49
  textarea { font-family: var(--mono); font-size: 12.5px; min-height: 90px; }
48
50
 
49
- .app { display: grid; grid-template-columns: 250px 1fr; grid-template-rows: 52px 1fr; height: 100vh; }
50
- .app.with-job { grid-template-columns: 250px 1fr minmax(380px, 40%); }
51
+ .app { display: grid; grid-template-columns: 250px minmax(0, 1fr); grid-template-rows: 52px 1fr; height: 100vh; }
52
+ .app.with-job { grid-template-columns: 250px minmax(0, 1fr) minmax(380px, 40%); }
51
53
  header { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
52
- .brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 8px; }
53
- .brand small { font-weight: 400; color: var(--faint); font-size: 12px; }
54
+ .brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 8px; letter-spacing: -.01em; color: var(--text); }
55
+ .brand:hover { text-decoration: none; }
56
+ .brand svg { width: 22px; height: 22px; flex: none; }
57
+ .brand small { font-weight: 400; color: var(--faint); font-size: 12px; letter-spacing: 0; }
54
58
  .project { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; min-width: 0; }
55
59
  .project .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 36vw; }
56
- nav.servers { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 10px 8px; }
57
- nav h3 { margin: 6px 8px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; }
58
- .srv { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 7px 10px; border-radius: 7px; }
59
- .srv:hover { background: var(--bg); }
60
+ nav.servers { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; display: flex; flex-direction: column; }
61
+ .nav-top { position: sticky; top: 0; z-index: 1; background: var(--panel); display: flex; gap: 6px; padding: 10px 10px 6px; }
62
+ .nav-top input { padding: 5px 9px; font-size: 13px; }
63
+ .nav-top button { flex: none; }
64
+ #navlist { padding: 0 8px 12px; }
65
+ nav h3 { margin: 10px 8px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; display: flex; justify-content: space-between; }
66
+ nav h3 span { font-weight: 500; letter-spacing: 0; }
67
+ .srv { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 6px 10px; border-radius: 7px; }
68
+ .srv:hover { background: var(--bg); border-color: transparent; }
60
69
  .srv.on { background: var(--accent-bg); }
70
+ .srv.on .n { color: var(--accent); }
61
71
  .srv .n { font-weight: 600; display: flex; align-items: center; gap: 6px; }
62
- .srv .t { color: var(--dim); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
72
+ .srv .t { color: var(--dim); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73
+ .nav-none { color: var(--faint); font-size: 12.5px; padding: 6px 10px; }
63
74
  .star { color: var(--warn); font-size: 11px; }
64
75
  main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: inline-size; }
76
+ .show-sm { display: none !important; }
65
77
  /* By main's own width, so an open job panel squeezes the tables like a small screen does. */
66
78
  @container (max-width: 720px) {
67
79
  td, th { padding: 8px; }
68
80
  .hide-sm { display: none; }
81
+ .show-sm { display: flex !important; }
69
82
  }
70
83
  .empty { color: var(--dim); text-align: center; padding: 60px 20px; }
71
84
  .empty h2 { color: var(--text); font-size: 18px; margin: 0 0 6px; }
@@ -73,13 +86,46 @@ main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: in
73
86
  .head h1 { margin: 0; font-size: 22px; }
74
87
  .head .sub { color: var(--dim); font-size: 13px; margin-top: 2px; }
75
88
  .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
76
- .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
77
- .tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 12px; color: var(--dim); }
89
+ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
90
+ .tabs::-webkit-scrollbar { display: none; }
91
+ .tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 12px; color: var(--dim); flex: none; }
92
+ .tabs button:hover { color: var(--text); }
78
93
  .tabs button.on { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
79
- .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); }
94
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); }
80
95
  .card + .card { margin-top: 16px; }
81
- .card > h2 { margin: 0; padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 10px; }
96
+ .card > h2 { margin: 0; padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
82
97
  .card > h2 .actions { font-weight: 400; }
98
+ .card > h2 .sub { font-weight: 400; color: var(--dim); }
99
+ .card > .lede { padding: 10px 16px; color: var(--dim); font-size: 13px; border-bottom: 1px solid var(--line); }
100
+
101
+ /* "⋯" menus: one floating list at a time, placed by openMenu(). */
102
+ button.more { padding-inline: 7px; letter-spacing: .05em; }
103
+ .menu { position: fixed; z-index: 30; min-width: 180px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); padding: 4px; display: grid; }
104
+ .menu button { border: 0; background: none; text-align: left; padding: 6px 10px; border-radius: 6px; display: flex; justify-content: space-between; gap: 16px; }
105
+ .menu button:hover, .menu button:focus-visible { background: var(--bg); outline: none; }
106
+ .menu button.danger:hover { background: var(--bad-bg); }
107
+ .menu button .k { color: var(--faint); font-family: var(--mono); font-size: 11.5px; }
108
+ .menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
109
+
110
+ .home { max-width: 860px; margin: 0 auto; padding-top: 20px; }
111
+ .home h1 { margin: 0 0 4px; font-size: 22px; }
112
+ .home > p { color: var(--dim); margin: 0 0 20px; }
113
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
114
+ .tile { display: block; text-align: left; padding: 14px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); width: 100%; }
115
+ .tile:hover { border-color: var(--accent); }
116
+ .tile .n { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; }
117
+ .tile .t { color: var(--dim); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
118
+ .tile.add { border-style: dashed; box-shadow: none; color: var(--dim); display: grid; place-items: center; min-height: 64px; }
119
+ .cli { margin-top: 28px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 13px; color: var(--dim); }
120
+ .cli pre { margin: 8px 0 0; font: 12.5px/1.6 var(--mono); color: var(--text); white-space: pre-wrap; }
121
+ .cli pre span { color: var(--faint); }
122
+
123
+ .drow { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 16px; padding: 10px 0; border-bottom: 1px solid var(--line); }
124
+ .drow:first-child { padding-top: 0; }
125
+ .drow > div:first-child { flex: 1 1 260px; min-width: 0; }
126
+ .drow .actions { flex: none; }
127
+ .site-name { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
128
+ td .chips.show-sm { margin-top: 4px; }
83
129
  .card .body { padding: 12px 16px; }
84
130
  table { border-collapse: collapse; width: 100%; }
85
131
  th { text-align: left; font-weight: 600; font-size: 11.5px; color: var(--faint); text-transform: uppercase; letter-spacing: .04em; padding: 8px 12px; border-bottom: 1px solid var(--line); }
@@ -102,11 +148,12 @@ td.acts button { margin-left: 4px; }
102
148
  .note { padding: 10px 14px; border-radius: 8px; background: var(--warn-bg); color: var(--warn); margin-bottom: 14px; }
103
149
  .note.bad { background: var(--bad-bg); color: var(--bad); }
104
150
  .note.info { background: var(--accent-bg); color: var(--accent); }
105
- .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: 13px; }
151
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: 13px; margin: 0; }
106
152
  .kv dt { color: var(--dim); } .kv dd { margin: 0; }
107
153
  pre.block { background: var(--bg); border: 1px solid var(--line); padding: 10px 12px; border-radius: 6px; overflow-x: auto; font-size: 12px; margin: 6px 0 0; }
108
154
  .detail td { background: var(--bg); }
109
- .releases { margin: 8px 0 0; padding: 0; list-style: none; }
155
+ .releases, .services { margin: 0; padding: 0; list-style: none; }
156
+ .services li { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
110
157
  .releases li { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
111
158
  .run { padding: 12px 16px; border-bottom: 1px solid var(--line); }
112
159
  .run:last-child { border-bottom: 0; }
@@ -178,11 +225,14 @@ body.dragging::after {
178
225
  }
179
226
 
180
227
  @media (max-width: 1100px) {
181
- .app.with-job { grid-template-columns: 220px 1fr; }
228
+ .app.with-job { grid-template-columns: 220px minmax(0, 1fr); }
182
229
  aside.job { position: fixed; inset: 52px 0 0 auto; width: min(560px, 100vw); z-index: 5; box-shadow: var(--shadow); }
183
230
  }
184
231
  @media (max-width: 760px) {
185
- .app, .app.with-job { grid-template-columns: 1fr; grid-template-rows: 52px auto 1fr; }
232
+ .app, .app.with-job { grid-template-columns: minmax(0, 1fr); grid-template-rows: 52px auto 1fr; }
233
+ header { padding: 0 12px; gap: 8px; }
234
+ .brand small { display: none; }
235
+ .project button { padding: 5px 9px; font-size: 13px; white-space: nowrap; }
186
236
  nav.servers { display: none; }
187
237
  .server-pick { display: block; padding: 10px 16px 0; }
188
238
  main { padding: 14px 16px 40px; }
@@ -194,10 +244,14 @@ body.dragging::after {
194
244
  <body>
195
245
  <div class="app" id="app">
196
246
  <header>
197
- <div class="brand">landfall <small id="version"></small></div>
247
+ <a class="brand" href="#/"><svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="4" fill="var(--accent)"/><path d="M4 5h8M4 8h8M4 11h5" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>landfall <small id="version"></small></a>
198
248
  <div class="project" id="project"></div>
199
249
  </header>
200
- <nav class="servers" id="nav"></nav>
250
+ <!-- The filter stays put across renders, so typing keeps its focus. -->
251
+ <nav class="servers" id="nav">
252
+ <div class="nav-top"><input id="filter" type="search" placeholder="Filter servers" autocomplete="off" spellcheck="false" aria-label="Filter servers"><button type="button" id="add-server" title="Register an ssh alias or set up a fresh machine">+</button></div>
253
+ <div id="navlist"></div>
254
+ </nav>
201
255
  <div class="server-pick" id="pick"></div>
202
256
  <main id="main"></main>
203
257
  <aside class="job" id="job" hidden></aside>
@@ -232,6 +286,7 @@ const S = {
232
286
  settings: {}, // name -> {data: lf server config --json + dns of the main domain}
233
287
  dns: {}, // name -> {data: {ips, domains: [{domain, ips, ok, https}]}}
234
288
  open: new Set(), // expanded site rows
289
+ filter: "", // server list filter
235
290
  jobs: new Map(), // id -> {info, lines, after, actions, es}
236
291
  job: null, // shown job id
237
292
  };
@@ -266,15 +321,64 @@ function btn(label, fn, cls = "", title = "") {
266
321
  }
267
322
  document.addEventListener("click", (e) => {
268
323
  const el = e.target.closest("[data-k]");
324
+ const menu = $("#menu");
325
+ // A click outside an open menu only closes it.
326
+ if (menu && !e.target.closest("#menu")) {
327
+ closeMenu();
328
+ e.preventDefault();
329
+ return void e.stopPropagation();
330
+ }
269
331
  if (!el || el.disabled) return;
270
332
  const fn = handlers.get(el.dataset.k);
271
333
  if (fn) {
272
334
  e.preventDefault();
273
335
  e.stopPropagation();
274
- fn(e);
336
+ fn(e, el);
275
337
  }
276
338
  });
277
339
 
340
+ /**
341
+ * A "⋯" button for the less used actions. items: [{label, run, cls, title, hint}] or "-" for a divider;
342
+ * falsy items are skipped. The menu keeps its own handlers, so a render while it is open leaves it working.
343
+ */
344
+ function menuBtn(items, title = "More actions", cls = "sm") {
345
+ items = items.filter(Boolean);
346
+ if (!items.length) return "";
347
+ return btn("⋯", (e, el) => openMenu(el, items), `${cls} more`, title);
348
+ }
349
+ function openMenu(anchor, items) {
350
+ closeMenu();
351
+ const m = Object.assign(document.createElement("div"), { id: "menu", className: "menu" });
352
+ m.setAttribute("role", "menu");
353
+ m.innerHTML = items
354
+ .map((it, i) => it === "-" ? "<hr>" : `<button type="button" role="menuitem" data-i="${i}" class="${it.cls ?? ""}"${it.title ? ` title="${esc(it.title)}"` : ""}><span>${esc(it.label)}</span>${it.hint ? `<span class="k">${esc(it.hint)}</span>` : ""}</button>`)
355
+ .join("");
356
+ m.onclick = (e) => {
357
+ const b = e.target.closest("button[data-i]");
358
+ if (!b) return;
359
+ closeMenu();
360
+ items[b.dataset.i].run();
361
+ };
362
+ m.onkeydown = (e) => {
363
+ const all = [...m.querySelectorAll("button")];
364
+ const i = all.indexOf(document.activeElement);
365
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
366
+ e.preventDefault();
367
+ all[(i + (e.key === "ArrowDown" ? 1 : all.length - 1)) % all.length].focus();
368
+ }
369
+ };
370
+ document.body.append(m);
371
+ const r = anchor.getBoundingClientRect();
372
+ m.style.left = `${Math.max(8, Math.min(r.right - m.offsetWidth, innerWidth - m.offsetWidth - 8))}px`;
373
+ m.style.top = `${r.bottom + 4 + m.offsetHeight > innerHeight - 8 ? Math.max(8, r.top - m.offsetHeight - 4) : r.bottom + 4}px`;
374
+ $("button", m)?.focus({ preventScroll: true });
375
+ }
376
+ function closeMenu() {
377
+ $("#menu")?.remove();
378
+ }
379
+ addEventListener("resize", closeMenu);
380
+ document.addEventListener("scroll", closeMenu, true);
381
+
278
382
  // ---- routing: #/<server>/<tab> ---------------------------------------------
279
383
 
280
384
  function go(sel, tab = S.tab) {
@@ -608,9 +712,9 @@ function passwordFor(args, base) {
608
712
  function addServer() {
609
713
  dialog({
610
714
  title: "Add a server",
611
- text: "Use an alias from ~/.ssh/config (like het), or pick a new name and give the machine's address, fresh or already in use. landfall writes the ssh config entry for it.",
715
+ text: "Use an alias from ~/.ssh/config (like vps), or pick a new name and give the machine's address, fresh or already in use. landfall writes the ssh config entry for it.",
612
716
  fields: [
613
- { name: "name", label: "Name", placeholder: "het" },
717
+ { name: "name", label: "Name", placeholder: "vps" },
614
718
  { name: "host", label: "IP or hostname (not needed for an ssh alias)", placeholder: "203.0.113.7" },
615
719
  {
616
720
  legend: "Login (for a new address)",
@@ -656,11 +760,12 @@ const tilde = (p) => p.replace(/^\/(Users|home)\/[^/]+/, "~");
656
760
  function describe(d) {
657
761
  const built = d.build ? ` · builds with <code>${esc(d.build)}</code> first` : "";
658
762
  const what =
659
- d.kind === "image" ? `Docker image <code>${esc(d.project?.image ?? "")}</code> (from .landfall.json)`
763
+ d.kind === "compose" ? `Compose project <code>${esc(d.compose ?? "compose.yaml")}</code>: built and started on the server; x-landfall picks the routed service`
764
+ : d.kind === "image" ? `Docker image <code>${esc(d.project?.image ?? "")}</code> (from .landfall.json)`
660
765
  : d.kind === "dockerfile" ? `Dockerfile: built on the server, served on port ${esc(d.port)}`
661
766
  : d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.spa ? " (single-page app)" : ""}${built}`
662
767
  : d.build ? `Static site, built with <code>${esc(d.build)}</code> first`
663
- : `<span style="color:var(--warn)">No index.html, Dockerfile or build script here.</span>`;
768
+ : `<span style="color:var(--warn)">No compose.yaml, Dockerfile, index.html or build script here.</span>`;
664
769
  const before = d.project?.server ? ` <span class="dim">· deployed before to ${esc(d.project.server)}</span>` : "";
665
770
  return what + before;
666
771
  }
@@ -833,7 +938,7 @@ function copy(text) {
833
938
  navigator.clipboard?.writeText(text).then(() => toast(`Copied ${text}`), () => toast(text)) ?? toast(text);
834
939
  }
835
940
 
836
- const servedByOther = (n, site, d) => S.overview[n]?.data?.sites.find((s) => s.meta.name !== site && s.meta.domains.includes(d))?.meta.name;
941
+ const servedByOther = (n, site, d) => S.overview[n]?.data?.sites.find((s) => s.meta.name !== site && s.domains.includes(d))?.meta.name;
837
942
 
838
943
  function domainsDialog(n, m) {
839
944
  const main = mainDomain(n);
@@ -882,7 +987,7 @@ function authDialog(n, m) {
882
987
 
883
988
  function ensureDns(n, o) {
884
989
  if (S.dns[n] || !o?.sites.length) return;
885
- const domains = [...new Set(o.sites.flatMap((x) => x.meta.domains))];
990
+ const domains = [...new Set(o.sites.flatMap((x) => x.domains))];
886
991
  S.dns[n] = { loading: true };
887
992
  http(`/api/servers/${encodeURIComponent(n)}/dns?domains=${encodeURIComponent(domains.join(","))}`).then(
888
993
  (data) => ((S.dns[n] = { data }), render()),
@@ -937,13 +1042,19 @@ function renderNav() {
937
1042
  const list = st.servers;
938
1043
  const own = list.filter((s) => s.registered);
939
1044
  const aliases = list.filter((s) => !s.registered);
1045
+ const q = S.filter.toLowerCase();
1046
+ const hit = (s) => !q || s.name.toLowerCase().includes(q) || s.target.toLowerCase().includes(q) || s.name === S.sel;
940
1047
  const item = (s) => `<button type="button" class="srv ${s.name === S.sel ? "on" : ""}" ${on(() => go(s.name, S.sel === s.name ? S.tab : "sites"))}>
941
1048
  <div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
942
1049
  <div class="t">${esc(s.target)}</div></button>`;
943
- $("#nav").innerHTML = `
944
- ${own.length ? `<h3>Servers</h3>${own.map(item).join("")}` : ""}
945
- ${aliases.length ? `<h3>From ~/.ssh/config</h3>${aliases.map(item).join("")}` : ""}
946
- <div style="padding:10px 8px">${btn("+ Add server", addServer, "", "Register an ssh alias or set up a fresh machine")}</div>`;
1050
+ const group = (title, l) => {
1051
+ const shown = l.filter(hit);
1052
+ return l.length ? `<h3>${title}${q ? `<span>${shown.length}/${l.length}</span>` : ""}</h3>${shown.map(item).join("") || `<div class="nav-none">no match</div>`}` : "";
1053
+ };
1054
+ $("#filter").hidden = list.length < 8;
1055
+ $("#add-server").textContent = list.length < 8 ? "+ Add server" : "+";
1056
+ $("#navlist").innerHTML = `${group("Servers", own)}${group("From ~/.ssh/config", aliases)}
1057
+ ${list.length ? "" : `<div class="nav-none">No servers yet.</div>`}`;
947
1058
  $("#pick").innerHTML = `<select onchange="go(this.value || null)">
948
1059
  <option value="">Pick a server…</option>${list.map((s) => `<option value="${esc(s.name)}" ${s.name === S.sel ? "selected" : ""}>${esc(s.name)} · ${esc(s.target)}</option>`).join("")}
949
1060
  </select>`;
@@ -967,7 +1078,8 @@ function errorHint(error) {
967
1078
  }
968
1079
 
969
1080
  const isUp = (status) => /^up/i.test(status);
970
- const statusDot = (status) => `<span class="dot ${isUp(status) ? (/unhealthy|restarting/i.test(status) ? "warn" : "up") : "down"}"></span>`;
1081
+ // A one-off service that finished (Exited (0)) is not down: grey, not red.
1082
+ const statusDot = (status) => `<span class="dot ${isUp(status) ? (/unhealthy|restarting/i.test(status) ? "warn" : "up") : /^exited \(0\)/i.test(status) ? "" : "down"}"></span>`;
971
1083
 
972
1084
  const size = (b) => {
973
1085
  const u = ["B", "K", "M", "G", "T"];
@@ -1005,12 +1117,7 @@ function factsNote(n) {
1005
1117
 
1006
1118
  function renderMain() {
1007
1119
  const main = $("#main");
1008
- if (!S.sel) {
1009
- main.innerHTML = `<div class="empty"><h2>Pick a server</h2>
1010
- <p>landfall connects only when you open one. Servers are ssh targets: any ~/.ssh/config alias works.</p>
1011
- <p>${btn("+ Add server", addServer, "primary")}</p></div>`;
1012
- return;
1013
- }
1120
+ if (!S.sel) return void (main.innerHTML = homeView());
1014
1121
  const n = S.sel;
1015
1122
  const s = server();
1016
1123
  const ov = S.overview[n] ?? { loading: true };
@@ -1027,12 +1134,15 @@ function renderMain() {
1027
1134
  <div><h1>${esc(n)}${s?.isDefault ? ` <span class="star" title="default server">★</span>` : ""}</h1><div class="sub">${sub}</div>${factsLine(n)}</div>
1028
1135
  <div class="actions">
1029
1136
  ${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
1030
- ${btn("Traefik", () => run(["traefik", "status", ...sv(n)]), "", "lf traefik status")}
1031
- ${btn("Info", () => run(["server", "info", n]), "", "lf server info")}
1032
1137
  ${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
1033
- ${s?.isDefault ? "" : btn("Make default", () => makeDefault(n), "ghost")}
1034
- ${s?.registered ? btn("Forget", () => forget(n), "ghost danger") : ""}
1035
- ${btn("↻", () => load(n, S.tab, true) || render(), "ghost", "Reload")}
1138
+ ${btn("↻", () => load(n, S.tab, true) || render(), "", "Reload")}
1139
+ ${menuBtn([
1140
+ { label: "Server info", hint: "lf server info", run: () => run(["server", "info", n]) },
1141
+ { label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) },
1142
+ s?.isDefault ? null : { label: "Make default", title: "Commands without -s use it", run: () => makeDefault(n) },
1143
+ s?.registered ? "-" : null,
1144
+ s?.registered ? { label: `Forget ${n}…`, cls: "danger", hint: "lf server rm", run: () => forget(n) } : null,
1145
+ ], "More", "")}
1036
1146
  </div>
1037
1147
  </div>
1038
1148
  ${factsNote(n)}
@@ -1051,6 +1161,42 @@ function renderMain() {
1051
1161
  else view.innerHTML = historyView(n);
1052
1162
  }
1053
1163
 
1164
+ /** No server open: the servers to pick from, and how the same works in a terminal. */
1165
+ function homeView() {
1166
+ const list = S.state.servers;
1167
+ const own = list.filter((s) => s.registered);
1168
+ const tile = (s) => `<button type="button" class="tile" ${on(() => go(s.name, "sites"))}>
1169
+ <div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
1170
+ <div class="t">${esc(s.target)}</div></button>`;
1171
+ const def = list.find((s) => s.isDefault)?.name;
1172
+ return `<div class="home">
1173
+ <h1>${own.length ? "Your servers" : "Pick a server"}</h1>
1174
+ <p>landfall connects only when you open one. Servers are ssh targets: any ~/.ssh/config alias works${own.length < list.length ? ` (${list.length - own.length} in the list on the left)` : ""}.</p>
1175
+ <div class="grid">${own.map(tile).join("")}<button type="button" class="tile add" ${on(addServer)}>+ Add server</button></div>
1176
+ <div class="cli">Everything here runs an <span class="mono">lf</span> command, shown above its output, so you can do the same in a terminal:
1177
+ <pre>lf deploy ./dist -d blog <span># a folder, Dockerfile project or image, with HTTPS</span>
1178
+ lf ls${def ? "" : " -s <server>"} <span># sites, and what else Traefik routes</span>
1179
+ lf logs blog -f <span># follow a site's logs</span>
1180
+ lf undo <span># revert the last change</span>
1181
+ lf help <span># every command</span></pre></div>
1182
+ </div>`;
1183
+ }
1184
+
1185
+ /** Containers Traefik routes that landfall did not deploy: shown; Compose projects can be adopted. */
1186
+ function othersCard(n, o) {
1187
+ if (!o.others?.length) return "";
1188
+ const links = (e) => e.domains.map((d) => `<span class="chip"><a href="https://${esc(d)}" target="_blank" rel="noopener">${esc(d)}</a></span>`).join("");
1189
+ return `<div class="card scroll"><h2>Not deployed by landfall <span class="badge">${o.others.length}</span></h2>
1190
+ <div class="lede">Containers your Traefik routes, e.g. your own compose projects. Adopting one lists it as a site with logs, restart and stop; it keeps running from its folder.</div>
1191
+ <table><thead><tr><th>Name</th><th class="hide-sm">Domains</th><th class="hide-sm">Folder</th><th></th></tr></thead><tbody>${o.others
1192
+ .map((e) => `<tr><td><div class="site-name">${statusDot(e.status)}<b>${esc(e.name)}</b>&nbsp;<span class="faint">${esc(e.status.toLowerCase())}</span></div>
1193
+ <div class="chips show-sm">${links(e)}</div></td>
1194
+ <td class="hide-sm"><div class="chips">${links(e)}</div></td>
1195
+ <td class="hide-sm mono dim">${esc(e.dir ?? e.containers.join(", "))}</td>
1196
+ <td class="acts">${e.dir ? btn("Adopt…", () => adoptDialog(n, e), "sm", "lf adopt") : ""}</td></tr>`)
1197
+ .join("")}</tbody></table></div>`;
1198
+ }
1199
+
1054
1200
  function sitesView(n, o) {
1055
1201
  ensureDns(n, o);
1056
1202
  if (!o.ready) {
@@ -1060,73 +1206,111 @@ function sitesView(n, o) {
1060
1206
  }
1061
1207
  if (!o.sites.length) {
1062
1208
  return `<div class="card"><div class="body empty"><h2>No sites yet</h2>
1063
- <p>Deploy a static folder, a Dockerfile project or an image. It gets HTTPS automatically.</p>
1209
+ <p>Deploy a folder with a compose.yaml, a Dockerfile, static files, or an image. It gets HTTPS automatically.</p>
1064
1210
  <p>${btn("Deploy a folder…", () => folderDialog(S.state.cwd), "primary")} <span class="dim">or drop one here</span></p>
1065
- <p class="faint mono">lf deploy ./dist -d example.com -s ${esc(n)}</p></div></div>`;
1211
+ <p class="faint mono">lf deploy ./dist -d example.com -s ${esc(n)}</p></div></div>${othersCard(n, o)}`;
1066
1212
  }
1067
1213
  const rows = o.sites
1068
- .map(({ meta: m, status }) => {
1214
+ .map(({ meta: m, status, services, domains, main, state, kind }) => {
1069
1215
  const cur = m.releases.find((r) => r.id === m.current);
1070
1216
  const name = m.name;
1071
1217
  const open = S.open.has(`${n}/${name}`);
1072
1218
  const up = isUp(status);
1219
+ const old = state === "old";
1220
+ // Adopted and pre-Compose sites: landfall runs them, it does not deploy them.
1221
+ const managed = state === "managed";
1073
1222
  const older = [...m.releases].sort((a, b) => (a.at < b.at ? 1 : -1)).filter((r) => r.at < (cur?.at ?? ""));
1074
1223
  const proj = (S.state.projects ?? []).find((p) => p.server === n && p.name === name);
1224
+ const deleteSite = () =>
1225
+ confirmDlg({
1226
+ title: m.adopted ? `Stop and forget ${name}?` : `Delete ${name} from ${n}?`,
1227
+ text: m.adopted
1228
+ ? `Runs docker compose down in ${m.adopted}; its files and volumes stay.`
1229
+ : "Stops it and removes its files and releases. Its named volumes (databases, uploads) stay.\nThis cannot be undone.",
1230
+ ok: m.adopted ? `Forget ${name}` : `Delete ${name}`,
1231
+ danger: true,
1232
+ onOk: () => run(["sites", "rm", name, ...sv(n), "-y"]),
1233
+ });
1234
+ const svcMenu = (x) =>
1235
+ menuBtn([
1236
+ { label: "Logs", hint: `lf logs ${name} ${x.service}`, run: () => run(["logs", name, x.service, ...sv(n), "-f"]) },
1237
+ { label: "Restart", hint: "lf restart", run: () => run(["restart", name, x.service, ...sv(n)]) },
1238
+ { label: isUp(x.status) ? "Stop" : "Start", hint: `lf ${isUp(x.status) ? "stop" : "start"}`, run: () => run([isUp(x.status) ? "stop" : "start", name, x.service, ...sv(n)]) },
1239
+ ], `${x.service} of ${name}`, "sm ghost");
1075
1240
  const acts = [
1076
- proj
1241
+ proj && !m.adopted
1077
1242
  ? btn("Redeploy", () => confirmDlg({ title: `Redeploy ${name}?`, text: `From ${tilde(proj.path)}${proj.kind === "static" ? " (runs its build first, if it has one)" : ""}.`, ok: "Deploy", onOk: () => run(["deploy", "-y", ...sv(n)], { server: n, cwd: proj.path, after: openAfter }) }), "sm primary", `lf deploy in ${proj.path}`)
1078
1243
  : "",
1079
- btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the container logs"),
1080
- btn("Restart", () => run(["restart", name, ...sv(n)]), "sm"),
1081
- btn(up ? "Stop" : "Start", () => run([up ? "stop" : "start", name, ...sv(n)]), "sm"),
1082
- older.length
1083
- ? btn("Roll back", () => confirmDlg({ title: `Roll back ${name}?`, text: `From ${m.current} to ${older[0].id} (${ago(older[0].at)})`, ok: "Roll back", onOk: () => run(["rollback", name, ...sv(n), "-y"]) }), "sm")
1084
- : "",
1085
- btn("Domains", () => domainsDialog(n, m), "sm", "Add, change or remove domains"),
1086
- btn(m.auth?.length ? "Login" : "Password", () => authDialog(n, m), "sm", "Protect the site with a user and password"),
1087
- m.kind === "static" ? "" : btn("Env", () => envDialog(n, name), "sm", "Environment variables"),
1088
- btn("Delete", () => confirmDlg({ title: `Delete ${name} from ${n}?`, text: "Stops it and removes all its files and releases.\nThis cannot be undone.", ok: `Delete ${name}`, danger: true, onOk: () => run(["sites", "rm", name, ...sv(n), "-y"]) }), "sm danger"),
1244
+ old ? "" : btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the logs of all its services"),
1245
+ menuBtn([
1246
+ old ? null : { label: "Restart", hint: "lf restart", run: () => run(["restart", name, ...sv(n)]) },
1247
+ old ? null : { label: up ? "Stop" : "Start", hint: `lf ${up ? "stop" : "start"}`, run: () => run([up ? "stop" : "start", name, ...sv(n)]) },
1248
+ managed && older.length
1249
+ ? { label: "Roll back…", hint: "lf rollback", title: `To ${older[0].id} (${ago(older[0].at)})`, run: () => confirmDlg({ title: `Roll back ${name}?`, text: `From ${m.current} to ${older[0].id} (${ago(older[0].at)}). Volumes keep their data.`, ok: "Roll back", onOk: () => run(["rollback", name, ...sv(n), "-y"]) }) }
1250
+ : null,
1251
+ "-",
1252
+ main ? { label: "Domains…", hint: "lf domains", run: () => domainsDialog(n, m) } : null,
1253
+ managed ? { label: m.auth?.length ? "Login…" : "Password…", hint: "lf auth", title: "Protect the site with a user and password", run: () => authDialog(n, m) } : null,
1254
+ managed ? { label: "Environment…", hint: "lf env", run: () => envDialog(n, name) } : null,
1255
+ "-",
1256
+ { label: m.adopted ? `Forget ${name}…` : `Delete ${name}…`, cls: "danger", hint: "lf sites rm", run: deleteSite },
1257
+ ], `More for ${name}`),
1089
1258
  ].join("");
1090
1259
  const toggle = () => (open ? S.open.delete(`${n}/${name}`) : S.open.add(`${n}/${name}`), render());
1260
+ const live = m.releases?.find((r) => r.id === m.current);
1261
+ const svcDomains = (svc) => (svc === main ? m.domains : (live?.services?.find((x) => x.name === svc)?.domains ?? []));
1091
1262
  const detail = open
1092
1263
  ? `<tr class="detail"><td colspan="5"><dl class="kv">
1093
- <dt>Container</dt><dd class="mono">landfall-${esc(name)} · ${esc(status)}</dd>
1094
- <dt>On ${esc(n)}</dt><dd>${siteFiles(m, cur)}</dd>
1095
- <dt>Source</dt><dd>${proj
1096
- ? `<span class="mono">${esc(tilde(proj.path))}</span> <span class="dim">· settings in</span> <span class="mono">.landfall.json</span> <span class="dim">· redeploy runs there</span>`
1097
- : `<span class="dim">not deployed from this machine${m.kind === "image" ? "" : " (or the folder moved)"}</span>`}</dd>
1098
- <dt>Kind</dt><dd>${esc(m.kind)}${m.spa ? " (single-page app)" : ""} · port ${esc(m.port)}</dd>
1099
- <dt>Login</dt><dd>${m.auth?.length ? `${esc(m.auth.map((a) => a.split(":")[0]).join(", "))} (basic auth)` : `<span class="dim">public</span>`}</dd>
1264
+ ${old ? `<dt>Old site</dt><dd class="dim">Deployed before sites were Compose projects: ${proj ? "redeploy it" : `run <span class="mono">lf deploy</span> for it`} once to manage it again.</dd>` : ""}
1265
+ <dt>Services</dt><dd>${services.length
1266
+ ? `<ul class="services">${services.map((x) => `<li>${statusDot(x.status)}<b>${esc(x.service)}</b>${svcDomains(x.service).length ? ` <span class="badge">${esc(svcDomains(x.service).join(" "))}</span>` : ""} <span class="faint">${esc(x.status.toLowerCase())}</span>${old ? "" : svcMenu(x)}</li>`).join("")}</ul>`
1267
+ : `<span class="dim">none running</span>`}</dd>
1268
+ <dt>On ${esc(n)}</dt><dd>${siteFiles(m, state)}</dd>
1269
+ ${m.adopted ? "" : `<dt>Source</dt><dd>${proj
1270
+ ? `<span class="mono">${esc(tilde(proj.path))}</span> <span class="dim">· redeploy runs there</span>`
1271
+ : `<span class="dim">not deployed from this machine${m.kind === "image" ? "" : " (or the folder moved)"}</span>`}</dd>`}
1272
+ <dt>Kind</dt><dd>${esc(kind)}${main || !managed ? "" : ` <span class="dim">· domains set in compose.yaml</span>`}</dd>
1273
+ ${managed ? `<dt>Login</dt><dd>${m.auth?.length ? `${esc(m.auth.map((a) => a.split(":")[0]).join(", "))} (basic auth)` : `<span class="dim">public</span>`}</dd>` : ""}
1100
1274
  <dt>Created</dt><dd>${esc(ago(m.createdAt))} · updated ${esc(ago(m.updatedAt))}</dd>
1101
- <dt>Releases</dt><dd><ul class="releases">${[...m.releases]
1275
+ ${m.releases.length ? `<dt>Releases</dt><dd><ul class="releases">${[...m.releases]
1102
1276
  .sort((a, b) => (a.at < b.at ? 1 : -1))
1103
- .map((r) => `<li><span class="mono">${esc(r.id)}</span><span class="dim">${esc(ago(r.at))}</span>${r.image && m.kind === "image" ? `<span class="mono faint">${esc(r.image)}</span>` : ""}
1104
- ${r.id === m.current ? `<span class="badge ok">live</span>` : btn("Switch to this", () => confirmDlg({ title: `Switch ${name} to ${r.id}?`, text: `Release from ${ago(r.at)} goes live instead of ${m.current}.`, ok: "Switch", onOk: () => run(["rollback", name, ...sv(n), "--to", r.id, "-y"]) }), "sm")}</li>`)
1105
- .join("")}</ul></dd>
1277
+ .map((r) => `<li><span class="mono">${esc(r.id)}</span><span class="dim">${esc(ago(r.at))}</span>${r.services ? `<span class="faint">${esc(r.services.map((x) => x.name).join(", "))}</span>` : ""}
1278
+ ${r.id === m.current ? `<span class="badge ok">live</span>` : managed ? btn("Switch to this", () => confirmDlg({ title: `Switch ${name} to ${r.id}?`, text: `Release from ${ago(r.at)} goes live instead of ${m.current}. Volumes keep their data.`, ok: "Switch", onOk: () => run(["rollback", name, ...sv(n), "--to", r.id, "-y"]) }), "sm") : ""}</li>`)
1279
+ .join("")}</ul></dd>` : ""}
1106
1280
  </dl></td></tr>`
1107
1281
  : "";
1108
1282
  return `<tr class="click" ${on(toggle)}>
1109
- <td class="nowrap">${statusDot(status)}<b>${esc(name)}</b>${m.auth?.length ? `<span class="lock" title="password protected">🔒</span>` : ""} <span class="faint">${open ? "▾" : "▸"}</span></td>
1110
- <td class="hide-sm"><div class="chips">${m.domains.map((d) => domainChip(n, d)).join("")}</div></td>
1111
- <td class="hide-sm"><span class="badge">${esc(m.kind)}</span></td>
1283
+ <td><div class="site-name">${statusDot(status)}<b>${esc(name)}</b>${m.auth?.length ? `<span class="lock" title="password protected">🔒</span>` : ""} <span class="faint">${open ? "▾" : "▸"}</span></div>
1284
+ <div class="chips show-sm">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
1285
+ <td class="hide-sm"><div class="chips">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
1286
+ <td class="hide-sm"><span class="badge" title="${esc(services.map((x) => x.service).join(", "))}">${esc(kind)}</span>${services.length > 1 ? ` <span class="faint">${services.length} services</span>` : ""}</td>
1112
1287
  <td class="hide-sm dim">${cur ? esc(ago(cur.at)) : ""}</td>
1113
1288
  <td class="acts">${acts}</td></tr>${detail}`;
1114
1289
  })
1115
1290
  .join("");
1116
1291
  return `<div class="card scroll"><table>
1117
1292
  <thead><tr><th>Site</th><th class="hide-sm">Domains</th><th class="hide-sm">Kind</th><th class="hide-sm">Deployed</th><th></th></tr></thead>
1118
- <tbody>${rows}</tbody></table></div>`;
1293
+ <tbody>${rows}</tbody></table></div>${othersCard(n, o)}`;
1119
1294
  }
1120
1295
 
1121
- /** Where a site lives on the server, and which of its files are live. */
1122
- function siteFiles(m, cur) {
1296
+ /** Where a site lives on the server: docker compose works in its current folder. */
1297
+ function siteFiles(m, state) {
1123
1298
  const dir = `${S.state.paths.sites}/${m.name}`;
1124
- const live =
1125
- m.kind === "image" ? `image <span class="mono">${esc(cur?.image ?? "?")}</span>`
1126
- : m.kind === "static" ? `files <span class="mono">${esc(`${dir}/releases/${m.current}`)}</span>`
1127
- : `built from <span class="mono">${esc(`${dir}/releases/${m.current}`)}</span>${cur?.image ? ` as <span class="mono">${esc(cur.image)}</span>` : ""}`;
1128
- return `<span class="mono">${esc(dir)}</span><div class="dim">live: ${live}</div>
1129
- <div class="faint mono">site.json · docker-compose.yml · .env (mode 600)${m.kind === "image" ? "" : " · releases/"}</div>`;
1299
+ if (m.adopted) return `<span class="mono">${esc(m.adopted)}</span><div class="dim">its own folder; project <span class="mono">${esc(m.project ?? m.name)}</span></div>`;
1300
+ if (state === "old") return `<span class="mono">${esc(dir)}</span>`;
1301
+ return `<span class="mono">${esc(dir)}/current</span> <span class="dim">→ releases/${esc(m.current)}</span>
1302
+ <div class="faint mono">compose.yaml · compose.override.yaml (landfall's) · .env (mode 600)</div>
1303
+ <div class="faint">run it by hand: <span class="mono">cd ${esc(dir)}/current && docker compose ps</span></div>`;
1304
+ }
1305
+
1306
+ function adoptDialog(n, e) {
1307
+ dialog({
1308
+ title: `Adopt ${e.name}?`,
1309
+ text: `${e.name} keeps running from ${e.dir} with its own files and Traefik labels. landfall lists it as a site with logs, restart, stop and start; it does not deploy it or change its domains.`,
1310
+ fields: [{ name: "name", label: "Site name", value: e.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+/, "") }],
1311
+ ok: "Adopt",
1312
+ onOk: (v) => run(["adopt", e.name, ...sv(n), "--name", v.name.trim()], { server: n }),
1313
+ });
1130
1314
  }
1131
1315
 
1132
1316
  function envDialog(n, name) {
@@ -1357,15 +1541,17 @@ function settingsView(n) {
1357
1541
  if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
1358
1542
  }, "Apply");
1359
1543
  const sites = S.overview[n]?.data?.sites ?? [];
1360
- const servedBy = (d) => sites.find((s) => s.meta.domains.includes(d))?.meta.name;
1544
+ const external = (d) => S.overview[n]?.data?.others?.find((e) => e.domains.includes(d))?.name;
1545
+ const servedBy = (d) => sites.find((s) => s.domains.includes(d))?.meta.name;
1361
1546
  const serve = (d) => {
1362
1547
  const cur = servedBy(d);
1363
- const pick = sites.filter((s) => s.meta.name !== cur);
1548
+ // Only sites whose domains landfall sets; the others name theirs in compose.yaml.
1549
+ const pick = sites.filter((s) => s.main && s.meta.name !== cur);
1364
1550
  if (!pick.length) return void toast(cur ? `${cur} is the only site on ${n}` : `No sites on ${n} yet: deploy one with @ as its domain`);
1365
1551
  dialog({
1366
1552
  title: `Serve a site on ${d}`,
1367
1553
  text: cur ? `${cur} serves ${d} now; it moves to the site you pick, which keeps its other domains.` : `The site keeps its other domains. ${d} needs an A record pointing at the server.`,
1368
- fields: [{ name: "site", label: "Site", type: "select", options: pick.map((s) => ({ value: s.meta.name, label: `${s.meta.name} (${s.meta.domains.join(", ")})` })), value: pick[0].meta.name }],
1554
+ fields: [{ name: "site", label: "Site", type: "select", options: pick.map((s) => ({ value: s.meta.name, label: `${s.meta.name} (${s.domains.join(", ")})` })), value: pick[0].meta.name }],
1369
1555
  ok: "Serve it",
1370
1556
  onOk: (v) => run(["domains", v.site, ...sv(n), "--add", d, "-y"], { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "settings", true), load(n, "sites", true), render()) : undefined) }),
1371
1557
  });
@@ -1376,19 +1562,22 @@ function settingsView(n) {
1376
1562
  const saved = x.saved ? Object.entries(x.saved).filter(([k, v]) => v !== undefined && k !== "dashboard").map(([k, v]) => [k, `<span class="mono">${esc(String(v))}</span>`]) : [];
1377
1563
  return `
1378
1564
  <div class="card">
1379
- <h2>Main domains ${btn("Add…", addDomain, "sm")}</h2>
1565
+ <h2>Main domains <span class="actions">${btn("Add domain…", addDomain, "sm")}</span></h2>
1380
1566
  <div class="body">${x.domains.length
1381
- ? `<table><tbody>${x.domains.map((d, i) => `<tr>
1382
- <td>${dnsRow(x.dns[i].apex)}${i === 0 ? ` <span class="badge">primary</span>` : ""}<div class="faint">sites: ${dnsRow(x.dns[i].wildcard)}</div><div class="faint">${servedBy(d) ? `served by <b>${esc(servedBy(d))}</b>` : "no site serves it"}</div></td>
1383
- <td><div class="actions">${btn(servedBy(d) ? "Serve another site…" : "Serve a site…", () => serve(d), "ghost sm")}${i ? btn("Make primary", () => run(["server", "domain", n, "--primary", d], reload), "ghost sm") : ""}${btn("Remove", () => confirmDlg({ title: `Remove ${d} from ${n}?`, text: "New sites stop getting names under it. Sites keep their domains.", ok: "Remove it", danger: true, onOk: () => run(["server", "domain", n, "--rm", d, "-y"], reload) }), "ghost sm")}</div></td>
1384
- </tr>`).join("")}</tbody></table>
1567
+ ? `${x.domains.map((d, i) => `<div class="drow">
1568
+ <div>${dnsRow(x.dns[i].apex)}${i === 0 ? ` <span class="badge accent">primary</span>` : ""}<div class="dim">sites: ${dnsRow(x.dns[i].wildcard)}</div><div class="faint">${servedBy(d) ? `served by <b>${esc(servedBy(d))}</b>` : external(d) ? `served by <b>${esc(external(d))}</b> (not landfall)` : "no site serves it"}</div></div>
1569
+ <div class="actions">${btn(servedBy(d) ? "Serve another site…" : "Serve a site…", () => serve(d), "sm")}${menuBtn([
1570
+ i ? { label: "Make primary", hint: "--primary", run: () => run(["server", "domain", n, "--primary", d], reload) } : null,
1571
+ { label: `Remove ${d}…`, cls: "danger", hint: "--rm", run: () => confirmDlg({ title: `Remove ${d} from ${n}?`, text: "New sites stop getting names under it. Sites keep their domains.", ok: "Remove it", danger: true, onOk: () => run(["server", "domain", n, "--rm", d, "-y"], reload) }) },
1572
+ ], `More for ${d}`)}</div>
1573
+ </div>`).join("")}
1385
1574
  <p class="faint">New sites get <span class="mono">&lt;site&gt;.${esc(x.domains[0])}</span>; a bare name like <span class="mono">blog</span> means <span class="mono">blog.${esc(x.domains[0])}</span>, and <span class="mono">@</span> means <span class="mono">${esc(x.domains[0])}</span> itself.</p>
1386
1575
  ${records.length ? `<div class="note">Add at your DNS provider:<div class="mono">${records.join("<br>")}</div></div>` : ""}`
1387
1576
  : `<p class="dim">No main domain set in landfall. New sites get a free <span class="mono">&lt;site&gt;.&lt;ip&gt;.sslip.io</span> address until you set one.</p>`}
1388
1577
  ${x.suggest.length ? `<div class="note">${x.domains.length ? "Other domains" : "Domains"} your sites on this server already use: ${x.suggest.map((g) => `<b>${esc(g.domain)}</b> <span class="faint">(${g.hosts})</span> ${btn("Add", () => run(["server", "domain", n, "--add", g.domain], reload), "sm")}`).join(" ")}</div>` : ""}</div>
1389
1578
  </div>
1390
1579
  <div class="card">
1391
- <h2>Traefik ${btn("Dashboard…", dashboard, "sm")}</h2>
1580
+ <h2>Traefik <span class="actions">${btn("Dashboard…", dashboard, "sm")}</span></h2>
1392
1581
  <div class="body">${rows([
1393
1582
  ["image", esc(x.traefik.summary)],
1394
1583
  ["e-mail", esc(x.traefik.email ?? "none")],
@@ -1481,12 +1670,19 @@ function render() {
1481
1670
  }
1482
1671
 
1483
1672
  document.addEventListener("keydown", (e) => {
1673
+ if (e.key === "Escape" && $("#menu")) return closeMenu();
1484
1674
  if (e.key === "Escape" && !$("#dlg").open && S.job) {
1485
1675
  S.job = null;
1486
1676
  render();
1487
1677
  }
1488
1678
  });
1489
1679
 
1680
+ $("#filter").addEventListener("input", (e) => {
1681
+ S.filter = e.target.value.trim();
1682
+ renderNav();
1683
+ });
1684
+ $("#add-server").onclick = () => addServer();
1685
+
1490
1686
  (async () => {
1491
1687
  try {
1492
1688
  await refreshState();