landfall 0.1.0 → 0.3.0

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,16 +712,16 @@ 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)",
617
721
  fields: [
618
722
  { name: "user", label: "User", placeholder: "root" },
619
723
  { name: "port", label: "SSH port", placeholder: "22" },
620
- { name: "key", label: "Private key file (optional)", placeholder: "~/.ssh/id_ed25519", hint: "Without one, landfall creates ~/.ssh/srv_<name> and installs it with the password." },
724
+ { name: "key", label: "Private key file (optional)", placeholder: "~/.ssh/id_ed25519", hint: "Without one, landfall creates ~/.ssh/landfall_<name> and installs it with the password." },
621
725
  { name: "password", label: "Password (optional)", type: "password", hint: "Used once to install the key, never stored. Leave empty if the key already works." },
622
726
  ],
623
727
  },
@@ -656,16 +760,17 @@ 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 srv.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
  }
667
772
 
668
- /** Deploy a local folder (det from /api/fs or an upload). upload: a temporary folder, so no srv.json. */
773
+ /** Deploy a local folder (det from /api/fs or an upload). upload: a temporary folder, so no .landfall.json. */
669
774
  function deployDialog(det, o = {}) {
670
775
  const p = det.project;
671
776
  const servers = S.state.servers.map((s) => ({ value: s.name, label: s.name }));
@@ -674,7 +779,7 @@ function deployDialog(det, o = {}) {
674
779
  const main = mainDomain(target);
675
780
  dialog({
676
781
  title: `Deploy ${name}`,
677
- text: o.upload ? `Uploaded ${plural(o.files ?? 0, "file")}. Not remembered: drop it again to redeploy.` : `From ${tilde(det.path)}${p ? " (settings from srv.json)" : ""}`,
782
+ text: o.upload ? `Uploaded ${plural(o.files ?? 0, "file")}. Not remembered: drop it again to redeploy.` : `From ${tilde(det.path)}${p ? " (settings from .landfall.json)" : ""}`,
678
783
  extra: `<div class="detected">${describe(det)}</div>`,
679
784
  fields: [
680
785
  { name: "name", label: "Site name", value: name },
@@ -683,7 +788,7 @@ function deployDialog(det, o = {}) {
683
788
  name: "domains", label: "Domains", value: (p?.domains ?? []).join(" "),
684
789
  placeholder: autoDomain(target, name) || "example.com www.example.com",
685
790
  hint: main
686
- ? `Empty: <name>.${main}. A bare name like blog means blog.${main}. Other domains need an A record pointing at the server.`
791
+ ? `Empty: <name>.${main}. A bare name like blog means blog.${main}, @ means ${main} itself (another site serving it hands it over). Other domains need an A record pointing at the server.`
687
792
  : "Empty: a free <name>.<ip>.sslip.io address, no DNS needed. Own domains need an A record pointing at the server.",
688
793
  },
689
794
  ...(det.kind === "static"
@@ -833,6 +938,8 @@ function copy(text) {
833
938
  navigator.clipboard?.writeText(text).then(() => toast(`Copied ${text}`), () => toast(text)) ?? toast(text);
834
939
  }
835
940
 
941
+ const servedByOther = (n, site, d) => S.overview[n]?.data?.sites.find((s) => s.meta.name !== site && s.domains.includes(d))?.meta.name;
942
+
836
943
  function domainsDialog(n, m) {
837
944
  const main = mainDomain(n);
838
945
  const free = autoDomain(n, m.name);
@@ -842,12 +949,14 @@ function domainsDialog(n, m) {
842
949
  text: `Each domain needs an A record pointing at ${ips.join(", ") || "the server"}. Traefik fetches the certificate once it resolves.`,
843
950
  fields: [
844
951
  { name: "domains", label: "Domains (space separated)", value: m.domains.join(" ") },
952
+ ...(main && !m.domains.includes(main) ? [{ name: "apex", label: `Also serve ${main}`, type: "check", value: false, hint: servedByOther(n, m.name, main) ? `${servedByOther(n, m.name, main)} serves it now and hands it over.` : "The main domain itself." }] : []),
845
953
  ...(free && !m.domains.includes(free) ? [{ name: "free", label: main ? `Also add ${free}` : `Also add the free address ${free}`, type: "check", value: false, hint: main ? `Under the main domain: works once *.${main} points at the server.` : "Works right away, no DNS setup." }] : []),
846
954
  ],
847
955
  ok: "Save",
848
956
  onOk: (v) => {
849
957
  const domains = v.domains.split(/[\s,]+/).filter(Boolean);
850
958
  if (v.free) domains.push(free);
959
+ if (v.apex) domains.push(main);
851
960
  if (!domains.length) return void toast("A site needs at least one domain"), false;
852
961
  run(["domains", m.name, ...sv(n), ...domains.flatMap((d) => ["--set", d]), "-y"], { server: n });
853
962
  },
@@ -878,7 +987,7 @@ function authDialog(n, m) {
878
987
 
879
988
  function ensureDns(n, o) {
880
989
  if (S.dns[n] || !o?.sites.length) return;
881
- const domains = [...new Set(o.sites.flatMap((x) => x.meta.domains))];
990
+ const domains = [...new Set(o.sites.flatMap((x) => x.domains))];
882
991
  S.dns[n] = { loading: true };
883
992
  http(`/api/servers/${encodeURIComponent(n)}/dns?domains=${encodeURIComponent(domains.join(","))}`).then(
884
993
  (data) => ((S.dns[n] = { data }), render()),
@@ -933,13 +1042,19 @@ function renderNav() {
933
1042
  const list = st.servers;
934
1043
  const own = list.filter((s) => s.registered);
935
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;
936
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"))}>
937
1048
  <div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
938
1049
  <div class="t">${esc(s.target)}</div></button>`;
939
- $("#nav").innerHTML = `
940
- ${own.length ? `<h3>Servers</h3>${own.map(item).join("")}` : ""}
941
- ${aliases.length ? `<h3>From ~/.ssh/config</h3>${aliases.map(item).join("")}` : ""}
942
- <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>`}`;
943
1058
  $("#pick").innerHTML = `<select onchange="go(this.value || null)">
944
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("")}
945
1060
  </select>`;
@@ -952,7 +1067,7 @@ function renderHeader() {
952
1067
  $("#project").innerHTML = `
953
1068
  <span class="path" title="${esc(st.cwd)}">${p ? `<b>${esc(p.name)}</b> · ${esc(p.domains.join(", "))}` : esc(st.cwd.replace(/^\/(Users|home)\/[^/]+/, "~"))}</span>
954
1069
  ${btn("Deploy folder…", () => folderDialog(st.cwd), "", "Pick a folder, a recent project, or drop one here")}
955
- ${btn(p ? "Deploy" : "Deploy this folder", deployHere, "primary", p ? `lf deploy (srv.json in ${st.cwd})` : `lf deploy from ${st.cwd}`)}`;
1070
+ ${btn(p ? "Deploy" : "Deploy this folder", deployHere, "primary", p ? `lf deploy (.landfall.json in ${st.cwd})` : `lf deploy from ${st.cwd}`)}`;
956
1071
  }
957
1072
 
958
1073
  function errorHint(error) {
@@ -963,7 +1078,8 @@ function errorHint(error) {
963
1078
  }
964
1079
 
965
1080
  const isUp = (status) => /^up/i.test(status);
966
- 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>`;
967
1083
 
968
1084
  const size = (b) => {
969
1085
  const u = ["B", "K", "M", "G", "T"];
@@ -1001,12 +1117,7 @@ function factsNote(n) {
1001
1117
 
1002
1118
  function renderMain() {
1003
1119
  const main = $("#main");
1004
- if (!S.sel) {
1005
- main.innerHTML = `<div class="empty"><h2>Pick a server</h2>
1006
- <p>landfall connects only when you open one. Servers are ssh targets: any ~/.ssh/config alias works.</p>
1007
- <p>${btn("+ Add server", addServer, "primary")}</p></div>`;
1008
- return;
1009
- }
1120
+ if (!S.sel) return void (main.innerHTML = homeView());
1010
1121
  const n = S.sel;
1011
1122
  const s = server();
1012
1123
  const ov = S.overview[n] ?? { loading: true };
@@ -1023,12 +1134,15 @@ function renderMain() {
1023
1134
  <div><h1>${esc(n)}${s?.isDefault ? ` <span class="star" title="default server">★</span>` : ""}</h1><div class="sub">${sub}</div>${factsLine(n)}</div>
1024
1135
  <div class="actions">
1025
1136
  ${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
1026
- ${btn("Traefik", () => run(["traefik", "status", ...sv(n)]), "", "lf traefik status")}
1027
- ${btn("Info", () => run(["server", "info", n]), "", "lf server info")}
1028
1137
  ${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
1029
- ${s?.isDefault ? "" : btn("Make default", () => makeDefault(n), "ghost")}
1030
- ${s?.registered ? btn("Forget", () => forget(n), "ghost danger") : ""}
1031
- ${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", "")}
1032
1146
  </div>
1033
1147
  </div>
1034
1148
  ${factsNote(n)}
@@ -1047,6 +1161,42 @@ function renderMain() {
1047
1161
  else view.innerHTML = historyView(n);
1048
1162
  }
1049
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
+
1050
1200
  function sitesView(n, o) {
1051
1201
  ensureDns(n, o);
1052
1202
  if (!o.ready) {
@@ -1056,73 +1206,111 @@ function sitesView(n, o) {
1056
1206
  }
1057
1207
  if (!o.sites.length) {
1058
1208
  return `<div class="card"><div class="body empty"><h2>No sites yet</h2>
1059
- <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>
1060
1210
  <p>${btn("Deploy a folder…", () => folderDialog(S.state.cwd), "primary")} <span class="dim">or drop one here</span></p>
1061
- <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)}`;
1062
1212
  }
1063
1213
  const rows = o.sites
1064
- .map(({ meta: m, status }) => {
1214
+ .map(({ meta: m, status, services, domains, main, state, kind }) => {
1065
1215
  const cur = m.releases.find((r) => r.id === m.current);
1066
1216
  const name = m.name;
1067
1217
  const open = S.open.has(`${n}/${name}`);
1068
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";
1069
1222
  const older = [...m.releases].sort((a, b) => (a.at < b.at ? 1 : -1)).filter((r) => r.at < (cur?.at ?? ""));
1070
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");
1071
1240
  const acts = [
1072
- proj
1241
+ proj && !m.adopted
1073
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}`)
1074
1243
  : "",
1075
- btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the container logs"),
1076
- btn("Restart", () => run(["restart", name, ...sv(n)]), "sm"),
1077
- btn(up ? "Stop" : "Start", () => run([up ? "stop" : "start", name, ...sv(n)]), "sm"),
1078
- older.length
1079
- ? 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")
1080
- : "",
1081
- btn("Domains", () => domainsDialog(n, m), "sm", "Add, change or remove domains"),
1082
- btn(m.auth?.length ? "Login" : "Password", () => authDialog(n, m), "sm", "Protect the site with a user and password"),
1083
- m.kind === "static" ? "" : btn("Env", () => envDialog(n, name), "sm", "Environment variables"),
1084
- 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}`),
1085
1258
  ].join("");
1086
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 ?? []));
1087
1262
  const detail = open
1088
1263
  ? `<tr class="detail"><td colspan="5"><dl class="kv">
1089
- <dt>Container</dt><dd class="mono">srv-${esc(name)} · ${esc(status)}</dd>
1090
- <dt>On ${esc(n)}</dt><dd>${siteFiles(m, cur)}</dd>
1091
- <dt>Source</dt><dd>${proj
1092
- ? `<span class="mono">${esc(tilde(proj.path))}</span> <span class="dim">· settings in</span> <span class="mono">srv.json</span> <span class="dim">· redeploy runs there</span>`
1093
- : `<span class="dim">not deployed from this machine${m.kind === "image" ? "" : " (or the folder moved)"}</span>`}</dd>
1094
- <dt>Kind</dt><dd>${esc(m.kind)}${m.spa ? " (single-page app)" : ""} · port ${esc(m.port)}</dd>
1095
- <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>` : ""}
1096
1274
  <dt>Created</dt><dd>${esc(ago(m.createdAt))} · updated ${esc(ago(m.updatedAt))}</dd>
1097
- <dt>Releases</dt><dd><ul class="releases">${[...m.releases]
1275
+ ${m.releases.length ? `<dt>Releases</dt><dd><ul class="releases">${[...m.releases]
1098
1276
  .sort((a, b) => (a.at < b.at ? 1 : -1))
1099
- .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>` : ""}
1100
- ${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>`)
1101
- .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>` : ""}
1102
1280
  </dl></td></tr>`
1103
1281
  : "";
1104
1282
  return `<tr class="click" ${on(toggle)}>
1105
- <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>
1106
- <td class="hide-sm"><div class="chips">${m.domains.map((d) => domainChip(n, d)).join("")}</div></td>
1107
- <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>
1108
1287
  <td class="hide-sm dim">${cur ? esc(ago(cur.at)) : ""}</td>
1109
1288
  <td class="acts">${acts}</td></tr>${detail}`;
1110
1289
  })
1111
1290
  .join("");
1112
1291
  return `<div class="card scroll"><table>
1113
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>
1114
- <tbody>${rows}</tbody></table></div>`;
1293
+ <tbody>${rows}</tbody></table></div>${othersCard(n, o)}`;
1115
1294
  }
1116
1295
 
1117
- /** Where a site lives on the server, and which of its files are live. */
1118
- function siteFiles(m, cur) {
1296
+ /** Where a site lives on the server: docker compose works in its current folder. */
1297
+ function siteFiles(m, state) {
1119
1298
  const dir = `${S.state.paths.sites}/${m.name}`;
1120
- const live =
1121
- m.kind === "image" ? `image <span class="mono">${esc(cur?.image ?? "?")}</span>`
1122
- : m.kind === "static" ? `files <span class="mono">${esc(`${dir}/releases/${m.current}`)}</span>`
1123
- : `built from <span class="mono">${esc(`${dir}/releases/${m.current}`)}</span>${cur?.image ? ` as <span class="mono">${esc(cur.image)}</span>` : ""}`;
1124
- return `<span class="mono">${esc(dir)}</span><div class="dim">live: ${live}</div>
1125
- <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
+ });
1126
1314
  }
1127
1315
 
1128
1316
  function envDialog(n, name) {
@@ -1352,25 +1540,44 @@ function settingsView(n) {
1352
1540
  promptDlg(`Traefik dashboard on ${n}`, "Serves the dashboard behind a password (shown in the output). `off` removes it.\nRuns lf traefik setup, which reconfigures landfall's Traefik.", { label: "Domain", type: "text", value: x.traefik.dashboard ?? (x.domains[0] ? `traefik.${x.domains[0]}` : "") }, (d) => {
1353
1541
  if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
1354
1542
  }, "Apply");
1543
+ const sites = S.overview[n]?.data?.sites ?? [];
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;
1546
+ const serve = (d) => {
1547
+ const cur = servedBy(d);
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);
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`);
1551
+ dialog({
1552
+ title: `Serve a site on ${d}`,
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.`,
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 }],
1555
+ ok: "Serve it",
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) }),
1557
+ });
1558
+ };
1355
1559
  const P = S.state.paths;
1356
1560
  const ip = x.dns[0]?.ips[0] ?? "<server ip>";
1357
1561
  const records = x.dns.flatMap((r) => [r.apex, r.wildcard]).filter((d) => !d.ok).map((d) => `${esc(d.domain)} A ${esc(ip)}`);
1358
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>`]) : [];
1359
1563
  return `
1360
1564
  <div class="card">
1361
- <h2>Main domains ${btn("Add…", addDomain, "sm")}</h2>
1565
+ <h2>Main domains <span class="actions">${btn("Add domain…", addDomain, "sm")}</span></h2>
1362
1566
  <div class="body">${x.domains.length
1363
- ? `<table><tbody>${x.domains.map((d, i) => `<tr>
1364
- <td>${dnsRow(x.dns[i].apex)}${i === 0 ? ` <span class="badge">primary</span>` : ""}<div class="faint">sites: ${dnsRow(x.dns[i].wildcard)}</div></td>
1365
- <td><div class="actions">${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>
1366
- </tr>`).join("")}</tbody></table>
1367
- <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>.</p>
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("")}
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>
1368
1575
  ${records.length ? `<div class="note">Add at your DNS provider:<div class="mono">${records.join("<br>")}</div></div>` : ""}`
1369
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>`}
1370
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>
1371
1578
  </div>
1372
1579
  <div class="card">
1373
- <h2>Traefik ${btn("Dashboard…", dashboard, "sm")}</h2>
1580
+ <h2>Traefik <span class="actions">${btn("Dashboard…", dashboard, "sm")}</span></h2>
1374
1581
  <div class="body">${rows([
1375
1582
  ["image", esc(x.traefik.summary)],
1376
1583
  ["e-mail", esc(x.traefik.email ?? "none")],
@@ -1442,7 +1649,7 @@ function historyView(n) {
1442
1649
  .join("");
1443
1650
  return `
1444
1651
  <div class="card">
1445
- <h2>Journal <span class="dim" style="font-weight:400">/opt/srv/journal.log · newest first</span>
1652
+ <h2>Journal <span class="dim" style="font-weight:400">/opt/landfall/journal.log · newest first</span>
1446
1653
  <span class="actions">${total ? btn("Undo everything", () => confirmDlg({ title: `Undo everything landfall did on ${n}?`, text: `${plural(total, "change")}, newest first. Sites, Traefik, the deploy user and landfall's firewall rules go away.`, ok: "Revert everything", danger: true, onOk: () => undo(["--all"]) }), "sm danger") : ""}</span></h2>
1447
1654
  ${items}
1448
1655
  </div>`;
@@ -1463,12 +1670,19 @@ function render() {
1463
1670
  }
1464
1671
 
1465
1672
  document.addEventListener("keydown", (e) => {
1673
+ if (e.key === "Escape" && $("#menu")) return closeMenu();
1466
1674
  if (e.key === "Escape" && !$("#dlg").open && S.job) {
1467
1675
  S.job = null;
1468
1676
  render();
1469
1677
  }
1470
1678
  });
1471
1679
 
1680
+ $("#filter").addEventListener("input", (e) => {
1681
+ S.filter = e.target.value.trim();
1682
+ renderNav();
1683
+ });
1684
+ $("#add-server").onclick = () => addServer();
1685
+
1472
1686
  (async () => {
1473
1687
  try {
1474
1688
  await refreshState();