landfall 0.2.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/README.md +222 -143
- package/dist/cli.mjs +292 -191
- package/dist/index.html +284 -88
- package/dist/job-DOi_DnYQ.mjs +2 -0
- package/dist/server-Cj4TXzYj.mjs +4 -0
- package/dist/tui-0-Gcinq3.mjs +8 -0
- package/package.json +4 -3
- package/dist/job-i5GF9h93.mjs +0 -2
- package/dist/server-CqBtraHg.mjs +0 -4
- package/dist/tui-BDvEukfL.mjs +0 -8
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
|
|
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;
|
|
57
|
-
nav
|
|
58
|
-
.
|
|
59
|
-
.
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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 === "
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
1034
|
-
${
|
|
1035
|
-
|
|
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> <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
|
|
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
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1094
|
-
<dt>
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
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.
|
|
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="
|
|
1110
|
-
|
|
1111
|
-
<td class="hide-sm"><
|
|
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
|
|
1122
|
-
function siteFiles(m,
|
|
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
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
1565
|
+
<h2>Main domains <span class="actions">${btn("Add domain…", addDomain, "sm")}</span></h2>
|
|
1380
1566
|
<div class="body">${x.domains.length
|
|
1381
|
-
?
|
|
1382
|
-
<
|
|
1383
|
-
<
|
|
1384
|
-
|
|
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"><site>.${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"><site>.<ip>.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
|
|
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();
|