landfall 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1511 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>landfall</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='4' fill='%232563eb'/><path d='M4 5h8M4 8h8M4 11h5' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
8
+ <style>
9
+ :root {
10
+ --bg: #f6f7f9; --panel: #fff; --line: #e3e6eb; --text: #1b1f24; --dim: #6a7280; --faint: #9aa1ad;
11
+ --accent: #2563eb; --accent-bg: #e8effd; --ok: #15803d; --ok-bg: #e5f5ea; --warn: #b45309; --warn-bg: #fdf3e2;
12
+ --bad: #c0262d; --bad-bg: #fbe9ea; --term: #11151b; --term-text: #d7dce3; --shadow: 0 1px 2px rgb(0 0 0 / .05), 0 4px 16px rgb(0 0 0 / .06);
13
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
14
+ color-scheme: light;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --bg: #0e1116; --panel: #161a21; --line: #262c36; --text: #e4e7ec; --dim: #8b93a1; --faint: #5f6775;
19
+ --accent: #5b8cff; --accent-bg: #1a2540; --ok: #4ade80; --ok-bg: #12291b; --warn: #f5b34a; --warn-bg: #2d2412;
20
+ --bad: #ff6b6b; --bad-bg: #331719; --term: #0a0d11; --term-text: #d7dce3; --shadow: 0 1px 2px rgb(0 0 0 / .3), 0 6px 20px rgb(0 0 0 / .35);
21
+ color-scheme: dark;
22
+ }
23
+ }
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; height: 100%; }
26
+ body { background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif; }
27
+ a { color: var(--accent); text-decoration: none; }
28
+ a:hover { text-decoration: underline; }
29
+ code, .mono { font-family: var(--mono); font-size: 12.5px; }
30
+ button {
31
+ font: inherit; font-size: 13px; border: 1px solid var(--line); background: var(--panel); color: var(--text);
32
+ padding: 5px 11px; border-radius: 7px; cursor: pointer; white-space: nowrap;
33
+ }
34
+ button:hover { border-color: var(--faint); }
35
+ button:disabled { opacity: .5; cursor: default; }
36
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
37
+ button.danger { color: var(--bad); }
38
+ button.danger.solid { background: var(--bad); border-color: var(--bad); color: #fff; }
39
+ button.ghost { border-color: transparent; background: transparent; color: var(--dim); }
40
+ button.ghost:hover { color: var(--text); background: var(--bg); }
41
+ button.sm { padding: 2px 8px; font-size: 12px; }
42
+ input, select, textarea {
43
+ font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 6px 9px; width: 100%;
44
+ }
45
+ input.secret { -webkit-text-security: disc; text-security: disc; }
46
+ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-bg); border-color: var(--accent); }
47
+ textarea { font-family: var(--mono); font-size: 12.5px; min-height: 90px; }
48
+
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
+ header { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
52
+ .brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 8px; }
53
+ .brand small { font-weight: 400; color: var(--faint); font-size: 12px; }
54
+ .project { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; min-width: 0; }
55
+ .project .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 36vw; }
56
+ nav.servers { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 10px 8px; }
57
+ nav h3 { margin: 6px 8px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; }
58
+ .srv { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 7px 10px; border-radius: 7px; }
59
+ .srv:hover { background: var(--bg); }
60
+ .srv.on { background: var(--accent-bg); }
61
+ .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; }
63
+ .star { color: var(--warn); font-size: 11px; }
64
+ main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: inline-size; }
65
+ /* By main's own width, so an open job panel squeezes the tables like a small screen does. */
66
+ @container (max-width: 720px) {
67
+ td, th { padding: 8px; }
68
+ .hide-sm { display: none; }
69
+ }
70
+ .empty { color: var(--dim); text-align: center; padding: 60px 20px; }
71
+ .empty h2 { color: var(--text); font-size: 18px; margin: 0 0 6px; }
72
+ .head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 20px; margin-bottom: 16px; }
73
+ .head h1 { margin: 0; font-size: 22px; }
74
+ .head .sub { color: var(--dim); font-size: 13px; margin-top: 2px; }
75
+ .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
76
+ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
77
+ .tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 12px; color: var(--dim); }
78
+ .tabs button.on { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
79
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); }
80
+ .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; }
82
+ .card > h2 .actions { font-weight: 400; }
83
+ .card .body { padding: 12px 16px; }
84
+ table { border-collapse: collapse; width: 100%; }
85
+ 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); }
86
+ td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
87
+ tr:last-child td { border-bottom: 0; }
88
+ tr.click { cursor: pointer; }
89
+ tr.click:hover td { background: var(--bg); }
90
+ td.acts { text-align: right; white-space: nowrap; }
91
+ .nowrap { white-space: nowrap; }
92
+ td.acts button { margin-left: 4px; }
93
+ .scroll { overflow-x: auto; }
94
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); margin-right: 6px; vertical-align: 1px; }
95
+ .dot.up { background: var(--ok); } .dot.down { background: var(--bad); } .dot.warn { background: var(--warn); }
96
+ .badge { display: inline-block; font-size: 11.5px; padding: 1px 7px; border-radius: 99px; background: var(--bg); color: var(--dim); border: 1px solid var(--line); }
97
+ .badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
98
+ .badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
99
+ .badge.bad { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
100
+ .badge.accent { background: var(--accent-bg); color: var(--accent); border-color: transparent; }
101
+ .dim { color: var(--dim); } .faint { color: var(--faint); }
102
+ .note { padding: 10px 14px; border-radius: 8px; background: var(--warn-bg); color: var(--warn); margin-bottom: 14px; }
103
+ .note.bad { background: var(--bad-bg); color: var(--bad); }
104
+ .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; }
106
+ .kv dt { color: var(--dim); } .kv dd { margin: 0; }
107
+ 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
+ .detail td { background: var(--bg); }
109
+ .releases { margin: 8px 0 0; padding: 0; list-style: none; }
110
+ .releases li { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
111
+ .run { padding: 12px 16px; border-bottom: 1px solid var(--line); }
112
+ .run:last-child { border-bottom: 0; }
113
+ .run .top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
114
+ .run .cmd { font-family: var(--mono); font-weight: 600; font-size: 13px; }
115
+ .steps { list-style: none; margin: 8px 0 0; padding: 0 0 0 4px; }
116
+ .steps li { display: flex; gap: 8px; align-items: baseline; padding: 2px 0; font-size: 13px; }
117
+ .steps li .ico { width: 16px; text-align: center; flex: none; }
118
+ .steps li.undone, .steps li.fixed { color: var(--faint); }
119
+ .steps li.undone .what { text-decoration: line-through; }
120
+ .steps li button { opacity: 0; }
121
+ .steps li:hover button { opacity: 1; }
122
+ .form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: end; }
123
+ .form-row label { flex: 1 1 140px; }
124
+ label { display: block; font-size: 12.5px; color: var(--dim); }
125
+ label > input, label > select, label > textarea { margin-top: 4px; }
126
+ .check { display: flex; gap: 8px; align-items: flex-start; color: var(--text); font-size: 13.5px; }
127
+ .check input { width: auto; margin-top: 3px; }
128
+ .hint { font-size: 12px; color: var(--faint); margin-top: 3px; }
129
+ .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -2px; }
130
+ @keyframes spin { to { transform: rotate(360deg); } }
131
+ .loading { color: var(--dim); padding: 30px 0; text-align: center; }
132
+
133
+ [hidden] { display: none !important; }
134
+ aside.job { border-left: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; min-width: 0; }
135
+ .job-head { padding: 10px 12px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
136
+ .job-head .title { font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
137
+ .job-out { flex: 1; overflow: auto; background: var(--term); color: var(--term-text); font: 12.5px/1.5 var(--mono); padding: 10px 12px; white-space: pre-wrap; word-break: break-word; }
138
+ .job-out .c-cyan { color: #67d3e8; } .job-out .c-green { color: #6ee7a0; } .job-out .c-yellow { color: #f5c76a; } .job-out .c-red { color: #ff8a8a; } .job-out .c-dim { color: #7b8494; }
139
+ .job-foot { padding: 10px 12px; border-top: 1px solid var(--line); display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
140
+ .job-foot .status { margin-right: auto; font-size: 13px; }
141
+ .joblist { border-top: 1px solid var(--line); max-height: 28%; overflow: auto; }
142
+ .joblist button { display: flex; width: 100%; gap: 8px; border: 0; border-radius: 0; text-align: left; background: none; padding: 6px 12px; font-family: var(--mono); font-size: 12px; color: var(--dim); }
143
+ .joblist button span:last-child { overflow: hidden; text-overflow: ellipsis; }
144
+ .joblist button.on { background: var(--accent-bg); color: var(--text); }
145
+
146
+ dialog { border: 1px solid var(--line); border-radius: 12px; padding: 0; background: var(--panel); color: var(--text); box-shadow: var(--shadow); width: min(520px, calc(100vw - 32px)); }
147
+ dialog::backdrop { background: rgb(0 0 0 / .35); }
148
+ dialog form { padding: 18px 20px; display: grid; gap: 12px; }
149
+ dialog h2 { margin: 0; font-size: 16px; }
150
+ dialog .text { color: var(--dim); white-space: pre-wrap; font-size: 13.5px; }
151
+ dialog .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
152
+ fieldset { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
153
+ legend { font-size: 12.5px; font-weight: 600; padding: 0 4px; }
154
+ .toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 13px; z-index: 10; }
155
+ .server-pick { display: none; }
156
+ .facts { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--dim); font-size: 12.5px; margin-top: 4px; }
157
+ .facts b { color: var(--text); font-weight: 500; }
158
+ .chips { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
159
+ .chip { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; }
160
+ .chip a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
161
+ .chip button { padding: 0 5px; font-size: 12px; opacity: .55; }
162
+ .chip button:hover { opacity: 1; }
163
+ .lock { font-size: 11px; margin-left: 4px; }
164
+ dialog.wide { width: min(680px, calc(100vw - 32px)); }
165
+ .list { display: grid; border: 1px solid var(--line); border-radius: 8px; max-height: 260px; overflow: auto; }
166
+ .list .row { display: flex; gap: 10px; align-items: baseline; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); text-align: left; background: none; padding: 6px 10px; min-width: 0; }
167
+ .list .row:last-child { border-bottom: 0; }
168
+ .list .row:hover { background: var(--bg); }
169
+ .list .row .mono { margin-left: auto; overflow: hidden; text-overflow: ellipsis; }
170
+ .crumbs { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; font-size: 12.5px; }
171
+ .crumbs button { padding: 1px 4px; }
172
+ .detected { font-size: 13px; padding: 8px 12px; border-radius: 8px; background: var(--bg); }
173
+ .drop { border: 1.5px dashed var(--line); border-radius: 8px; padding: 12px; text-align: center; color: var(--dim); font-size: 13px; }
174
+ .drop label { display: inline; color: var(--accent); cursor: pointer; font-size: 13px; }
175
+ body.dragging::after {
176
+ content: "Drop a folder or .zip to deploy it"; position: fixed; inset: 12px; z-index: 20; display: grid; place-items: center;
177
+ border: 2px dashed var(--accent); border-radius: 14px; background: color-mix(in srgb, var(--bg) 85%, transparent); color: var(--accent); font-size: 18px; font-weight: 600; pointer-events: none;
178
+ }
179
+
180
+ @media (max-width: 1100px) {
181
+ .app.with-job { grid-template-columns: 220px 1fr; }
182
+ aside.job { position: fixed; inset: 52px 0 0 auto; width: min(560px, 100vw); z-index: 5; box-shadow: var(--shadow); }
183
+ }
184
+ @media (max-width: 760px) {
185
+ .app, .app.with-job { grid-template-columns: 1fr; grid-template-rows: 52px auto 1fr; }
186
+ nav.servers { display: none; }
187
+ .server-pick { display: block; padding: 10px 16px 0; }
188
+ main { padding: 14px 16px 40px; }
189
+ .project .path { display: none; }
190
+ aside.job { inset: 52px 0 0 0; width: 100vw; }
191
+ }
192
+ </style>
193
+ </head>
194
+ <body>
195
+ <div class="app" id="app">
196
+ <header>
197
+ <div class="brand">landfall <small id="version"></small></div>
198
+ <div class="project" id="project"></div>
199
+ </header>
200
+ <nav class="servers" id="nav"></nav>
201
+ <div class="server-pick" id="pick"></div>
202
+ <main id="main"></main>
203
+ <aside class="job" id="job" hidden></aside>
204
+ </div>
205
+ <dialog id="dlg"></dialog>
206
+ <script>
207
+ "use strict";
208
+ const $ = (s, el = document) => el.querySelector(s);
209
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
210
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
211
+ function ago(iso) {
212
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
213
+ if (!isFinite(s)) return "";
214
+ if (s < 60) return "just now";
215
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
216
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
217
+ if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
218
+ return new Date(iso).toLocaleDateString();
219
+ }
220
+
221
+ // ---- state ---------------------------------------------------------------
222
+
223
+ const S = {
224
+ state: null, // /api/state
225
+ sel: null, // selected server name
226
+ tab: "sites",
227
+ overview: {}, // name -> {data} | {error} | {loading}
228
+ firewall: {},
229
+ history: {},
230
+ updates: {},
231
+ facts: {},
232
+ settings: {}, // name -> {data: lf server config --json + dns of the main domain}
233
+ dns: {}, // name -> {data: {ips, domains: [{domain, ips, ok, https}]}}
234
+ open: new Set(), // expanded site rows
235
+ jobs: new Map(), // id -> {info, lines, after, actions, es}
236
+ job: null, // shown job id
237
+ };
238
+
239
+ async function http(path, body) {
240
+ const r = await fetch(path, body === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
241
+ if (r.status === 401) throw new Error("This browser is not logged in. Open the link `lf ui` printed.");
242
+ const data = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
243
+ if (!r.ok) throw new Error(data.error || r.statusText);
244
+ return data;
245
+ }
246
+
247
+ let toastTimer;
248
+ function toast(msg) {
249
+ let t = $(".toast");
250
+ if (!t) document.body.append((t = Object.assign(document.createElement("div"), { className: "toast" })));
251
+ t.textContent = msg;
252
+ clearTimeout(toastTimer);
253
+ toastTimer = setTimeout(() => t.remove(), 3500);
254
+ }
255
+
256
+ // Buttons call functions registered per render: data-k="<id>".
257
+ let handlers = new Map();
258
+ let hid = 0;
259
+ function on(fn) {
260
+ const k = String(++hid);
261
+ handlers.set(k, fn);
262
+ return `data-k="${k}"`;
263
+ }
264
+ function btn(label, fn, cls = "", title = "") {
265
+ return `<button type="button" class="${cls}" ${on(fn)}${title ? ` title="${esc(title)}"` : ""}>${label}</button>`;
266
+ }
267
+ document.addEventListener("click", (e) => {
268
+ const el = e.target.closest("[data-k]");
269
+ if (!el || el.disabled) return;
270
+ const fn = handlers.get(el.dataset.k);
271
+ if (fn) {
272
+ e.preventDefault();
273
+ e.stopPropagation();
274
+ fn(e);
275
+ }
276
+ });
277
+
278
+ // ---- routing: #/<server>/<tab> ---------------------------------------------
279
+
280
+ function go(sel, tab = S.tab) {
281
+ const hash = sel ? `#/${encodeURIComponent(sel)}/${tab}` : "#/";
282
+ if (location.hash !== hash) location.hash = hash;
283
+ else route();
284
+ }
285
+ function route() {
286
+ const [, name, tab] = location.hash.split("/");
287
+ S.sel = name ? decodeURIComponent(name) : null;
288
+ S.tab = tab in STORE ? tab : "sites";
289
+ if (S.sel) load(S.sel, S.tab);
290
+ render();
291
+ }
292
+ window.addEventListener("hashchange", route);
293
+
294
+ // ---- loading -------------------------------------------------------------
295
+
296
+ const STORE = { sites: "overview", facts: "facts", updates: "updates", firewall: "firewall", history: "history", settings: "settings" };
297
+ const URLS = { sites: "", facts: "/facts", updates: "/updates", firewall: "/firewall", history: "/history", settings: "/config" };
298
+
299
+ function load(name, tab, force = false) {
300
+ // The overview and facts drive the header, so every tab needs them.
301
+ const needed = tab === "sites" ? ["sites", "facts"] : ["sites", "facts", tab];
302
+ for (const t of needed) {
303
+ const store = S[STORE[t]];
304
+ if (store[name] && !force) continue;
305
+ store[name] = { loading: true };
306
+ http(`/api/servers/${encodeURIComponent(name)}${URLS[t]}`).then(
307
+ (data) => ((store[name] = { data }), render()),
308
+ (e) => ((store[name] = { error: e.message }), render()),
309
+ );
310
+ }
311
+ }
312
+ function invalidate(name) {
313
+ for (const k of [...Object.values(STORE), "dns"]) delete S[k][name];
314
+ if (S.sel === name) load(name, S.tab);
315
+ }
316
+ async function refreshState() {
317
+ S.state = await http("/api/state");
318
+ render();
319
+ }
320
+
321
+ // ---- jobs ----------------------------------------------------------------
322
+
323
+ /**
324
+ * Run `lf <argv>` on the machine running lf ui; output streams into the side panel.
325
+ * after(job) may return follow-up actions [{label, run, cls}] once it ends.
326
+ */
327
+ async function run(argv, o = {}) {
328
+ let info;
329
+ try {
330
+ info = await http("/api/jobs", { argv, server: o.server ?? S.sel ?? undefined, password: o.password, cwd: o.cwd });
331
+ } catch (e) {
332
+ return toast(e.message);
333
+ }
334
+ const j = { info, lines: [], after: o.after, actions: [] };
335
+ S.jobs.set(info.id, j);
336
+ S.job = info.id;
337
+ stream(j);
338
+ render();
339
+ }
340
+
341
+ function stream(j) {
342
+ const es = new EventSource(`/api/jobs/${j.info.id}/events?from=${j.lines.length}`);
343
+ j.es = es;
344
+ es.onmessage = (e) => {
345
+ const { from, lines } = JSON.parse(e.data);
346
+ j.lines.length = Math.min(j.lines.length, from);
347
+ j.lines.push(...lines);
348
+ if (S.job === j.info.id) appendOutput(j);
349
+ };
350
+ es.addEventListener("done", (e) => {
351
+ es.close();
352
+ j.info = JSON.parse(e.data);
353
+ finished(j);
354
+ });
355
+ // Dropped connection while it still runs: pick up where it left off.
356
+ es.onerror = () => {
357
+ es.close();
358
+ if (j.info.code === undefined) setTimeout(() => stream(j), 1000);
359
+ };
360
+ }
361
+
362
+ function finished(j) {
363
+ const { info } = j;
364
+ const failed = info.code !== 0 && info.code !== null;
365
+ j.actions = (j.after && j.after(j)) || [];
366
+ // Safety checks refuse what would lock out ssh or break running services; --force overrides after reading why.
367
+ if (failed && !info.argv.includes("--force") && j.lines.some((l) => l.includes("--force"))) {
368
+ j.actions.push({ label: "Do it anyway (--force)", cls: "danger", run: () => run([...info.argv, "--force"], { server: info.server }) });
369
+ }
370
+ if (info.undoable && info.server && info.code !== null) {
371
+ j.actions.push({
372
+ label: "Undo this",
373
+ run: () =>
374
+ confirmDlg({
375
+ title: `Undo \`lf ${info.argv.join(" ")}\`?`,
376
+ text: `Reverts the changes this command recorded in the journal on ${info.server}.`,
377
+ ok: "Undo it",
378
+ onOk: () => run(["undo", info.id, "-s", info.server, "-y"], { server: info.server }),
379
+ }),
380
+ });
381
+ }
382
+ if (info.server) invalidate(info.server);
383
+ refreshState().catch(() => {});
384
+ render();
385
+ }
386
+
387
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
388
+ function lineClass(l) {
389
+ if (/^==> /.test(l)) return "c-cyan";
390
+ if (/^\s*✓/.test(l)) return "c-green";
391
+ if (/^\s*!/.test(l)) return "c-yellow";
392
+ if (/^\s*(error|✗|FAIL)/i.test(l)) return "c-red";
393
+ if (/^\$ /.test(l)) return "c-dim";
394
+ return "";
395
+ }
396
+ function appendOutput(j) {
397
+ const out = $("#job .job-out");
398
+ if (!out) return;
399
+ const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
400
+ const shown = Number(out.dataset.n || 0);
401
+ if (shown > j.lines.length) out.textContent = "";
402
+ const frag = document.createDocumentFragment();
403
+ for (const l of j.lines.slice(out.textContent ? shown : 0)) {
404
+ const div = document.createElement("div");
405
+ div.textContent = l.replace(ANSI, "") || " ";
406
+ const c = lineClass(div.textContent);
407
+ if (c) div.className = c;
408
+ frag.append(div);
409
+ }
410
+ out.append(frag);
411
+ out.dataset.n = j.lines.length;
412
+ if (atBottom) out.scrollTop = out.scrollHeight;
413
+ }
414
+
415
+ function renderJob() {
416
+ const el = $("#job");
417
+ const j = S.jobs.get(S.job);
418
+ $("#app").classList.toggle("with-job", !!j);
419
+ el.hidden = !j;
420
+ if (!j) return;
421
+ const { info } = j;
422
+ const running = info.code === undefined;
423
+ const status = running
424
+ ? `<span class="spin"></span> running`
425
+ : info.code === null
426
+ ? `<span class="dim">stopped</span>`
427
+ : info.code === 0
428
+ ? `<span style="color:var(--ok)">✓ done</span>`
429
+ : `<span style="color:var(--bad)">✗ failed (exit ${info.code})</span>`;
430
+ const others = [...S.jobs.values()].reverse();
431
+ el.innerHTML = `
432
+ <div class="job-head">
433
+ <span class="title" title="${esc("lf " + info.argv.join(" "))}">$ lf ${esc(info.argv.join(" "))}</span>
434
+ ${btn("✕", () => ((S.job = null), render()), "ghost sm", "Close (Esc)")}
435
+ </div>
436
+ <div class="job-out"></div>
437
+ <div class="job-foot">
438
+ <span class="status">${status}</span>
439
+ ${running ? btn("Stop", () => http(`/api/jobs/${info.id}/stop`, {}).catch((e) => toast(e.message)), "danger") : ""}
440
+ ${(running ? [] : j.actions).map((a, i) => btn(esc(a.label), a.run, a.cls ?? (i === 0 ? "primary" : ""))).join("")}
441
+ </div>
442
+ ${others.length > 1 ? `<div class="joblist">${others
443
+ .map((o) => `<button type="button" class="${o.info.id === S.job ? "on" : ""}" ${on(() => ((S.job = o.info.id), render()))}>
444
+ <span>${o.info.code === undefined ? "…" : o.info.code === 0 ? "✓" : o.info.code === null ? "■" : "✗"}</span><span>lf ${esc(o.info.argv.join(" "))}</span></button>`)
445
+ .join("")}</div>` : ""}`;
446
+ appendOutput(j);
447
+ const out = $("#job .job-out");
448
+ out.scrollTop = out.scrollHeight;
449
+ }
450
+
451
+ // ---- dialogs -------------------------------------------------------------
452
+
453
+ /**
454
+ * A modal form. fields: [{name, label, value, type: text|password|textarea|select|check, options, hint, placeholder}].
455
+ * onOk(values) runs on submit; return false to keep it open.
456
+ */
457
+ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "" }) {
458
+ const dlg = openDlg();
459
+ const field = (f) => {
460
+ const id = `f-${f.name}`;
461
+ const hint = f.hint ? `<div class="hint">${esc(f.hint)}</div>` : "";
462
+ if (f.type === "check") {
463
+ return `<label class="check"><input type="checkbox" id="${id}" name="${f.name}" ${f.value ? "checked" : ""}><span>${esc(f.label)}${hint}</span></label>`;
464
+ }
465
+ if (f.type === "select") {
466
+ return `<label>${esc(f.label)}<select id="${id}" name="${f.name}">${f.options
467
+ .map((o) => `<option value="${esc(o.value)}" ${o.value === f.value ? "selected" : ""}>${esc(o.label)}</option>`)
468
+ .join("")}</select>${hint}</label>`;
469
+ }
470
+ if (f.type === "password") return `<label>${esc(f.label)}${secretInput(id, f)}${hint}</label>`;
471
+ if (f.type === "textarea") return `<label>${esc(f.label)}<textarea id="${id}" name="${f.name}" placeholder="${esc(f.placeholder)}">${esc(f.value)}</textarea>${hint}</label>`;
472
+ return `<label>${esc(f.label)}<input id="${id}" name="${f.name}" type="${f.type || "text"}" value="${esc(f.value)}" placeholder="${esc(f.placeholder)}" autocomplete="off" spellcheck="false">${hint}</label>`;
473
+ };
474
+ const group = (f) => (f.legend ? `<fieldset><legend>${esc(f.legend)}</legend>${f.fields.map(field).join("")}</fieldset>` : field(f));
475
+ dlg.innerHTML = `<form method="dialog">
476
+ <h2>${esc(title)}</h2>
477
+ ${text ? `<div class="text">${esc(text)}</div>` : ""}
478
+ ${extra}
479
+ ${fields.map(group).join("")}
480
+ <div class="btns">
481
+ <button type="button" value="cancel" class="ghost" id="dlg-cancel">Cancel</button>
482
+ <button type="submit" value="ok" class="${danger ? "danger solid" : "primary"}">${esc(ok)}</button>
483
+ </div>
484
+ </form>`;
485
+ const form = $("form", dlg);
486
+ $("#dlg-cancel", dlg).onclick = () => dlg.close();
487
+ form.onsubmit = (e) => {
488
+ e.preventDefault();
489
+ const values = {};
490
+ for (const f of fields.flatMap((f) => f.fields ?? [f])) {
491
+ const el = form.elements[f.name];
492
+ values[f.name] = f.type === "check" ? el.checked : el.value;
493
+ }
494
+ if (onOk(values) === false) return;
495
+ // Empty before closing: browsers offer to save a password they still see when the form goes away.
496
+ for (const el of form.querySelectorAll(".secret")) el.value = "";
497
+ dlg.close();
498
+ };
499
+ dlg.showModal();
500
+ const first = $("input:not([type=checkbox]), textarea, select", form);
501
+ (first || $("button[type=submit]", form)).focus();
502
+ }
503
+ /**
504
+ * Passwords here are for servers and sites, not for this page: a masked text field, so browsers
505
+ * and password managers do not offer to save them. Browsers without text masking get a password field.
506
+ */
507
+ const MASK = CSS.supports("-webkit-text-security", "disc");
508
+ const secretInput = (id, f) =>
509
+ `<input id="${id}" name="${f.name}" class="secret" type="${MASK ? "text" : "password"}" value="${esc(f.value)}" placeholder="${esc(f.placeholder)}" autocomplete="${MASK ? "off" : "new-password"}" autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other">`;
510
+ /** The shared <dialog>, closed and reset so a new one can take its place. */
511
+ function openDlg(cls = "") {
512
+ const dlg = $("#dlg");
513
+ if (dlg.open) dlg.close();
514
+ dlg.className = cls;
515
+ return dlg;
516
+ }
517
+ const confirmDlg = (o) => dialog({ ...o, onOk: () => o.onOk() });
518
+ const promptDlg = (title, text, field, onOk, ok = "OK") => dialog({ title, text, fields: [{ name: "v", ...field }], ok, onOk: (v) => onOk(v.v.trim()) });
519
+
520
+ // ---- server flows (mirror the terminal dashboard) -------------------------
521
+
522
+ const sv = (n) => ["-s", n];
523
+ const server = () => S.state?.servers.find((s) => s.name === S.sel);
524
+
525
+ function setupFlow(args, base = args) {
526
+ run(["setup", ...args, "--check"], {
527
+ server: args[0],
528
+ after: (j) => {
529
+ if (j.info.code !== 0) return;
530
+ if (j.lines.some((l) => l.includes("no key login yet"))) {
531
+ return [{ label: "Enter the password to install a key", run: () => passwordFor(args, base) }];
532
+ }
533
+ const todo = j.lines.filter((l) => /^\s+→ /.test(l)).length;
534
+ return [
535
+ { label: todo ? `Apply ${plural(todo, "change")}` : "Finish setup (nothing to install)", run: () => run(["setup", ...args, "-y"], { server: args[0] }) },
536
+ { label: "Change options", run: () => setupWizard(base) },
537
+ ];
538
+ },
539
+ });
540
+ }
541
+
542
+ function setupWizard(base) {
543
+ const saved = S.state.servers.find((s) => s.name === base[0])?.saved ?? {};
544
+ dialog({
545
+ title: `Set up ${base[0]}`,
546
+ text: "landfall first shows what it finds and what it would change. Nothing is applied before you confirm.",
547
+ ok: "Check",
548
+ fields: [
549
+ {
550
+ legend: "Traefik (reverse proxy, automatic HTTPS)",
551
+ fields: [
552
+ {
553
+ name: "traefik", label: "Mode", type: "select", value: saved.hostNetwork ? "host" : "docker",
554
+ options: [
555
+ { value: "docker", label: "Own Traefik, ports published by Docker" },
556
+ { value: "host", label: "Own Traefik on the host network (real client IPs, ufw guards 80/443)" },
557
+ { value: "none", label: "No Traefik (add later with traefik setup)" },
558
+ ],
559
+ hint: "If the server already runs a Traefik on 80/443, setup routes sites through it instead.",
560
+ },
561
+ { name: "image", label: "Image", value: saved.traefikImage ?? S.state.defaultImage },
562
+ { name: "email", label: "Let's Encrypt e-mail (optional)", value: saved.email ?? "" },
563
+ { name: "dashboard", label: "Dashboard domain (optional)", value: "", placeholder: "traefik.example.com", hint: "Behind basic auth; the password is shown after setup." },
564
+ ],
565
+ },
566
+ { name: "firewall", label: "Enable ufw: deny incoming except ssh, 80 and 443", type: "check", value: true, hint: "Skipped automatically if it would block ports in use." },
567
+ { name: "harden", label: "Key login only (turn off ssh passwords)", type: "check", value: true, hint: "Skipped automatically if other users still log in with a password." },
568
+ { name: "user", label: "Deploy user", value: "deploy", hint: "landfall deploys as this user (docker group, your key). Empty keeps the login user." },
569
+ ],
570
+ onOk: (v) => {
571
+ const f = [];
572
+ if (v.traefik === "none") f.push("--no-traefik");
573
+ else {
574
+ f.push(v.traefik === "host" ? "--host-network" : "--no-host-network");
575
+ if (v.image.trim()) f.push(`--image=${v.image.trim()}`);
576
+ if (v.email.trim()) f.push(`--email=${v.email.trim()}`);
577
+ if (v.dashboard.trim()) f.push(`--dashboard=${v.dashboard.trim()}`);
578
+ }
579
+ if (!v.firewall) f.push("--no-firewall");
580
+ if (!v.harden) f.push("--no-harden");
581
+ f.push(`--deploy-user=${v.user.trim()}`);
582
+ setupFlow([...base, ...f], base);
583
+ },
584
+ });
585
+ }
586
+
587
+ function passwordFor(args, base) {
588
+ const host = args.includes("--host") ? `root@${args[args.indexOf("--host") + 1]}` : S.state.servers.find((s) => s.name === args[0])?.target ?? args[0];
589
+ promptDlg(
590
+ `Password for ${host}`,
591
+ "Used once to install landfall's ssh key (ssh-copy-id). It is not stored; afterwards only the key is used.",
592
+ { type: "password", label: "Password" },
593
+ (pw) => {
594
+ if (!pw) return;
595
+ run(["setup", ...args, "--key-only", "-y"], {
596
+ password: pw,
597
+ server: args[0],
598
+ after: (j) => {
599
+ if (j.info.code === 0) return void setTimeout(() => setupFlow(args, base));
600
+ return [{ label: "Try the password again", run: () => passwordFor(args, base) }];
601
+ },
602
+ });
603
+ },
604
+ "Install key",
605
+ );
606
+ }
607
+
608
+ function addServer() {
609
+ dialog({
610
+ title: "Add a server",
611
+ text: "Use an alias from ~/.ssh/config (like het), or pick a new name and give the machine's address, fresh or already in use. landfall writes the ssh config entry for it.",
612
+ fields: [
613
+ { name: "name", label: "Name", placeholder: "het" },
614
+ { name: "host", label: "IP or hostname (not needed for an ssh alias)", placeholder: "203.0.113.7" },
615
+ {
616
+ legend: "Login (for a new address)",
617
+ fields: [
618
+ { name: "user", label: "User", placeholder: "root" },
619
+ { 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/landfall_<name> and installs it with the password." },
621
+ { name: "password", label: "Password (optional)", type: "password", hint: "Used once to install the key, never stored. Leave empty if the key already works." },
622
+ ],
623
+ },
624
+ { name: "setup", label: "Set it up now (shows what it would change first)", type: "check", value: true },
625
+ ],
626
+ ok: "Add",
627
+ onOk: (v) => {
628
+ const [name, host, user, port, key] = [v.name, v.host, v.user, v.port, v.key].map((x) => x.trim());
629
+ if (!name) return false;
630
+ const known = S.state.servers.some((s) => s.name === name);
631
+ if (!known && !host) return void toast(`${name} is not in your ssh config: give its IP or hostname`), false;
632
+ if (port && !/^\d+$/.test(port)) return void toast("The port is a number"), false;
633
+ const args = [name, ...(host ? ["--host", host] : []), ...(user ? ["-u", user] : []), ...(port ? ["-p", port] : []), ...(key ? ["-i", key] : [])];
634
+ // With a password: install the key now, then the setup wizard needs nothing but the alias.
635
+ const then = (j) => (j.info.code === 0 ? void (v.setup ? setupWizard([name]) : go(name)) : undefined);
636
+ if (v.password) run(["server", "add", ...args], { server: name, password: v.password, after: then });
637
+ else if (v.setup) setupWizard(args);
638
+ else run(["server", "add", ...args], { server: name, after: () => void go(name) });
639
+ },
640
+ });
641
+ }
642
+
643
+ const defaultServer = () => S.sel ?? S.state.servers.find((s) => s.isDefault)?.name;
644
+ const sslip = (name, ip) => `${name}.${ip.replace(/\./g, "-")}.sslip.io`;
645
+ const mainDomain = (n) => S.state.servers.find((s) => s.name === n)?.saved?.domains?.[0];
646
+ /** What `lf deploy` picks without a domain: <site>.<main domain>, else the free sslip.io address. */
647
+ function autoDomain(n, name) {
648
+ const main = mainDomain(n);
649
+ if (main) return `${name}.${main}`;
650
+ const ip = S.facts[n]?.data?.sslipIp;
651
+ return ip ? sslip(name, ip) : "";
652
+ }
653
+ const slugify = (s) => s.toLowerCase().replace(/\.(zip|tgz|tar(\.gz)?)$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "site";
654
+ const tilde = (p) => p.replace(/^\/(Users|home)\/[^/]+/, "~");
655
+
656
+ function describe(d) {
657
+ const built = d.build ? ` · builds with <code>${esc(d.build)}</code> first` : "";
658
+ const what =
659
+ d.kind === "image" ? `Docker image <code>${esc(d.project?.image ?? "")}</code> (from .landfall.json)`
660
+ : d.kind === "dockerfile" ? `Dockerfile: built on the server, served on port ${esc(d.port)}`
661
+ : d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.spa ? " (single-page app)" : ""}${built}`
662
+ : d.build ? `Static site, built with <code>${esc(d.build)}</code> first`
663
+ : `<span style="color:var(--warn)">No index.html, Dockerfile or build script here.</span>`;
664
+ const before = d.project?.server ? ` <span class="dim">· deployed before to ${esc(d.project.server)}</span>` : "";
665
+ return what + before;
666
+ }
667
+
668
+ /** Deploy a local folder (det from /api/fs or an upload). upload: a temporary folder, so no .landfall.json. */
669
+ function deployDialog(det, o = {}) {
670
+ const p = det.project;
671
+ const servers = S.state.servers.map((s) => ({ value: s.name, label: s.name }));
672
+ const target = p?.server ?? defaultServer();
673
+ const name = o.name ?? det.name;
674
+ const main = mainDomain(target);
675
+ dialog({
676
+ 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 .landfall.json)" : ""}`,
678
+ extra: `<div class="detected">${describe(det)}</div>`,
679
+ fields: [
680
+ { name: "name", label: "Site name", value: name },
681
+ { name: "server", label: "Server", type: "select", options: servers, value: target },
682
+ {
683
+ name: "domains", label: "Domains", value: (p?.domains ?? []).join(" "),
684
+ placeholder: autoDomain(target, name) || "example.com www.example.com",
685
+ hint: main
686
+ ? `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
+ : "Empty: a free <name>.<ip>.sslip.io address, no DNS needed. Own domains need an A record pointing at the server.",
688
+ },
689
+ ...(det.kind === "static"
690
+ ? [
691
+ { name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
692
+ { name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
693
+ ]
694
+ : []),
695
+ ],
696
+ ok: "Deploy",
697
+ onOk: (v) => {
698
+ const n = v.name.trim();
699
+ if (!n || !v.server) return false;
700
+ const domains = v.domains.split(/[\s,]+/).filter(Boolean);
701
+ const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...domains.flatMap((d) => ["-d", d])];
702
+ if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
703
+ if (v.spa && !p?.spa) argv.push("--spa");
704
+ if (o.upload) argv.push("--no-save");
705
+ run(argv, { server: v.server, cwd: det.path, after: openAfter });
706
+ },
707
+ });
708
+ }
709
+
710
+ /** After a deploy: offer the address it printed. */
711
+ function openAfter(j) {
712
+ if (j.info.code !== 0) return;
713
+ const url = j.lines.map((l) => l.replace(ANSI, "").match(/https:\/\/[^\s)]+/)?.[0]).filter(Boolean).pop();
714
+ if (url) return [{ label: `Open ${url.replace("https://", "")}`, run: () => window.open(url, "_blank", "noopener") }];
715
+ }
716
+
717
+ async function browse(path) {
718
+ return http(`/api/fs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
719
+ }
720
+
721
+ function deployHere() {
722
+ browse(S.state.cwd).then((b) => deployDialog(b.detected), (e) => toast(e.message));
723
+ }
724
+
725
+ /** Pick a folder on this machine: recent projects, a folder browser, or drop/choose files to upload. */
726
+ async function folderDialog(path) {
727
+ let b;
728
+ try {
729
+ b = await browse(path);
730
+ } catch (e) {
731
+ return toast(e.message);
732
+ }
733
+ const dlg = openDlg("wide");
734
+ const recent = (S.state.projects ?? []).slice(0, 8);
735
+ const parts = b.path.split("/").filter(Boolean);
736
+ const crumbs = `<button type="button" class="ghost sm" data-path="/">/</button>` + parts.map((x, i) =>
737
+ `<button type="button" class="ghost sm" data-path="${esc("/" + parts.slice(0, i + 1).join("/"))}">${esc(x)}</button>`).join(`<span class="faint">/</span>`);
738
+ dlg.innerHTML = `<form method="dialog">
739
+ <h2>Deploy a folder</h2>
740
+ ${recent.length ? `<fieldset><legend>Recent</legend><div class="list">${recent
741
+ .map((r, i) => `<button type="button" class="row" data-recent="${i}" title="Deploy again"><b>${esc(r.name)}</b><span class="dim">→ ${esc(r.server)}${r.domains[0] ? ` · ${esc(r.domains[0])}` : ""}</span><span class="faint mono">${esc(tilde(r.path))}</span></button>`)
742
+ .join("")}</div></fieldset>` : ""}
743
+ <div class="crumbs mono">${crumbs} <button type="button" class="ghost sm" data-path="${esc(b.home)}" title="Home">~</button></div>
744
+ <div class="list">${b.parent ? `<button type="button" class="row" data-path="${esc(b.parent)}">↑ ..</button>` : ""}${b.dirs
745
+ .map((d) => `<button type="button" class="row" data-path="${esc(b.path.replace(/\/$/, "") + "/" + d)}">📁 ${esc(d)}</button>`)
746
+ .join("") || `<div class="row faint">no subfolders</div>`}</div>
747
+ <div class="detected"><b>${esc(b.detected.name)}</b>: ${describe(b.detected)}</div>
748
+ <div class="drop">Or drop a folder or .zip anywhere ·
749
+ <label>choose a folder<input type="file" id="pick-dir" webkitdirectory multiple hidden></label> ·
750
+ <label>choose a .zip<input type="file" id="pick-zip" accept=".zip,.tar,.tgz,.gz" hidden></label></div>
751
+ <div class="btns">
752
+ <button type="button" class="ghost" id="dlg-cancel">Cancel</button>
753
+ <button type="submit" class="primary">Deploy this folder…</button>
754
+ </div>
755
+ </form>`;
756
+ $("#dlg-cancel", dlg).onclick = () => dlg.close();
757
+ $("form", dlg).onsubmit = (e) => (e.preventDefault(), deployDialog(b.detected));
758
+ $("form", dlg).onclick = (e) => {
759
+ const el = e.target.closest("[data-path],[data-recent]");
760
+ if (!el) return;
761
+ if (el.dataset.path) return void folderDialog(el.dataset.path);
762
+ browse(recent[el.dataset.recent].path).then((x) => deployDialog(x.detected), (err) => toast(err.message));
763
+ };
764
+ $("#pick-dir", dlg).onchange = (e) => {
765
+ const files = [...e.target.files];
766
+ upload(files.filter((f) => !skipped(f.webkitRelativePath)).map((f) => [f, f.webkitRelativePath || f.name]), files[0]?.webkitRelativePath.split("/")[0]);
767
+ };
768
+ $("#pick-zip", dlg).onchange = (e) => {
769
+ const f = e.target.files[0];
770
+ if (f) upload([[f, f.name]], f.name);
771
+ };
772
+ if (!dlg.open) dlg.showModal();
773
+ }
774
+
775
+ // Dropped folders: walk the entries (skipping dependencies and VCS data), then upload with relative paths.
776
+ const SKIP = new Set(["node_modules", ".git", ".DS_Store", "__MACOSX"]);
777
+ const skipped = (rel) => rel.split("/").some((x) => SKIP.has(x));
778
+ async function droppedFiles(roots) {
779
+ const out = [];
780
+ const walk = async (entry, prefix) => {
781
+ if (SKIP.has(entry.name)) return;
782
+ if (entry.isFile) out.push([await new Promise((res, rej) => entry.file(res, rej)), prefix + entry.name]);
783
+ else if (entry.isDirectory) {
784
+ const reader = entry.createReader();
785
+ for (let batch; (batch = await new Promise((res, rej) => reader.readEntries(res, rej))).length; ) {
786
+ for (const e of batch) await walk(e, `${prefix}${entry.name}/`);
787
+ }
788
+ }
789
+ };
790
+ for (const r of roots) await walk(r, "");
791
+ return out;
792
+ }
793
+
794
+ async function upload(pairs, label = "site") {
795
+ if (!pairs.length) return toast("Nothing to upload");
796
+ const fd = new FormData();
797
+ for (const [f, path] of pairs) fd.append("file", f), fd.append("path", path);
798
+ toast(`Uploading ${plural(pairs.length, "file")}…`);
799
+ try {
800
+ const r = await fetch("/api/upload", { method: "POST", body: fd });
801
+ const data = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
802
+ if (!r.ok) throw new Error(data.error);
803
+ toast(`Uploaded ${plural(data.files, "file")}`);
804
+ deployDialog(data.detected, { upload: true, files: data.files, name: slugify(label) });
805
+ } catch (e) {
806
+ toast(`Upload failed: ${e.message}`);
807
+ }
808
+ }
809
+
810
+ const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes("Files");
811
+ window.addEventListener("dragover", (e) => {
812
+ if (!hasFiles(e)) return;
813
+ e.preventDefault();
814
+ document.body.classList.add("dragging");
815
+ });
816
+ window.addEventListener("dragleave", (e) => {
817
+ if (!e.relatedTarget) document.body.classList.remove("dragging");
818
+ });
819
+ window.addEventListener("drop", (e) => {
820
+ if (!hasFiles(e)) return;
821
+ e.preventDefault();
822
+ document.body.classList.remove("dragging");
823
+ // Entries must be taken while the event is live.
824
+ const roots = [...e.dataTransfer.items].map((i) => i.webkitGetAsEntry?.()).filter(Boolean);
825
+ const label = roots[0]?.name ?? e.dataTransfer.files[0]?.name;
826
+ const files = roots.length ? droppedFiles(roots) : Promise.resolve([...e.dataTransfer.files].map((f) => [f, f.name]));
827
+ files.then((pairs) => upload(pairs, label), (err) => toast(err.message));
828
+ });
829
+
830
+ // ---- domains & login ---------------------------------------------------------
831
+
832
+ function copy(text) {
833
+ navigator.clipboard?.writeText(text).then(() => toast(`Copied ${text}`), () => toast(text)) ?? toast(text);
834
+ }
835
+
836
+ const servedByOther = (n, site, d) => S.overview[n]?.data?.sites.find((s) => s.meta.name !== site && s.meta.domains.includes(d))?.meta.name;
837
+
838
+ function domainsDialog(n, m) {
839
+ const main = mainDomain(n);
840
+ const free = autoDomain(n, m.name);
841
+ const ips = S.dns[n]?.data?.ips ?? [];
842
+ dialog({
843
+ title: `Domains of ${m.name}`,
844
+ text: `Each domain needs an A record pointing at ${ips.join(", ") || "the server"}. Traefik fetches the certificate once it resolves.`,
845
+ fields: [
846
+ { name: "domains", label: "Domains (space separated)", value: m.domains.join(" ") },
847
+ ...(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." }] : []),
848
+ ...(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." }] : []),
849
+ ],
850
+ ok: "Save",
851
+ onOk: (v) => {
852
+ const domains = v.domains.split(/[\s,]+/).filter(Boolean);
853
+ if (v.free) domains.push(free);
854
+ if (v.apex) domains.push(main);
855
+ if (!domains.length) return void toast("A site needs at least one domain"), false;
856
+ run(["domains", m.name, ...sv(n), ...domains.flatMap((d) => ["--set", d]), "-y"], { server: n });
857
+ },
858
+ });
859
+ }
860
+
861
+ function authDialog(n, m) {
862
+ const users = (m.auth ?? []).map((a) => a.split(":")[0]);
863
+ dialog({
864
+ title: `Password for ${m.name}`,
865
+ text: users.length
866
+ ? `Visitors log in as ${users.join(", ")}. Saving an existing user changes their password.`
867
+ : "Visitors have to log in (HTTP basic auth) before they see the site.",
868
+ fields: [
869
+ { name: "user", label: "User", value: users[0] ?? "admin" },
870
+ { name: "password", label: "Password (at least 8 characters)", type: "password", auto: "new-password" },
871
+ ...(users.length ? [{ name: "off", label: "Remove the login: the site is public again", type: "check", value: false }] : []),
872
+ ],
873
+ ok: "Save",
874
+ onOk: (v) => {
875
+ if (v.off) return void run(["auth", m.name, ...sv(n), "--off", "-y"], { server: n });
876
+ if (!v.user.trim()) return void toast("Give a user name"), false;
877
+ if (v.password.length < 8) return void toast("The password needs at least 8 characters"), false;
878
+ run(["auth", m.name, ...sv(n), "-u", v.user.trim(), "-p", v.password], { server: n });
879
+ },
880
+ });
881
+ }
882
+
883
+ function ensureDns(n, o) {
884
+ if (S.dns[n] || !o?.sites.length) return;
885
+ const domains = [...new Set(o.sites.flatMap((x) => x.meta.domains))];
886
+ S.dns[n] = { loading: true };
887
+ http(`/api/servers/${encodeURIComponent(n)}/dns?domains=${encodeURIComponent(domains.join(","))}`).then(
888
+ (data) => ((S.dns[n] = { data }), render()),
889
+ (e) => ((S.dns[n] = { error: e.message }), render()),
890
+ );
891
+ }
892
+
893
+ function domainChip(n, d) {
894
+ const c = S.dns[n]?.data?.domains.find((x) => x.domain === d);
895
+ const url = `https://${d}`;
896
+ let dot = "", title = "checking…";
897
+ if (c) {
898
+ const st = c.https.status;
899
+ if (!c.ips.length) (dot = "down"), (title = "DNS: does not resolve");
900
+ else if (!c.ok) (dot = "warn"), (title = `DNS points at ${c.ips.join(", ")}, not this server`);
901
+ else if (st && st < 500) (dot = "up"), (title = `HTTPS ${st}${st === 401 ? " (login required)" : ""}`);
902
+ else (dot = "down"), (title = `HTTPS: ${st ?? c.https.error}`);
903
+ }
904
+ return `<span class="chip"><span class="dot ${dot}" title="${esc(title)}"></span><a href="${esc(url)}" target="_blank" rel="noopener" title="${esc(title)}" onclick="event.stopPropagation()">${esc(d)}</a>${btn("⧉", () => copy(url), "ghost", "Copy the address")}</span>`;
905
+ }
906
+
907
+ function runCommand(n) {
908
+ promptDlg(`Run a command on ${n}`, "Runs through ssh as the server's login; output shows on the right.", { label: "Command", placeholder: "df -h", type: "text" }, (cmd) => {
909
+ if (cmd) run(["ssh", n, "--", cmd], { server: n });
910
+ }, "Run");
911
+ }
912
+
913
+ function forget(n) {
914
+ confirmDlg({
915
+ title: `Forget ${n}?`,
916
+ text: "Removes it from landfall (and the Host block landfall wrote to ~/.ssh/config).\nNothing on the server is changed.",
917
+ ok: `Forget ${n}`,
918
+ danger: true,
919
+ onOk: () => run(["server", "rm", n, "-y"], { server: n, after: (j) => (j.info.code === 0 ? void go(null) : undefined) }),
920
+ });
921
+ }
922
+
923
+ async function makeDefault(n) {
924
+ try {
925
+ await http(`/api/servers/${encodeURIComponent(n)}/default`, {});
926
+ await refreshState();
927
+ toast(`${n} is now the default server`);
928
+ } catch (e) {
929
+ toast(e.message);
930
+ }
931
+ }
932
+
933
+ // ---- views ---------------------------------------------------------------
934
+
935
+ function renderNav() {
936
+ const st = S.state;
937
+ const list = st.servers;
938
+ const own = list.filter((s) => s.registered);
939
+ const aliases = list.filter((s) => !s.registered);
940
+ 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
+ <div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
942
+ <div class="t">${esc(s.target)}</div></button>`;
943
+ $("#nav").innerHTML = `
944
+ ${own.length ? `<h3>Servers</h3>${own.map(item).join("")}` : ""}
945
+ ${aliases.length ? `<h3>From ~/.ssh/config</h3>${aliases.map(item).join("")}` : ""}
946
+ <div style="padding:10px 8px">${btn("+ Add server", addServer, "", "Register an ssh alias or set up a fresh machine")}</div>`;
947
+ $("#pick").innerHTML = `<select onchange="go(this.value || null)">
948
+ <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
+ </select>`;
950
+ }
951
+
952
+ function renderHeader() {
953
+ const st = S.state;
954
+ $("#version").textContent = st.version;
955
+ const p = st.project;
956
+ $("#project").innerHTML = `
957
+ <span class="path" title="${esc(st.cwd)}">${p ? `<b>${esc(p.name)}</b> · ${esc(p.domains.join(", "))}` : esc(st.cwd.replace(/^\/(Users|home)\/[^/]+/, "~"))}</span>
958
+ ${btn("Deploy folder…", () => folderDialog(st.cwd), "", "Pick a folder, a recent project, or drop one here")}
959
+ ${btn(p ? "Deploy" : "Deploy this folder", deployHere, "primary", p ? `lf deploy (.landfall.json in ${st.cwd})` : `lf deploy from ${st.cwd}`)}`;
960
+ }
961
+
962
+ function errorHint(error) {
963
+ if (/permission denied/i.test(error)) return "No key login yet. Use Set up: landfall asks for the password once and installs a key.";
964
+ if (/resolve|not known|nodename/i.test(error)) return "Host name not found. Check the entry in ~/.ssh/config.";
965
+ if (/timed out|refused|no route|unreachable/i.test(error)) return "Server not reachable. Is it running, and is the ssh port right?";
966
+ return "";
967
+ }
968
+
969
+ const isUp = (status) => /^up/i.test(status);
970
+ const statusDot = (status) => `<span class="dot ${isUp(status) ? (/unhealthy|restarting/i.test(status) ? "warn" : "up") : "down"}"></span>`;
971
+
972
+ const size = (b) => {
973
+ const u = ["B", "K", "M", "G", "T"];
974
+ let i = 0;
975
+ while (b >= 1024 && i < u.length - 1) (b /= 1024), i++;
976
+ return `${b < 10 && i ? b.toFixed(1) : Math.round(b)}${u[i]}`;
977
+ };
978
+ const uptime = (s) => {
979
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
980
+ return d ? `${d}d ${h}h` : h ? `${h}h` : `${Math.floor(s / 60)}m`;
981
+ };
982
+
983
+ function factsLine(n) {
984
+ const f = S.facts[n]?.data;
985
+ if (!f) return "";
986
+ const pct = (used, total) => (total ? Math.round((used / total) * 100) : 0);
987
+ const disk = pct(f.disk.used, f.disk.total);
988
+ const items = [
989
+ `<b>${esc(f.os)}</b> ${esc(f.arch)}`,
990
+ `<span title="kernel ${esc(f.kernel)}">up ${uptime(f.uptime)}</span>`,
991
+ f.cpus ? `${f.cpus} CPU · load ${esc(f.load.split(" ")[0])}` : "",
992
+ f.mem.total ? `RAM ${size(f.mem.total - f.mem.available)} / ${size(f.mem.total)}` : "",
993
+ f.disk.total ? `<span ${disk > 85 ? `style="color:var(--warn)"` : ""}>disk ${disk}% of ${size(f.disk.total)}</span>` : "",
994
+ f.docker ? `docker ${esc(f.docker)}` : `<span class="faint">no docker</span>`,
995
+ f.reboot ? `<span class="badge warn">reboot required</span>` : "",
996
+ ];
997
+ return `<div class="facts">${items.filter(Boolean).map((x) => `<span>${x}</span>`).join("")}</div>`;
998
+ }
999
+
1000
+ function factsNote(n) {
1001
+ const sup = S.facts[n]?.data?.support;
1002
+ if (!sup?.note) return "";
1003
+ return `<div class="note ${sup.level === "ok" ? "" : "bad"}">${esc(sup.note)}</div>`;
1004
+ }
1005
+
1006
+ function renderMain() {
1007
+ 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
+ }
1014
+ const n = S.sel;
1015
+ const s = server();
1016
+ const ov = S.overview[n] ?? { loading: true };
1017
+ const o = ov.data;
1018
+ const traefik = o
1019
+ ? o.traefik
1020
+ ? `${statusDot(o.traefik)}${esc(o.proxy)} ${esc(o.traefik.toLowerCase())}`
1021
+ : o.ready ? `<span class="dot down"></span>no traefik` : ""
1022
+ : "";
1023
+ const sub = [esc(s?.target ?? ""), s && !s.registered ? "from ~/.ssh/config" : "", traefik].filter(Boolean).join(" · ");
1024
+ const tab = (t, label) => `<button type="button" class="${S.tab === t ? "on" : ""}" ${on(() => go(n, t))}>${label}</button>`;
1025
+ main.innerHTML = `
1026
+ <div class="head">
1027
+ <div><h1>${esc(n)}${s?.isDefault ? ` <span class="star" title="default server">★</span>` : ""}</h1><div class="sub">${sub}</div>${factsLine(n)}</div>
1028
+ <div class="actions">
1029
+ ${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
+ ${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
1033
+ ${s?.isDefault ? "" : btn("Make default", () => makeDefault(n), "ghost")}
1034
+ ${s?.registered ? btn("Forget", () => forget(n), "ghost danger") : ""}
1035
+ ${btn("↻", () => load(n, S.tab, true) || render(), "ghost", "Reload")}
1036
+ </div>
1037
+ </div>
1038
+ ${factsNote(n)}
1039
+ <div class="tabs">${tab("sites", `Sites${o ? ` <span class="badge">${o.sites.length}</span>` : ""}`)}${tab("updates", `Updates${updatesBadge(n)}`)}${tab("firewall", "Firewall")}${tab("history", "History")}${tab("settings", "Settings")}</div>
1040
+ <div id="view"></div>`;
1041
+ const view = $("#view");
1042
+ if (ov.loading) view.innerHTML = `<div class="loading"><span class="spin"></span> connecting to ${esc(n)}…</div>`;
1043
+ else if (ov.error) {
1044
+ const hint = errorHint(ov.error);
1045
+ view.innerHTML = `<div class="note bad"><b>Cannot reach ${esc(n)}.</b> ${esc(hint)}<div class="mono" style="margin-top:6px">${esc(ov.error)}</div></div>
1046
+ <div class="actions" style="margin:0">${btn("Set up", () => setupFlow([n]), "primary")} ${btn("Retry", () => (load(n, S.tab, true), render()))}</div>`;
1047
+ } else if (S.tab === "sites") view.innerHTML = sitesView(n, o);
1048
+ else if (S.tab === "updates") view.innerHTML = updatesView(n);
1049
+ else if (S.tab === "firewall") view.innerHTML = firewallView(n);
1050
+ else if (S.tab === "settings") view.innerHTML = settingsView(n);
1051
+ else view.innerHTML = historyView(n);
1052
+ }
1053
+
1054
+ function sitesView(n, o) {
1055
+ ensureDns(n, o);
1056
+ if (!o.ready) {
1057
+ return `<div class="card"><div class="body empty"><h2>Not set up for landfall yet</h2>
1058
+ <p>Setup installs Docker and Traefik, a deploy user and (if you want) a firewall. It shows what it would change first.</p>
1059
+ <p>${btn("Check & set up", () => setupFlow([n]), "primary")}</p></div></div>`;
1060
+ }
1061
+ if (!o.sites.length) {
1062
+ return `<div class="card"><div class="body empty"><h2>No sites yet</h2>
1063
+ <p>Deploy a static folder, a Dockerfile project or an image. It gets HTTPS automatically.</p>
1064
+ <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>`;
1066
+ }
1067
+ const rows = o.sites
1068
+ .map(({ meta: m, status }) => {
1069
+ const cur = m.releases.find((r) => r.id === m.current);
1070
+ const name = m.name;
1071
+ const open = S.open.has(`${n}/${name}`);
1072
+ const up = isUp(status);
1073
+ const older = [...m.releases].sort((a, b) => (a.at < b.at ? 1 : -1)).filter((r) => r.at < (cur?.at ?? ""));
1074
+ const proj = (S.state.projects ?? []).find((p) => p.server === n && p.name === name);
1075
+ const acts = [
1076
+ proj
1077
+ ? 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
+ : "",
1079
+ btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the container logs"),
1080
+ btn("Restart", () => run(["restart", name, ...sv(n)]), "sm"),
1081
+ btn(up ? "Stop" : "Start", () => run([up ? "stop" : "start", name, ...sv(n)]), "sm"),
1082
+ older.length
1083
+ ? btn("Roll back", () => confirmDlg({ title: `Roll back ${name}?`, text: `From ${m.current} to ${older[0].id} (${ago(older[0].at)})`, ok: "Roll back", onOk: () => run(["rollback", name, ...sv(n), "-y"]) }), "sm")
1084
+ : "",
1085
+ btn("Domains", () => domainsDialog(n, m), "sm", "Add, change or remove domains"),
1086
+ btn(m.auth?.length ? "Login" : "Password", () => authDialog(n, m), "sm", "Protect the site with a user and password"),
1087
+ m.kind === "static" ? "" : btn("Env", () => envDialog(n, name), "sm", "Environment variables"),
1088
+ btn("Delete", () => confirmDlg({ title: `Delete ${name} from ${n}?`, text: "Stops it and removes all its files and releases.\nThis cannot be undone.", ok: `Delete ${name}`, danger: true, onOk: () => run(["sites", "rm", name, ...sv(n), "-y"]) }), "sm danger"),
1089
+ ].join("");
1090
+ const toggle = () => (open ? S.open.delete(`${n}/${name}`) : S.open.add(`${n}/${name}`), render());
1091
+ const detail = open
1092
+ ? `<tr class="detail"><td colspan="5"><dl class="kv">
1093
+ <dt>Container</dt><dd class="mono">landfall-${esc(name)} · ${esc(status)}</dd>
1094
+ <dt>On ${esc(n)}</dt><dd>${siteFiles(m, cur)}</dd>
1095
+ <dt>Source</dt><dd>${proj
1096
+ ? `<span class="mono">${esc(tilde(proj.path))}</span> <span class="dim">· settings in</span> <span class="mono">.landfall.json</span> <span class="dim">· redeploy runs there</span>`
1097
+ : `<span class="dim">not deployed from this machine${m.kind === "image" ? "" : " (or the folder moved)"}</span>`}</dd>
1098
+ <dt>Kind</dt><dd>${esc(m.kind)}${m.spa ? " (single-page app)" : ""} · port ${esc(m.port)}</dd>
1099
+ <dt>Login</dt><dd>${m.auth?.length ? `${esc(m.auth.map((a) => a.split(":")[0]).join(", "))} (basic auth)` : `<span class="dim">public</span>`}</dd>
1100
+ <dt>Created</dt><dd>${esc(ago(m.createdAt))} · updated ${esc(ago(m.updatedAt))}</dd>
1101
+ <dt>Releases</dt><dd><ul class="releases">${[...m.releases]
1102
+ .sort((a, b) => (a.at < b.at ? 1 : -1))
1103
+ .map((r) => `<li><span class="mono">${esc(r.id)}</span><span class="dim">${esc(ago(r.at))}</span>${r.image && m.kind === "image" ? `<span class="mono faint">${esc(r.image)}</span>` : ""}
1104
+ ${r.id === m.current ? `<span class="badge ok">live</span>` : btn("Switch to this", () => confirmDlg({ title: `Switch ${name} to ${r.id}?`, text: `Release from ${ago(r.at)} goes live instead of ${m.current}.`, ok: "Switch", onOk: () => run(["rollback", name, ...sv(n), "--to", r.id, "-y"]) }), "sm")}</li>`)
1105
+ .join("")}</ul></dd>
1106
+ </dl></td></tr>`
1107
+ : "";
1108
+ return `<tr class="click" ${on(toggle)}>
1109
+ <td class="nowrap">${statusDot(status)}<b>${esc(name)}</b>${m.auth?.length ? `<span class="lock" title="password protected">🔒</span>` : ""} <span class="faint">${open ? "▾" : "▸"}</span></td>
1110
+ <td class="hide-sm"><div class="chips">${m.domains.map((d) => domainChip(n, d)).join("")}</div></td>
1111
+ <td class="hide-sm"><span class="badge">${esc(m.kind)}</span></td>
1112
+ <td class="hide-sm dim">${cur ? esc(ago(cur.at)) : ""}</td>
1113
+ <td class="acts">${acts}</td></tr>${detail}`;
1114
+ })
1115
+ .join("");
1116
+ return `<div class="card scroll"><table>
1117
+ <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>`;
1119
+ }
1120
+
1121
+ /** Where a site lives on the server, and which of its files are live. */
1122
+ function siteFiles(m, cur) {
1123
+ const dir = `${S.state.paths.sites}/${m.name}`;
1124
+ const live =
1125
+ m.kind === "image" ? `image <span class="mono">${esc(cur?.image ?? "?")}</span>`
1126
+ : m.kind === "static" ? `files <span class="mono">${esc(`${dir}/releases/${m.current}`)}</span>`
1127
+ : `built from <span class="mono">${esc(`${dir}/releases/${m.current}`)}</span>${cur?.image ? ` as <span class="mono">${esc(cur.image)}</span>` : ""}`;
1128
+ return `<span class="mono">${esc(dir)}</span><div class="dim">live: ${live}</div>
1129
+ <div class="faint mono">site.json · docker-compose.yml · .env (mode 600)${m.kind === "image" ? "" : " · releases/"}</div>`;
1130
+ }
1131
+
1132
+ function envDialog(n, name) {
1133
+ dialog({
1134
+ title: `Environment of ${name}`,
1135
+ text: "Changes recreate the container. Leave both empty to only show the current values.",
1136
+ fields: [
1137
+ { name: "set", label: "Set (KEY=VALUE, one per line)", type: "textarea", value: "", placeholder: "DATABASE_URL=postgres://…\nLOG_LEVEL=info" },
1138
+ { name: "unset", label: "Remove (names, space separated)", value: "", placeholder: "OLD_KEY" },
1139
+ ],
1140
+ ok: "Apply",
1141
+ onOk: (v) => {
1142
+ const pairs = v.set.split("\n").map((l) => l.trim()).filter((l) => l && l.includes("="));
1143
+ const unset = v.unset.split(/[\s,]+/).filter(Boolean).flatMap((k) => ["-u", k]);
1144
+ run(["env", name, ...sv(n), ...pairs, ...unset]);
1145
+ },
1146
+ });
1147
+ }
1148
+
1149
+ // ---- updates ---------------------------------------------------------------
1150
+
1151
+ const IMG = { current: ["ok", "up to date"], update: ["warn", "newer image"], unknown: ["", "unknown"], pinned: ["", "pinned"], rebuild: ["", "rebuild"], foreign: ["", "not landfall's"], missing: ["bad", "missing"] };
1152
+
1153
+ function updatesBadge(n) {
1154
+ const d = S.updates[n]?.data;
1155
+ if (!d) return "";
1156
+ const k = d.packages.updates.length + d.images.filter((i) => i.state === "update").length;
1157
+ const sec = d.packages.updates.some((u) => u.security);
1158
+ return k ? ` <span class="badge ${sec ? "warn" : ""}">${k}</span>` : "";
1159
+ }
1160
+
1161
+ function refreshUpdates(n) {
1162
+ S.updates[n] = { loading: true, refreshing: true };
1163
+ render();
1164
+ http(`/api/servers/${encodeURIComponent(n)}/updates?refresh=1`).then(
1165
+ (data) => ((S.updates[n] = { data }), render()),
1166
+ (e) => ((S.updates[n] = { error: e.message }), render()),
1167
+ );
1168
+ }
1169
+
1170
+ function updatesView(n) {
1171
+ const st = S.updates[n] ?? { loading: true };
1172
+ if (st.loading) return `<div class="loading"><span class="spin"></span> ${st.refreshing ? "refreshing package lists…" : "checking packages and images…"}</div>`;
1173
+ if (st.error) return `<div class="note bad">${esc(st.error)}</div><div class="actions" style="margin:0">${btn("Retry", () => (load(n, "updates", true), render()))}</div>`;
1174
+ const { packages: p, images, engine: e } = st.data;
1175
+ const s = sv(n);
1176
+ const sec = p.updates.filter((u) => u.security);
1177
+ const upgrade = (flags, what, k) =>
1178
+ confirmDlg({
1179
+ title: `Upgrade ${what} on ${n}?`,
1180
+ text: `${plural(k, "package")} through ${p.mgr}, as root. Package upgrades cannot be undone.${flags.includes("--reboot") ? "\nReboots afterwards if the upgrade needs it: sites are offline for a minute or two." : ""}`,
1181
+ ok: "Upgrade",
1182
+ danger: flags.includes("--reboot"),
1183
+ onOk: () => run(["update", "--packages", ...flags, ...s, "-y"], { server: n }),
1184
+ });
1185
+ const pkgActs = [
1186
+ btn("Refresh lists", () => refreshUpdates(n), "sm", "Fetch the newest package lists first (apt update)"),
1187
+ sec.length && p.mgr !== "apk" ? btn(`Security only (${sec.length})`, () => upgrade(["--security"], "security updates", sec.length), "sm") : "",
1188
+ p.updates.length ? btn("Upgrade all", () => upgrade([], "all packages", p.updates.length), "sm primary") : "",
1189
+ p.updates.length ? btn("Upgrade + reboot", () => upgrade(["--reboot"], "all packages", p.updates.length), "sm danger") : "",
1190
+ ].join("");
1191
+ const shown = p.updates.slice(0, 200);
1192
+ const pkgRows = shown
1193
+ .map((u) => `<tr><td class="mono">${esc(u.name)}</td><td class="mono dim hide-sm">${esc(u.from ?? "")}</td><td class="mono">${esc(u.to)}</td><td>${u.security ? `<span class="badge warn">security</span>` : ""}</td></tr>`)
1194
+ .join("");
1195
+ const imgUpdates = images.filter((i) => i.state === "update");
1196
+ const updImg = (names) =>
1197
+ confirmDlg({
1198
+ title: `Update ${names.join(", ")}?`,
1199
+ text: "Pulls the newer image and recreates the container (a few seconds offline). Undo puts the old image back.",
1200
+ ok: "Update",
1201
+ onOk: () => run(["update", ...names, "--images", ...s, "-y"], { server: n }),
1202
+ });
1203
+ const imgRows = images
1204
+ .map((i) => {
1205
+ const [cls, label] = IMG[i.state] ?? ["", i.state];
1206
+ const can = i.state === "update" || i.state === "unknown";
1207
+ return `<tr><td><b>${esc(i.name)}</b> <span class="faint">${esc(i.kind)}</span></td><td class="mono dim hide-sm">${esc(i.ref || "-")}</td>
1208
+ <td><span class="badge ${cls}">${esc(label)}</span></td><td class="dim hide-sm">${esc(i.note ?? "")}</td>
1209
+ <td class="acts">${can ? btn(i.state === "update" ? "Update" : "Pull anyway", () => updImg([i.name]), i.state === "update" ? "sm primary" : "sm") : ""}</td></tr>`;
1210
+ })
1211
+ .join("");
1212
+ const head = p.mgr === "none"
1213
+ ? `<span class="dim" style="font-weight:400">no supported package manager</span>`
1214
+ : p.updates.length
1215
+ ? `<span class="badge">${p.updates.length}</span>${sec.length ? ` <span class="badge warn">${sec.length} security</span>` : ""}`
1216
+ : `<span class="badge ok">up to date</span>`;
1217
+ return `
1218
+ <div class="card">
1219
+ <h2>Packages ${head}${p.reboot ? ` <span class="badge warn">reboot required</span>` : ""}
1220
+ <span class="dim hide-sm" style="font-weight:400">${esc(p.mgr)}${p.refreshed ? "" : " · lists not refreshed"}</span>
1221
+ <span class="actions">${pkgActs}</span></h2>
1222
+ ${pkgRows ? `<div class="scroll"><table>
1223
+ <thead><tr><th>Package</th><th class="hide-sm">Installed</th><th>New</th><th></th></tr></thead>
1224
+ <tbody>${pkgRows}${p.updates.length > shown.length ? `<tr><td colspan="4" class="dim">… ${p.updates.length - shown.length} more</td></tr>` : ""}</tbody>
1225
+ </table></div>` : ""}
1226
+ </div>
1227
+ <div class="card">
1228
+ <h2>Docker <span class="badge ${!e.docker || e.dockerNote || e.composeNote ? "warn" : "ok"}">${e.docker ? `${esc(e.docker)} · compose ${esc(e.compose || "missing")}` : "not installed"}</span>
1229
+ <span class="actions">${e.composeUpdate ? btn("Update compose", () => run(["update", "--compose", ...s, "-y"], { server: n }), "sm primary") : ""}</span></h2>
1230
+ ${[e.dockerNote, e.composeNote].filter(Boolean).map((t) => `<p class="dim">${esc(t)}</p>`).join("")}
1231
+ </div>
1232
+ <div class="card">
1233
+ <h2>Images <span class="dim" style="font-weight:400">Traefik and sites</span>
1234
+ <span class="actions">${imgUpdates.length ? btn(`Update all (${imgUpdates.length})`, () => updImg(imgUpdates.map((i) => i.name)), "sm primary") : ""}</span></h2>
1235
+ <div class="scroll"><table>
1236
+ <thead><tr><th>Name</th><th class="hide-sm">Image</th><th>State</th><th class="hide-sm">Note</th><th></th></tr></thead>
1237
+ <tbody>${imgRows || `<tr><td colspan="5" class="dim">no containers</td></tr>`}</tbody>
1238
+ </table></div>
1239
+ </div>`;
1240
+ }
1241
+
1242
+ // ---- firewall --------------------------------------------------------------
1243
+
1244
+ const REACH = { open: "ok", limited: "accent", blocked: "bad", local: "" };
1245
+
1246
+ function firewallView(n) {
1247
+ const st = S.firewall[n] ?? { loading: true };
1248
+ if (st.loading) return `<div class="loading"><span class="spin"></span> reading the firewall…</div>`;
1249
+ if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
1250
+ const fw = st.data;
1251
+ const s = sv(n);
1252
+ const fwRun = (argv) => run(argv, { server: n });
1253
+ const active = fw.ufw === "active";
1254
+ const toggle =
1255
+ fw.ufw === "missing"
1256
+ ? btn("Install & enable ufw", () => fwRun(["firewall", "enable", ...s]), "primary")
1257
+ : active
1258
+ ? btn("Turn off", () => confirmDlg({ title: `Turn the firewall on ${n} off?`, text: "Every listening port becomes reachable from anywhere.", ok: "Turn it off", danger: true, onOk: () => fwRun(["firewall", "disable", ...s, "-y"]) }), "danger")
1259
+ : btn("Turn on", () => fwRun(["firewall", "enable", ...s]), "primary");
1260
+ const badge = active ? `<span class="badge ok">on</span>` : fw.ufw === "missing" ? `<span class="badge warn">ufw not installed</span>` : `<span class="badge bad">off</span>`;
1261
+ const blocked = !active && fw.blocked.length
1262
+ ? `<div class="note">Turning it on would cut off ${esc(fw.blocked.join(", "))}: add rules for them first, or landfall refuses (with a way to do it anyway).</div>`
1263
+ : "";
1264
+ const ruleRows = fw.rules
1265
+ .map((r, i) => {
1266
+ const num = String(i + 1);
1267
+ const flip = r.action === "deny" || r.action === "reject" ? "allow" : "deny";
1268
+ const cls = r.action === "allow" ? "ok" : r.action === "limit" ? "accent" : "bad";
1269
+ const port = r.port ? `${r.port}${r.proto ? `/${r.proto}` : ""}` : "";
1270
+ return `<tr>
1271
+ <td class="faint">${num}</td>
1272
+ <td>${btn(`<span class="badge ${cls}">${esc(r.action)}${r.out ? " out" : ""}</span>`, () => fwRun(["firewall", "update", num, "--action", flip, ...s]), "ghost sm", `Switch to ${flip}`)}</td>
1273
+ <td class="mono">${esc(port || r.spec)}</td>
1274
+ <td>${r.from ? `<span class="mono">${esc(r.from)}</span>` : `<span class="dim">anywhere</span>`}</td>
1275
+ <td class="dim hide-sm">${esc(r.comment ?? "")}</td>
1276
+ <td class="acts">${btn("Edit", () => editRule(n, num, r), "sm")}${btn("Remove", () => confirmDlg({ title: `Remove rule ${num}?`, text: r.spec, ok: "Remove it", danger: true, onOk: () => fwRun(["firewall", "rm", num, ...s, "-y"]) }), "sm danger")}</td>
1277
+ </tr>`;
1278
+ })
1279
+ .join("");
1280
+ const portRows = fw.ports
1281
+ .map((p) => `<tr><td class="mono">${esc(p.port)}</td><td>${esc(p.proc)}</td><td><span class="badge ${REACH[p.state] ?? ""}">${esc(p.state)}</span></td><td class="dim">${esc(p.why)}</td></tr>`)
1282
+ .join("");
1283
+ return `
1284
+ ${blocked}
1285
+ <div class="card">
1286
+ <h2>ufw ${badge} <span class="dim" style="font-weight:400">incoming default: ${esc(fw.policy.toLowerCase())} · ssh on ${esc(fw.sshPort)}${fw.asRoot ? " · as root" : ""}</span>
1287
+ <span class="actions">${toggle}</span></h2>
1288
+ <div class="scroll"><table>
1289
+ <thead><tr><th>#</th><th>Action</th><th>Port</th><th>From</th><th class="hide-sm">Comment</th><th></th></tr></thead>
1290
+ <tbody>${ruleRows || `<tr><td colspan="6" class="dim">no rules</td></tr>`}</tbody>
1291
+ </table></div>
1292
+ <div class="body" style="border-top:1px solid var(--line)">
1293
+ <form class="form-row" id="addrule">
1294
+ <label>Port <input name="port" placeholder="5432, 51820/udp, 6000:6007/tcp" required></label>
1295
+ <label>From <input name="from" placeholder="anywhere, or 10.0.0.0/8"></label>
1296
+ <label style="flex:0 0 110px">Action <select name="action"><option value="allow">allow</option><option value="deny">deny</option></select></label>
1297
+ <button type="submit" class="primary">Add rule</button>
1298
+ </form>
1299
+ </div>
1300
+ </div>
1301
+ <div class="card">
1302
+ <h2>Listening ports <span class="dim" style="font-weight:400">and whether the internet reaches them</span></h2>
1303
+ <div class="scroll"><table>
1304
+ <thead><tr><th>Port</th><th>Process</th><th>Reach</th><th>Why</th></tr></thead>
1305
+ <tbody>${portRows || `<tr><td colspan="4" class="dim">nothing listening</td></tr>`}</tbody>
1306
+ </table></div>
1307
+ </div>`;
1308
+ }
1309
+
1310
+ document.addEventListener("submit", (e) => {
1311
+ if (e.target.id !== "addrule") return;
1312
+ e.preventDefault();
1313
+ const f = e.target.elements;
1314
+ const port = f.port.value.trim();
1315
+ if (!port) return;
1316
+ const from = f.from.value.trim();
1317
+ run(["firewall", f.action.value, port, ...sv(S.sel), ...(from && from !== "anywhere" ? ["--from", from] : [])], { server: S.sel });
1318
+ });
1319
+
1320
+ function editRule(n, num, r) {
1321
+ const port = r.port ? `${r.port}${r.proto ? `/${r.proto}` : ""}` : "";
1322
+ dialog({
1323
+ title: `Edit rule ${num}`,
1324
+ text: r.spec,
1325
+ fields: [
1326
+ { name: "port", label: "Port", value: port, placeholder: "port[/proto]" },
1327
+ { name: "from", label: "From", value: r.from ?? "", placeholder: "anywhere", hint: "An address or network like 10.0.0.0/8. Empty = anyone." },
1328
+ { name: "action", label: "Action", type: "select", value: r.action, options: ["allow", "deny", "reject", "limit"].map((a) => ({ value: a, label: a })) },
1329
+ ],
1330
+ ok: "Save",
1331
+ onOk: (v) => {
1332
+ const argv = ["firewall", "update", num, ...sv(n)];
1333
+ if (v.port.trim() && v.port.trim() !== port) argv.push("--port", v.port.trim());
1334
+ argv.push("--from", v.from.trim() || "any");
1335
+ if (v.action !== r.action) argv.push("--action", v.action);
1336
+ run(argv, { server: n });
1337
+ },
1338
+ });
1339
+ }
1340
+
1341
+ // ---- history ---------------------------------------------------------------
1342
+
1343
+ function settingsView(n) {
1344
+ const st = S.settings[n] ?? { loading: true };
1345
+ if (st.loading) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
1346
+ if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
1347
+ const x = st.data;
1348
+ const reload = { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "settings", true), render()) : undefined) };
1349
+ const rows = (pairs) => `<dl class="kv">${pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>`;
1350
+ const dnsRow = (d) => `${esc(d.domain)} ${d.ok ? `<span class="badge ok">dns ok</span>` : d.ips.length ? `<span class="badge warn">dns → ${esc(d.ips.join(", "))}</span>` : `<span class="badge bad">no dns</span>`}`;
1351
+ const addDomain = () =>
1352
+ promptDlg(`Add a main domain to ${n}`, `${x.domains.length ? "The first main domain stays the primary." : "The first main domain is the primary: new sites get <site>.<domain>, and a bare name like blog means blog.<domain>."}\nPoint <domain> and *.<domain> (A records) at the server once.`, { label: "Domain", placeholder: "example.com", type: "text", value: "" }, (d) => {
1353
+ if (d) run(["server", "domain", n, "--add", d], reload);
1354
+ }, "Add");
1355
+ const dashboard = () =>
1356
+ 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) => {
1357
+ if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
1358
+ }, "Apply");
1359
+ const sites = S.overview[n]?.data?.sites ?? [];
1360
+ const servedBy = (d) => sites.find((s) => s.meta.domains.includes(d))?.meta.name;
1361
+ const serve = (d) => {
1362
+ const cur = servedBy(d);
1363
+ const pick = sites.filter((s) => s.meta.name !== cur);
1364
+ 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
+ dialog({
1366
+ title: `Serve a site on ${d}`,
1367
+ text: cur ? `${cur} serves ${d} now; it moves to the site you pick, which keeps its other domains.` : `The site keeps its other domains. ${d} needs an A record pointing at the server.`,
1368
+ fields: [{ name: "site", label: "Site", type: "select", options: pick.map((s) => ({ value: s.meta.name, label: `${s.meta.name} (${s.meta.domains.join(", ")})` })), value: pick[0].meta.name }],
1369
+ ok: "Serve it",
1370
+ 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
+ });
1372
+ };
1373
+ const P = S.state.paths;
1374
+ const ip = x.dns[0]?.ips[0] ?? "<server ip>";
1375
+ const records = x.dns.flatMap((r) => [r.apex, r.wildcard]).filter((d) => !d.ok).map((d) => `${esc(d.domain)} A ${esc(ip)}`);
1376
+ 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
+ return `
1378
+ <div class="card">
1379
+ <h2>Main domains ${btn("Add…", addDomain, "sm")}</h2>
1380
+ <div class="body">${x.domains.length
1381
+ ? `<table><tbody>${x.domains.map((d, i) => `<tr>
1382
+ <td>${dnsRow(x.dns[i].apex)}${i === 0 ? ` <span class="badge">primary</span>` : ""}<div class="faint">sites: ${dnsRow(x.dns[i].wildcard)}</div><div class="faint">${servedBy(d) ? `served by <b>${esc(servedBy(d))}</b>` : "no site serves it"}</div></td>
1383
+ <td><div class="actions">${btn(servedBy(d) ? "Serve another site…" : "Serve a site…", () => serve(d), "ghost sm")}${i ? btn("Make primary", () => run(["server", "domain", n, "--primary", d], reload), "ghost sm") : ""}${btn("Remove", () => confirmDlg({ title: `Remove ${d} from ${n}?`, text: "New sites stop getting names under it. Sites keep their domains.", ok: "Remove it", danger: true, onOk: () => run(["server", "domain", n, "--rm", d, "-y"], reload) }), "ghost sm")}</div></td>
1384
+ </tr>`).join("")}</tbody></table>
1385
+ <p class="faint">New sites get <span class="mono">&lt;site&gt;.${esc(x.domains[0])}</span>; a bare name like <span class="mono">blog</span> means <span class="mono">blog.${esc(x.domains[0])}</span>, and <span class="mono">@</span> means <span class="mono">${esc(x.domains[0])}</span> itself.</p>
1386
+ ${records.length ? `<div class="note">Add at your DNS provider:<div class="mono">${records.join("<br>")}</div></div>` : ""}`
1387
+ : `<p class="dim">No main domain set in landfall. New sites get a free <span class="mono">&lt;site&gt;.&lt;ip&gt;.sslip.io</span> address until you set one.</p>`}
1388
+ ${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
+ </div>
1390
+ <div class="card">
1391
+ <h2>Traefik ${btn("Dashboard…", dashboard, "sm")}</h2>
1392
+ <div class="body">${rows([
1393
+ ["image", esc(x.traefik.summary)],
1394
+ ["e-mail", esc(x.traefik.email ?? "none")],
1395
+ ["dashboard", x.traefik.dashboard ? `<a href="https://${esc(x.traefik.dashboard)}" target="_blank" rel="noopener">${esc(x.traefik.dashboard)}</a>` : "off"],
1396
+ ["files", `<span class="mono">${esc(P.traefik)}/docker-compose.yml</span>`],
1397
+ ["certificates", `<span class="mono">${esc(P.traefik)}/letsencrypt/</span> <span class="dim">(kept on reinstall)</span>`],
1398
+ ])}<p class="faint">Used when landfall installs or reconfigures its own Traefik. With a Traefik that was already there, landfall only writes <span class="mono">${esc(P.proxy)}</span>.</p></div>
1399
+ </div>
1400
+ <div class="card">
1401
+ <h2>On the server <span class="dim mono" style="font-weight:400">${esc(P.root)}</span></h2>
1402
+ <div class="body">${rows([
1403
+ ["sites", `<span class="mono">${esc(P.sites)}/&lt;site&gt;/</span> <span class="dim">one folder per site: releases, compose file, .env</span>`],
1404
+ ["traefik", `<span class="mono">${esc(P.traefik)}/</span>`],
1405
+ ["history", `<span class="mono">${esc(P.journal)}</span> <span class="dim">every change with its undo</span>`],
1406
+ ])}</div>
1407
+ </div>
1408
+ <div class="card">
1409
+ <h2>landfall config <span class="dim mono" style="font-weight:400">${esc(x.configFile)}</span></h2>
1410
+ <div class="body">${saved.length ? rows(saved) : `<p class="dim">Not registered — an ssh alias landfall reads from ~/.ssh/config.</p>`}</div>
1411
+ </div>
1412
+ <div class="card">
1413
+ <h2>SSH <span class="dim mono" style="font-weight:400">ssh -G ${esc(x.saved?.host ?? n)}</span></h2>
1414
+ <div class="body">${rows([
1415
+ ["login", `<span class="mono">${esc(`${x.ssh.user}@${x.ssh.hostname}:${x.ssh.port}`)}</span>`],
1416
+ ...x.ssh.keys.map((k) => ["key", `<span class="mono">${esc(k.file)}</span>${k.exists ? "" : ` <span class="badge bad">missing</span>`}`]),
1417
+ ])}
1418
+ ${x.ssh.block
1419
+ ? `<p class="dim">${esc(x.ssh.block.file)}:${x.ssh.block.line} · ${x.ssh.block.managed ? "written by landfall" : "your own"}</p><pre class="mono block">${esc(x.ssh.block.text)}</pre>`
1420
+ : `<p class="dim">No Host block; ssh uses its defaults.</p>`}</div>
1421
+ </div>`;
1422
+ }
1423
+
1424
+ function historyView(n) {
1425
+ const st = S.history[n] ?? { loading: true };
1426
+ if (st.loading) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
1427
+ if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
1428
+ const runs = st.data;
1429
+ if (!runs.length) return `<div class="card"><div class="body empty"><h2>Nothing yet</h2><p>Every change landfall makes on ${esc(n)} is logged here, with a way to undo it.</p></div></div>`;
1430
+ const s = sv(n);
1431
+ const undo = (argv) => run(["undo", ...argv, ...s, "-y"], { server: n });
1432
+ const total = runs.reduce((a, r) => a + r.pending, 0);
1433
+ const items = runs
1434
+ .map((r) => {
1435
+ const todo = r.steps.filter((x) => x.undo && !x.undone);
1436
+ const later = runs.filter((x) => x.n > r.n && x.pending);
1437
+ const undoRun = () =>
1438
+ dialog({
1439
+ title: `Undo #${r.n} ${r.cmd} on ${n}?`,
1440
+ text: [...todo.map((x) => `↺ ${x.what}`).reverse(), ...(later.length ? ["", `Later runs build on it and stay: ${later.map((x) => `#${x.n}`).join(", ")}`] : [])].join("\n"),
1441
+ ok: `Revert ${plural(todo.length, "change")}`,
1442
+ danger: true,
1443
+ onOk: () => undo([String(r.n)]),
1444
+ });
1445
+ const steps = r.steps
1446
+ .map((x, i) => {
1447
+ const [cls, ico] = x.reverts ? ["fixed", "↩"] : x.undone ? ["undone", "✓"] : x.undo ? ["", "↺"] : ["fixed", "·"];
1448
+ const one = x.undo && !x.undone
1449
+ ? btn("undo", () => confirmDlg({ title: `Undo step ${r.n}.${i + 1}?`, text: x.what, ok: "Undo it", danger: true, onOk: () => undo([`${r.n}.${i + 1}`]) }), "ghost sm")
1450
+ : "";
1451
+ return `<li class="${cls}"><span class="ico">${ico}</span><span class="what">${esc(x.what)}</span>${!x.undo && !x.reverts ? `<span class="faint">(not reversible)</span>` : ""}${x.undone ? `<span class="faint">(undone)</span>` : ""}${one}</li>`;
1452
+ })
1453
+ .join("");
1454
+ return `<div class="run">
1455
+ <div class="top"><b>#${r.n}</b><span class="cmd">${esc(r.cmd || "?")}</span><span class="dim">${esc(ago(r.time))}</span>
1456
+ <span class="faint mono hide-sm">${esc(r.actor)} · ${esc(r.id)}</span>
1457
+ <span class="actions">${r.pending ? btn(`Undo ${plural(r.pending, "change")}`, undoRun, "sm") : `<span class="faint">nothing to undo</span>`}</span></div>
1458
+ <ul class="steps">${steps}</ul></div>`;
1459
+ })
1460
+ .join("");
1461
+ return `
1462
+ <div class="card">
1463
+ <h2>Journal <span class="dim" style="font-weight:400">/opt/landfall/journal.log · newest first</span>
1464
+ <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>
1465
+ ${items}
1466
+ </div>`;
1467
+ }
1468
+
1469
+ // ---- top level -----------------------------------------------------------
1470
+
1471
+ function render() {
1472
+ if (!S.state) return;
1473
+ // Every render re-registers the buttons it draws (dialogs wire their own buttons).
1474
+ handlers = new Map();
1475
+ const scroll = $("#main").scrollTop;
1476
+ renderHeader();
1477
+ renderNav();
1478
+ renderMain();
1479
+ renderJob();
1480
+ $("#main").scrollTop = scroll;
1481
+ }
1482
+
1483
+ document.addEventListener("keydown", (e) => {
1484
+ if (e.key === "Escape" && !$("#dlg").open && S.job) {
1485
+ S.job = null;
1486
+ render();
1487
+ }
1488
+ });
1489
+
1490
+ (async () => {
1491
+ try {
1492
+ await refreshState();
1493
+ // Jobs started before a reload keep running on the server: list them, and show one still running.
1494
+ for (const info of S.state.jobs.slice().reverse()) {
1495
+ const j = { info, lines: [], actions: [] };
1496
+ S.jobs.set(info.id, j);
1497
+ if (info.code === undefined) stream(j);
1498
+ else {
1499
+ const full = await http(`/api/jobs/${info.id}`);
1500
+ j.lines = full.lines;
1501
+ }
1502
+ }
1503
+ S.job = S.state.jobs.find((j) => j.code === undefined)?.id ?? null;
1504
+ route();
1505
+ } catch (e) {
1506
+ $("#main").innerHTML = `<div class="empty"><h2>lf ui</h2><p>${esc(e.message)}</p></div>`;
1507
+ }
1508
+ })();
1509
+ </script>
1510
+ </body>
1511
+ </html>