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/README.md +260 -163
- package/dist/cli.mjs +333 -189
- package/dist/index.html +305 -91
- package/dist/{job-CypEf1ud.mjs → job-DOi_DnYQ.mjs} +1 -1
- package/dist/server-Cj4TXzYj.mjs +4 -0
- package/dist/tui-0-Gcinq3.mjs +8 -0
- package/package.json +4 -3
- package/dist/server-CkG3c5Za.mjs +0 -4
- package/dist/tui-B0KECXvO.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,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
|
|
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)",
|
|
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/
|
|
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 === "
|
|
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
|
}
|
|
667
772
|
|
|
668
|
-
/** Deploy a local folder (det from /api/fs or an upload). upload: a temporary folder, so no
|
|
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
|
|
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.
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
${
|
|
1030
|
-
${
|
|
1031
|
-
|
|
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> <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
|
|
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
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
1090
|
-
<dt>
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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.
|
|
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="
|
|
1106
|
-
|
|
1107
|
-
<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>
|
|
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
|
|
1118
|
-
function siteFiles(m,
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
|
1565
|
+
<h2>Main domains <span class="actions">${btn("Add domain…", addDomain, "sm")}</span></h2>
|
|
1362
1566
|
<div class="body">${x.domains.length
|
|
1363
|
-
?
|
|
1364
|
-
<
|
|
1365
|
-
<
|
|
1366
|
-
|
|
1367
|
-
|
|
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"><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>
|
|
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"><site>.<ip>.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
|
|
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/
|
|
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();
|