landfall 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/cli.mjs +191 -205
- package/dist/index.html +309 -141
- package/dist/server-BrAinE0R.mjs +5 -0
- package/package.json +1 -4
- package/dist/job-DOi_DnYQ.mjs +0 -2
- package/dist/server-Cj4TXzYj.mjs +0 -4
- package/dist/tui-0-Gcinq3.mjs +0 -8
package/dist/index.html
CHANGED
|
@@ -7,16 +7,19 @@
|
|
|
7
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
8
|
<style>
|
|
9
9
|
:root {
|
|
10
|
-
|
|
11
|
-
--
|
|
10
|
+
/* Three text sizes and three corner radii; headings set their own. */
|
|
11
|
+
--fs-xs: 11.5px; --fs-sm: 12.5px; --fs: 13px;
|
|
12
|
+
--r-sm: 6px; --r: 8px; --r-lg: 12px;
|
|
13
|
+
--bg: #f6f7f9; --panel: #fff; --line: #e3e6eb; --line-strong: #c4c9d2; --text: #1b1f24; --dim: #5f6774; --faint: #6e7582;
|
|
14
|
+
--accent: #1d4ed8; --accent-bg: #e8effd; --accent-solid: var(--accent); --bad-solid: var(--bad); --ok: #13733a; --ok-bg: #e5f5ea; --warn: #b45309; --warn-bg: #fdf3e2;
|
|
12
15
|
--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
16
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
14
17
|
color-scheme: light;
|
|
15
18
|
}
|
|
16
19
|
@media (prefers-color-scheme: dark) {
|
|
17
20
|
:root {
|
|
18
|
-
--bg: #0e1116; --panel: #161a21; --line: #262c36; --text: #e4e7ec; --dim: #8b93a1; --faint: #
|
|
19
|
-
--accent: #5b8cff; --accent-bg: #1a2540; --ok: #4ade80; --ok-bg: #12291b; --warn: #f5b34a; --warn-bg: #2d2412;
|
|
21
|
+
--bg: #0e1116; --panel: #161a21; --line: #262c36; --line-strong: #3a4250; --text: #e4e7ec; --dim: #8b93a1; --faint: #7d8594;
|
|
22
|
+
--accent: #5b8cff; --accent-bg: #1a2540; --accent-solid: #3366dd; --bad-solid: #cc3333; --ok: #4ade80; --ok-bg: #12291b; --warn: #f5b34a; --warn-bg: #2d2412;
|
|
20
23
|
--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
24
|
color-scheme: dark;
|
|
22
25
|
}
|
|
@@ -26,52 +29,54 @@ html, body { margin: 0; height: 100%; }
|
|
|
26
29
|
body { background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif; }
|
|
27
30
|
a { color: var(--accent); text-decoration: none; }
|
|
28
31
|
a:hover { text-decoration: underline; }
|
|
29
|
-
code, .mono { font-family: var(--mono); font-size:
|
|
32
|
+
code, .mono { font-family: var(--mono); font-size: var(--fs-sm); }
|
|
30
33
|
button {
|
|
31
|
-
font: inherit; font-size:
|
|
32
|
-
padding: 5px 11px; border-radius:
|
|
34
|
+
font: inherit; font-size: var(--fs); border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
|
35
|
+
padding: 5px 11px; border-radius: var(--r-sm); cursor: pointer; white-space: nowrap;
|
|
33
36
|
}
|
|
34
37
|
button:hover { border-color: var(--faint); }
|
|
35
38
|
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
36
39
|
button.primary:hover { filter: brightness(1.08); }
|
|
37
40
|
button:disabled { opacity: .5; cursor: default; }
|
|
38
|
-
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
41
|
+
button.primary { background: var(--accent-solid); border-color: var(--accent-solid); color: #fff; }
|
|
39
42
|
button.danger { color: var(--bad); }
|
|
40
|
-
button.danger.solid { background: var(--bad); border-color: var(--bad); color: #fff; }
|
|
43
|
+
button.danger.solid { background: var(--bad-solid); border-color: var(--bad-solid); color: #fff; }
|
|
44
|
+
button.danger.solid:hover { filter: brightness(1.08); }
|
|
41
45
|
button.ghost { border-color: transparent; background: transparent; color: var(--dim); }
|
|
42
46
|
button.ghost:hover { color: var(--text); background: var(--bg); }
|
|
43
|
-
button.sm { padding: 2px 8px; font-size:
|
|
47
|
+
button.sm { padding: 2px 8px; font-size: var(--fs-sm); }
|
|
44
48
|
input, select, textarea {
|
|
45
|
-
font: inherit; color: var(--text); background: var(--
|
|
49
|
+
font: inherit; color: var(--text); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--r-sm); padding: 6px 9px; width: 100%;
|
|
46
50
|
}
|
|
47
51
|
input.secret { -webkit-text-security: disc; text-security: disc; }
|
|
48
|
-
input:focus, select:focus, textarea:focus { outline:
|
|
49
|
-
textarea { font-family: var(--mono); font-size:
|
|
52
|
+
input:focus, select:focus, textarea:focus { outline: 3px solid color-mix(in srgb, var(--accent) 30%, transparent); outline-offset: 0; border-color: var(--accent); }
|
|
53
|
+
textarea { font-family: var(--mono); font-size: var(--fs-sm); min-height: 90px; }
|
|
50
54
|
|
|
51
55
|
.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)
|
|
56
|
+
.app.with-job { grid-template-columns: 250px minmax(0, 1fr) clamp(360px, 32vw, 520px); }
|
|
53
57
|
header { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
54
58
|
.brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 8px; letter-spacing: -.01em; color: var(--text); }
|
|
55
59
|
.brand:hover { text-decoration: none; }
|
|
56
60
|
.brand svg { width: 22px; height: 22px; flex: none; }
|
|
57
|
-
.brand small { font-weight: 400; color: var(--faint); font-size:
|
|
58
|
-
.project { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--dim); font-size:
|
|
61
|
+
.brand small { font-weight: 400; color: var(--faint); font-size: var(--fs-sm); letter-spacing: 0; }
|
|
62
|
+
.project { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: var(--fs); min-width: 0; }
|
|
59
63
|
.project .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 36vw; }
|
|
60
64
|
nav.servers { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; display: flex; flex-direction: column; }
|
|
61
65
|
.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:
|
|
66
|
+
.nav-top input { padding: 5px 9px; font-size: var(--fs); }
|
|
63
67
|
.nav-top button { flex: none; }
|
|
64
68
|
#navlist { padding: 0 8px 12px; }
|
|
65
|
-
nav h3 { margin: 10px 8px 4px; font-size:
|
|
69
|
+
nav h3 { margin: 10px 8px 4px; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; display: flex; justify-content: space-between; }
|
|
66
70
|
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:
|
|
71
|
+
.srv { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 6px 10px; border-radius: var(--r-sm); }
|
|
68
72
|
.srv:hover { background: var(--bg); border-color: transparent; }
|
|
69
73
|
.srv.on { background: var(--accent-bg); }
|
|
70
74
|
.srv.on .n { color: var(--accent); }
|
|
75
|
+
.srv.on .t { color: var(--text); }
|
|
71
76
|
.srv .n { font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
|
72
|
-
.srv .t { color: var(--dim); font-size:
|
|
73
|
-
.nav-none { color: var(--faint); font-size:
|
|
74
|
-
.star { color: var(--warn); font-size:
|
|
77
|
+
.srv .t { color: var(--dim); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
78
|
+
.nav-none { color: var(--faint); font-size: var(--fs-sm); padding: 6px 10px; }
|
|
79
|
+
.star { color: var(--warn); font-size: var(--fs-xs); }
|
|
75
80
|
main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: inline-size; }
|
|
76
81
|
.show-sm { display: none !important; }
|
|
77
82
|
/* By main's own width, so an open job panel squeezes the tables like a small screen does. */
|
|
@@ -83,40 +88,42 @@ main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: in
|
|
|
83
88
|
.empty { color: var(--dim); text-align: center; padding: 60px 20px; }
|
|
84
89
|
.empty h2 { color: var(--text); font-size: 18px; margin: 0 0 6px; }
|
|
85
90
|
.head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 20px; margin-bottom: 16px; }
|
|
91
|
+
.head > div:first-child { flex: 1 1 320px; min-width: 0; }
|
|
86
92
|
.head h1 { margin: 0; font-size: 22px; }
|
|
87
|
-
.head .sub { color: var(--dim); font-size:
|
|
93
|
+
.head .sub { color: var(--dim); font-size: var(--fs); margin-top: 2px; }
|
|
88
94
|
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
|
|
89
95
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
|
|
90
96
|
.tabs::-webkit-scrollbar { display: none; }
|
|
91
97
|
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 12px; color: var(--dim); flex: none; }
|
|
92
98
|
.tabs button:hover { color: var(--text); }
|
|
93
99
|
.tabs button.on { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
|
|
94
|
-
.card { background: var(--panel); border: 1px solid var(--line); border-radius:
|
|
100
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); }
|
|
95
101
|
.card + .card { margin-top: 16px; }
|
|
96
102
|
.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; }
|
|
97
103
|
.card > h2 .actions { font-weight: 400; }
|
|
98
104
|
.card > h2 .sub { font-weight: 400; color: var(--dim); }
|
|
99
|
-
.card > .lede { padding: 10px 16px; color: var(--dim); font-size:
|
|
105
|
+
.card > .lede { padding: 10px 16px; color: var(--dim); font-size: var(--fs); border-bottom: 1px solid var(--line); }
|
|
100
106
|
|
|
101
107
|
/* "⋯" menus: one floating list at a time, placed by openMenu(). */
|
|
102
108
|
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:
|
|
104
|
-
.menu button { border: 0; background: none; text-align: left; padding: 6px 10px; border-radius:
|
|
105
|
-
.menu button:hover
|
|
109
|
+
.menu { position: fixed; z-index: 30; min-width: 180px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: 4px; display: grid; }
|
|
110
|
+
.menu button { border: 0; background: none; text-align: left; padding: 6px 10px; border-radius: var(--r-sm); display: flex; justify-content: space-between; gap: 16px; }
|
|
111
|
+
.menu button:hover { background: var(--bg); }
|
|
112
|
+
.menu button:focus-visible { background: var(--accent-bg); outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
106
113
|
.menu button.danger:hover { background: var(--bad-bg); }
|
|
107
|
-
.menu button .k { color: var(--faint); font-family: var(--mono); font-size:
|
|
114
|
+
.menu button .k { color: var(--faint); font-family: var(--mono); font-size: var(--fs-xs); }
|
|
108
115
|
.menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
|
|
109
116
|
|
|
110
117
|
.home { max-width: 860px; margin: 0 auto; padding-top: 20px; }
|
|
111
118
|
.home h1 { margin: 0 0 4px; font-size: 22px; }
|
|
112
119
|
.home > p { color: var(--dim); margin: 0 0 20px; }
|
|
113
120
|
.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:
|
|
121
|
+
.tile { display: block; text-align: left; padding: 14px 16px; border-radius: var(--r-lg); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); width: 100%; }
|
|
115
122
|
.tile:hover { border-color: var(--accent); }
|
|
116
123
|
.tile .n { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 6px; }
|
|
117
|
-
.tile .t { color: var(--dim); font-size:
|
|
124
|
+
.tile .t { color: var(--dim); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
118
125
|
.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:
|
|
126
|
+
.cli { margin-top: 28px; padding: 14px 16px; border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--panel); font-size: var(--fs); color: var(--dim); }
|
|
120
127
|
.cli pre { margin: 8px 0 0; font: 12.5px/1.6 var(--mono); color: var(--text); white-space: pre-wrap; }
|
|
121
128
|
.cli pre span { color: var(--faint); }
|
|
122
129
|
|
|
@@ -128,7 +135,7 @@ button.more { padding-inline: 7px; letter-spacing: .05em; }
|
|
|
128
135
|
td .chips.show-sm { margin-top: 4px; }
|
|
129
136
|
.card .body { padding: 12px 16px; }
|
|
130
137
|
table { border-collapse: collapse; width: 100%; }
|
|
131
|
-
th { text-align: left; font-weight: 600; font-size:
|
|
138
|
+
th { text-align: left; font-weight: 600; font-size: var(--fs-xs); color: var(--faint); text-transform: uppercase; letter-spacing: .04em; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
|
132
139
|
td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
|
133
140
|
tr:last-child td { border-bottom: 0; }
|
|
134
141
|
tr.click { cursor: pointer; }
|
|
@@ -139,18 +146,18 @@ td.acts button { margin-left: 4px; }
|
|
|
139
146
|
.scroll { overflow-x: auto; }
|
|
140
147
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); margin-right: 6px; vertical-align: 1px; }
|
|
141
148
|
.dot.up { background: var(--ok); } .dot.down { background: var(--bad); } .dot.warn { background: var(--warn); }
|
|
142
|
-
.badge { display: inline-block; font-size:
|
|
149
|
+
.badge { display: inline-block; font-size: var(--fs-xs); padding: 1px 7px; border-radius: 99px; background: var(--bg); color: var(--dim); border: 1px solid var(--line); }
|
|
143
150
|
.badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
|
|
144
151
|
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
|
|
145
152
|
.badge.bad { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
|
|
146
153
|
.badge.accent { background: var(--accent-bg); color: var(--accent); border-color: transparent; }
|
|
147
154
|
.dim { color: var(--dim); } .faint { color: var(--faint); }
|
|
148
|
-
.note { padding: 10px 14px; border-radius:
|
|
155
|
+
.note { padding: 10px 14px; border-radius: var(--r); background: var(--warn-bg); color: var(--warn); margin-bottom: 14px; }
|
|
149
156
|
.note.bad { background: var(--bad-bg); color: var(--bad); }
|
|
150
157
|
.note.info { background: var(--accent-bg); color: var(--accent); }
|
|
151
|
-
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size:
|
|
158
|
+
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: var(--fs); margin: 0; }
|
|
152
159
|
.kv dt { color: var(--dim); } .kv dd { margin: 0; }
|
|
153
|
-
pre.block { background: var(--bg); border: 1px solid var(--line); padding: 10px 12px; border-radius:
|
|
160
|
+
pre.block { background: var(--bg); border: 1px solid var(--line); padding: 10px 12px; border-radius: var(--r-sm); overflow-x: auto; font-size: var(--fs-sm); margin: 6px 0 0; }
|
|
154
161
|
.detail td { background: var(--bg); }
|
|
155
162
|
.releases, .services { margin: 0; padding: 0; list-style: none; }
|
|
156
163
|
.services li { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
|
|
@@ -158,70 +165,78 @@ pre.block { background: var(--bg); border: 1px solid var(--line); padding: 10px
|
|
|
158
165
|
.run { padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
159
166
|
.run:last-child { border-bottom: 0; }
|
|
160
167
|
.run .top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
161
|
-
.run .cmd { font-family: var(--mono); font-weight: 600; font-size:
|
|
168
|
+
.run .cmd { font-family: var(--mono); font-weight: 600; font-size: var(--fs); }
|
|
162
169
|
.steps { list-style: none; margin: 8px 0 0; padding: 0 0 0 4px; }
|
|
163
|
-
.steps li { display: flex; gap: 8px; align-items: baseline; padding: 2px 0; font-size:
|
|
170
|
+
.steps li { display: flex; gap: 8px; align-items: baseline; padding: 2px 0; font-size: var(--fs); }
|
|
164
171
|
.steps li .ico { width: 16px; text-align: center; flex: none; }
|
|
165
172
|
.steps li.undone, .steps li.fixed { color: var(--faint); }
|
|
166
173
|
.steps li.undone .what { text-decoration: line-through; }
|
|
167
174
|
.steps li button { opacity: 0; }
|
|
168
|
-
.steps li:hover button { opacity: 1; }
|
|
175
|
+
.steps li:hover button, .steps li:focus-within button { opacity: 1; }
|
|
169
176
|
.form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: end; }
|
|
170
177
|
.form-row label { flex: 1 1 140px; }
|
|
171
|
-
label { display: block; font-size:
|
|
178
|
+
label { display: block; font-size: var(--fs-sm); color: var(--dim); }
|
|
172
179
|
label > input, label > select, label > textarea { margin-top: 4px; }
|
|
173
|
-
.check { display: flex; gap: 8px; align-items: flex-start; color: var(--text); font-size:
|
|
180
|
+
.check { display: flex; gap: 8px; align-items: flex-start; color: var(--text); font-size: var(--fs); }
|
|
174
181
|
.check input { width: auto; margin-top: 3px; }
|
|
175
|
-
.hint { font-size:
|
|
182
|
+
.hint { font-size: var(--fs-sm); color: var(--faint); margin-top: 3px; }
|
|
176
183
|
.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; }
|
|
177
184
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
185
|
+
@media (prefers-reduced-motion: reduce) { .spin { animation-duration: 3s; } }
|
|
178
186
|
.loading { color: var(--dim); padding: 30px 0; text-align: center; }
|
|
179
187
|
|
|
180
188
|
[hidden] { display: none !important; }
|
|
181
189
|
aside.job { border-left: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; min-width: 0; }
|
|
182
190
|
.job-head { padding: 10px 12px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
|
|
183
|
-
.job-head .title { font-family: var(--mono); font-size:
|
|
191
|
+
.job-head .title { font-family: var(--mono); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
184
192
|
.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; }
|
|
185
193
|
.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; }
|
|
186
194
|
.job-foot { padding: 10px 12px; border-top: 1px solid var(--line); display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
187
|
-
.job-foot .status { margin-right: auto; font-size:
|
|
195
|
+
.job-foot .status { margin-right: auto; font-size: var(--fs); }
|
|
188
196
|
.joblist { border-top: 1px solid var(--line); max-height: 28%; overflow: auto; }
|
|
189
|
-
.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:
|
|
197
|
+
.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: var(--fs-sm); color: var(--dim); }
|
|
190
198
|
.joblist button span:last-child { overflow: hidden; text-overflow: ellipsis; }
|
|
191
|
-
.joblist button.on { background: var(--accent-bg); color: var(--text); }
|
|
199
|
+
.joblist button.on { background: var(--accent-bg); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); }
|
|
200
|
+
.joblist .ok { color: var(--ok); } .joblist .bad { color: var(--bad); }
|
|
192
201
|
|
|
193
|
-
dialog { border: 1px solid var(--line); border-radius:
|
|
202
|
+
dialog { border: 1px solid var(--line); border-radius: var(--r-lg); padding: 0; background: var(--panel); color: var(--text); box-shadow: var(--shadow); width: min(520px, calc(100vw - 32px)); }
|
|
194
203
|
dialog::backdrop { background: rgb(0 0 0 / .35); }
|
|
195
204
|
dialog form { padding: 18px 20px; display: grid; gap: 12px; }
|
|
196
205
|
dialog h2 { margin: 0; font-size: 16px; }
|
|
197
|
-
dialog .text { color: var(--dim); white-space: pre-wrap; font-size:
|
|
206
|
+
dialog .text { color: var(--dim); white-space: pre-wrap; font-size: var(--fs); }
|
|
198
207
|
dialog .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
|
199
|
-
fieldset { border: 1px solid var(--line); border-radius:
|
|
200
|
-
legend { font-size:
|
|
201
|
-
.toast { position: fixed;
|
|
208
|
+
fieldset { border: 1px solid var(--line); border-radius: var(--r); padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
|
|
209
|
+
legend { font-size: var(--fs-sm); font-weight: 600; padding: 0 4px; }
|
|
210
|
+
.toast { position: fixed; inset: auto auto 16px 50%; transform: translateX(-50%); margin: 0; border: 0; background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: var(--r); font-size: var(--fs); max-width: min(560px, calc(100vw - 32px)); }
|
|
211
|
+
.toast button { margin-left: 10px; background: none; border-color: color-mix(in srgb, var(--bg) 40%, transparent); color: inherit; }
|
|
212
|
+
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
213
|
+
.row-toggle { border: 0; background: none; padding: 2px 4px; margin-left: -4px; display: inline-flex; align-items: center; gap: 4px; color: inherit; font-weight: 600; }
|
|
214
|
+
.row-toggle:hover { background: none; text-decoration: underline; }
|
|
215
|
+
.jobs-btn .spin { width: 10px; height: 10px; margin-right: 4px; }
|
|
216
|
+
.dlg-err { margin: 0; }
|
|
202
217
|
.server-pick { display: none; }
|
|
203
|
-
.facts { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--dim); font-size:
|
|
218
|
+
.facts { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--dim); font-size: var(--fs-sm); margin-top: 4px; }
|
|
204
219
|
.facts b { color: var(--text); font-weight: 500; }
|
|
205
220
|
.chips { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
|
|
206
221
|
.chip { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; }
|
|
207
222
|
.chip a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
208
|
-
.chip button { padding: 0 5px; font-size:
|
|
209
|
-
.chip button:
|
|
210
|
-
.lock { font-size:
|
|
223
|
+
.chip button { padding: 0 5px; font-size: var(--fs-sm); min-width: 24px; min-height: 24px; color: var(--faint); }
|
|
224
|
+
.chip:hover button, .chip button:focus-visible { color: var(--text); }
|
|
225
|
+
.lock { font-size: var(--fs-xs); margin-left: 4px; }
|
|
211
226
|
dialog.wide { width: min(680px, calc(100vw - 32px)); }
|
|
212
|
-
.list { display: grid; border: 1px solid var(--line); border-radius:
|
|
227
|
+
.list { display: grid; border: 1px solid var(--line); border-radius: var(--r); max-height: 260px; overflow: auto; }
|
|
213
228
|
.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; }
|
|
214
229
|
.list .row:last-child { border-bottom: 0; }
|
|
215
230
|
.list .row:hover { background: var(--bg); }
|
|
216
231
|
.list .row .mono { margin-left: auto; overflow: hidden; text-overflow: ellipsis; }
|
|
217
|
-
.crumbs { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; font-size:
|
|
232
|
+
.crumbs { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; font-size: var(--fs-sm); }
|
|
218
233
|
.crumbs button { padding: 1px 4px; }
|
|
219
|
-
.detected { font-size:
|
|
220
|
-
.drop { border: 1.5px dashed var(--line); border-radius:
|
|
221
|
-
.drop label { display: inline; color: var(--accent); cursor: pointer; font-size:
|
|
234
|
+
.detected { font-size: var(--fs); padding: 8px 12px; border-radius: var(--r); background: var(--bg); }
|
|
235
|
+
.drop { border: 1.5px dashed var(--line); border-radius: var(--r); padding: 12px; text-align: center; color: var(--dim); font-size: var(--fs); }
|
|
236
|
+
.drop label { display: inline; color: var(--accent); cursor: pointer; font-size: var(--fs); }
|
|
222
237
|
body.dragging::after {
|
|
223
238
|
content: "Drop a folder or .zip to deploy it"; position: fixed; inset: 12px; z-index: 20; display: grid; place-items: center;
|
|
224
|
-
border: 2px dashed var(--accent); border-radius:
|
|
239
|
+
border: 2px dashed var(--accent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--bg) 85%, transparent); color: var(--accent); font-size: 18px; font-weight: 600; pointer-events: none;
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
@media (max-width: 1100px) {
|
|
@@ -232,7 +247,7 @@ body.dragging::after {
|
|
|
232
247
|
.app, .app.with-job { grid-template-columns: minmax(0, 1fr); grid-template-rows: 52px auto 1fr; }
|
|
233
248
|
header { padding: 0 12px; gap: 8px; }
|
|
234
249
|
.brand small { display: none; }
|
|
235
|
-
.project button { padding: 5px 9px; font-size:
|
|
250
|
+
.project button { padding: 5px 9px; font-size: var(--fs); white-space: nowrap; }
|
|
236
251
|
nav.servers { display: none; }
|
|
237
252
|
.server-pick { display: block; padding: 10px 16px 0; }
|
|
238
253
|
main { padding: 14px 16px 40px; }
|
|
@@ -254,9 +269,11 @@ body.dragging::after {
|
|
|
254
269
|
</nav>
|
|
255
270
|
<div class="server-pick" id="pick"></div>
|
|
256
271
|
<main id="main"></main>
|
|
257
|
-
<aside class="job" id="job" hidden></aside>
|
|
272
|
+
<aside class="job" id="job" aria-label="Command output" hidden></aside>
|
|
258
273
|
</div>
|
|
259
|
-
<dialog id="dlg"></dialog>
|
|
274
|
+
<dialog id="dlg" aria-labelledby="dlg-title"></dialog>
|
|
275
|
+
<div class="toast" id="toast" role="status" popover="manual"></div>
|
|
276
|
+
<div class="sr" id="sr-live" role="status"></div>
|
|
260
277
|
<script>
|
|
261
278
|
"use strict";
|
|
262
279
|
const $ = (s, el = document) => el.querySelector(s);
|
|
@@ -299,13 +316,32 @@ async function http(path, body) {
|
|
|
299
316
|
return data;
|
|
300
317
|
}
|
|
301
318
|
|
|
319
|
+
// One live region in the top layer, so it shows (and is announced) above an open dialog too.
|
|
320
|
+
// action: {label, run} adds a button, e.g. to open the job a toast is about.
|
|
302
321
|
let toastTimer;
|
|
303
|
-
function toast(msg) {
|
|
304
|
-
|
|
305
|
-
if (!t) document.body.append((t = Object.assign(document.createElement("div"), { className: "toast" })));
|
|
322
|
+
function toast(msg, action) {
|
|
323
|
+
const t = $("#toast");
|
|
306
324
|
t.textContent = msg;
|
|
325
|
+
if (action) {
|
|
326
|
+
const b = Object.assign(document.createElement("button"), { type: "button", className: "sm", textContent: action.label });
|
|
327
|
+
b.onclick = () => (hideToast(), action.run());
|
|
328
|
+
t.append(b);
|
|
329
|
+
}
|
|
330
|
+
t.hidePopover?.();
|
|
331
|
+
t.showPopover?.();
|
|
307
332
|
clearTimeout(toastTimer);
|
|
308
|
-
toastTimer = setTimeout(()
|
|
333
|
+
toastTimer = setTimeout(hideToast, Math.max(4000, msg.length * 70) + (action ? 4000 : 0));
|
|
334
|
+
}
|
|
335
|
+
function hideToast() {
|
|
336
|
+
$("#toast").hidePopover?.();
|
|
337
|
+
}
|
|
338
|
+
$("#toast").onmouseenter = () => clearTimeout(toastTimer);
|
|
339
|
+
$("#toast").onmouseleave = () => (toastTimer = setTimeout(hideToast, 2000));
|
|
340
|
+
/** Tell screen readers about a change that has no visible text of its own. */
|
|
341
|
+
function say(text) {
|
|
342
|
+
const r = $("#sr-live");
|
|
343
|
+
r.textContent = "";
|
|
344
|
+
setTimeout(() => (r.textContent = text), 50);
|
|
309
345
|
}
|
|
310
346
|
|
|
311
347
|
// Buttons call functions registered per render: data-k="<id>".
|
|
@@ -316,15 +352,16 @@ function on(fn) {
|
|
|
316
352
|
handlers.set(k, fn);
|
|
317
353
|
return `data-k="${k}"`;
|
|
318
354
|
}
|
|
319
|
-
|
|
320
|
-
|
|
355
|
+
/** f: a key that stays the same across renders, so the button keeps its keyboard focus when redrawn. */
|
|
356
|
+
function btn(label, fn, cls = "", title = "", f = "") {
|
|
357
|
+
return `<button type="button" class="${cls}" ${on(fn)}${title ? ` title="${esc(title)}"` : ""}${f ? ` data-f="${esc(f)}"` : ""}>${label}</button>`;
|
|
321
358
|
}
|
|
322
359
|
document.addEventListener("click", (e) => {
|
|
323
360
|
const el = e.target.closest("[data-k]");
|
|
324
361
|
const menu = $("#menu");
|
|
325
362
|
// A click outside an open menu only closes it.
|
|
326
363
|
if (menu && !e.target.closest("#menu")) {
|
|
327
|
-
closeMenu();
|
|
364
|
+
closeMenu(false);
|
|
328
365
|
e.preventDefault();
|
|
329
366
|
return void e.stopPropagation();
|
|
330
367
|
}
|
|
@@ -344,14 +381,17 @@ document.addEventListener("click", (e) => {
|
|
|
344
381
|
function menuBtn(items, title = "More actions", cls = "sm") {
|
|
345
382
|
items = items.filter(Boolean);
|
|
346
383
|
if (!items.length) return "";
|
|
347
|
-
return btn("⋯", (e, el) => openMenu(el, items), `${cls} more`, title);
|
|
384
|
+
return btn("⋯", (e, el) => openMenu(el, items), `${cls} more`, title).replace("<button", `<button aria-haspopup="menu" aria-expanded="false" aria-label="${esc(title)}"`);
|
|
348
385
|
}
|
|
386
|
+
let menuAnchor = null;
|
|
349
387
|
function openMenu(anchor, items) {
|
|
350
|
-
closeMenu();
|
|
388
|
+
closeMenu(false);
|
|
389
|
+
menuAnchor = anchor;
|
|
390
|
+
anchor.setAttribute("aria-expanded", "true");
|
|
351
391
|
const m = Object.assign(document.createElement("div"), { id: "menu", className: "menu" });
|
|
352
392
|
m.setAttribute("role", "menu");
|
|
353
393
|
m.innerHTML = items
|
|
354
|
-
.map((it, i) => it === "-" ?
|
|
394
|
+
.map((it, i) => it === "-" ? `<hr role="separator">` : `<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
395
|
.join("");
|
|
356
396
|
m.onclick = (e) => {
|
|
357
397
|
const b = e.target.closest("button[data-i]");
|
|
@@ -365,19 +405,35 @@ function openMenu(anchor, items) {
|
|
|
365
405
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
366
406
|
e.preventDefault();
|
|
367
407
|
all[(i + (e.key === "ArrowDown" ? 1 : all.length - 1)) % all.length].focus();
|
|
408
|
+
} else if (e.key === "Home" || e.key === "End") {
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
all[e.key === "Home" ? 0 : all.length - 1].focus();
|
|
368
411
|
}
|
|
369
412
|
};
|
|
413
|
+
// Keep focus where it is on mousedown: a browser that does not focus clicked buttons would blur the menu and close it before the click.
|
|
414
|
+
m.onmousedown = (e) => e.preventDefault();
|
|
415
|
+
m.addEventListener("focusout", (e) => {
|
|
416
|
+
if (!m.contains(e.relatedTarget)) closeMenu(false);
|
|
417
|
+
});
|
|
370
418
|
document.body.append(m);
|
|
371
419
|
const r = anchor.getBoundingClientRect();
|
|
372
420
|
m.style.left = `${Math.max(8, Math.min(r.right - m.offsetWidth, innerWidth - m.offsetWidth - 8))}px`;
|
|
373
421
|
m.style.top = `${r.bottom + 4 + m.offsetHeight > innerHeight - 8 ? Math.max(8, r.top - m.offsetHeight - 4) : r.bottom + 4}px`;
|
|
374
422
|
$("button", m)?.focus({ preventScroll: true });
|
|
375
423
|
}
|
|
376
|
-
|
|
377
|
-
|
|
424
|
+
/** restore: focus goes back to the "⋯" button (Esc, or an item that opens no dialog). */
|
|
425
|
+
function closeMenu(restore = true) {
|
|
426
|
+
const m = $("#menu");
|
|
427
|
+
if (!m) return;
|
|
428
|
+
// Removing the focused item fires focusout, which calls closeMenu again: drop the id first so that call finds nothing.
|
|
429
|
+
m.removeAttribute("id");
|
|
430
|
+
m.remove();
|
|
431
|
+
menuAnchor?.setAttribute("aria-expanded", "false");
|
|
432
|
+
if (restore && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true });
|
|
433
|
+
menuAnchor = null;
|
|
378
434
|
}
|
|
379
|
-
addEventListener("resize", closeMenu);
|
|
380
|
-
document.addEventListener("scroll", closeMenu, true);
|
|
435
|
+
addEventListener("resize", () => closeMenu(false));
|
|
436
|
+
document.addEventListener("scroll", (e) => e.target.closest?.("#menu") || closeMenu(false), true);
|
|
381
437
|
|
|
382
438
|
// ---- routing: #/<server>/<tab> ---------------------------------------------
|
|
383
439
|
|
|
@@ -390,7 +446,7 @@ function route() {
|
|
|
390
446
|
const [, name, tab] = location.hash.split("/");
|
|
391
447
|
S.sel = name ? decodeURIComponent(name) : null;
|
|
392
448
|
S.tab = tab in STORE ? tab : "sites";
|
|
393
|
-
if (S.sel) load(S.sel, S.tab);
|
|
449
|
+
if (S.sel && server()) load(S.sel, S.tab);
|
|
394
450
|
render();
|
|
395
451
|
}
|
|
396
452
|
window.addEventListener("hashchange", route);
|
|
@@ -400,13 +456,14 @@ window.addEventListener("hashchange", route);
|
|
|
400
456
|
const STORE = { sites: "overview", facts: "facts", updates: "updates", firewall: "firewall", history: "history", settings: "settings" };
|
|
401
457
|
const URLS = { sites: "", facts: "/facts", updates: "/updates", firewall: "/firewall", history: "/history", settings: "/config" };
|
|
402
458
|
|
|
459
|
+
// The overview and facts drive the header, so every tab needs them.
|
|
460
|
+
const needs = (tab) => (tab === "sites" ? ["sites", "facts"] : ["sites", "facts", tab]);
|
|
403
461
|
function load(name, tab, force = false) {
|
|
404
|
-
|
|
405
|
-
const needed = tab === "sites" ? ["sites", "facts"] : ["sites", "facts", tab];
|
|
406
|
-
for (const t of needed) {
|
|
462
|
+
for (const t of needs(tab)) {
|
|
407
463
|
const store = S[STORE[t]];
|
|
408
464
|
if (store[name] && !force) continue;
|
|
409
|
-
|
|
465
|
+
// Keep what is shown while it reloads; views show a spinner only when they have nothing yet.
|
|
466
|
+
store[name] = { ...store[name], loading: true, error: undefined };
|
|
410
467
|
http(`/api/servers/${encodeURIComponent(name)}${URLS[t]}`).then(
|
|
411
468
|
(data) => ((store[name] = { data }), render()),
|
|
412
469
|
(e) => ((store[name] = { error: e.message }), render()),
|
|
@@ -414,8 +471,11 @@ function load(name, tab, force = false) {
|
|
|
414
471
|
}
|
|
415
472
|
}
|
|
416
473
|
function invalidate(name) {
|
|
417
|
-
|
|
418
|
-
|
|
474
|
+
delete S.dns[name];
|
|
475
|
+
// What is on screen reloads in place; other tabs reload when opened.
|
|
476
|
+
const shown = new Set(S.sel === name ? needs(S.tab).map((t) => STORE[t]) : []);
|
|
477
|
+
for (const k of Object.values(STORE)) if (!shown.has(k)) delete S[k][name];
|
|
478
|
+
if (S.sel === name) load(name, S.tab, true);
|
|
419
479
|
}
|
|
420
480
|
async function refreshState() {
|
|
421
481
|
S.state = await http("/api/state");
|
|
@@ -440,6 +500,7 @@ async function run(argv, o = {}) {
|
|
|
440
500
|
S.job = info.id;
|
|
441
501
|
stream(j);
|
|
442
502
|
render();
|
|
503
|
+
say(`Running lf ${argv.join(" ")}`);
|
|
443
504
|
}
|
|
444
505
|
|
|
445
506
|
function stream(j) {
|
|
@@ -469,7 +530,12 @@ function finished(j) {
|
|
|
469
530
|
j.actions = (j.after && j.after(j)) || [];
|
|
470
531
|
// Safety checks refuse what would lock out ssh or break running services; --force overrides after reading why.
|
|
471
532
|
if (failed && !info.argv.includes("--force") && j.lines.some((l) => l.includes("--force"))) {
|
|
472
|
-
j.
|
|
533
|
+
const why = j.lines.map((l) => l.replace(ANSI, "").trim()).filter(Boolean).slice(-4).join("\n");
|
|
534
|
+
j.actions.push({
|
|
535
|
+
label: "Do it anyway (--force)…",
|
|
536
|
+
cls: "danger",
|
|
537
|
+
run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server }) }),
|
|
538
|
+
});
|
|
473
539
|
}
|
|
474
540
|
if (info.undoable && info.server && info.code !== null) {
|
|
475
541
|
j.actions.push({
|
|
@@ -486,7 +552,17 @@ function finished(j) {
|
|
|
486
552
|
if (info.server) invalidate(info.server);
|
|
487
553
|
refreshState().catch(() => {});
|
|
488
554
|
render();
|
|
555
|
+
const what = `lf ${info.argv.join(" ")} ${info.code === 0 ? "done" : info.code === null ? "stopped" : `failed (exit ${info.code})`}`;
|
|
556
|
+
// Closed or showing another job: say so, with a way back to it.
|
|
557
|
+
if (S.job !== info.id) toast(`${info.code === 0 ? "✓" : "✗"} ${what}`, { label: "Show", run: () => showJob(info.id) });
|
|
558
|
+
else say(what);
|
|
559
|
+
}
|
|
560
|
+
function showJob(id) {
|
|
561
|
+
S.job = id;
|
|
562
|
+
render();
|
|
563
|
+
$("#job .job-out")?.focus({ preventScroll: true });
|
|
489
564
|
}
|
|
565
|
+
const runningJobs = () => [...S.jobs.values()].filter((j) => j.info.code === undefined);
|
|
490
566
|
|
|
491
567
|
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
492
568
|
function lineClass(l) {
|
|
@@ -532,33 +608,51 @@ function renderJob() {
|
|
|
532
608
|
? `<span style="color:var(--ok)">✓ done</span>`
|
|
533
609
|
: `<span style="color:var(--bad)">✗ failed (exit ${info.code})</span>`;
|
|
534
610
|
const others = [...S.jobs.values()].reverse();
|
|
611
|
+
const stop = () => {
|
|
612
|
+
const doStop = () => http(`/api/jobs/${info.id}/stop`, {}).catch((e) => toast(e.message));
|
|
613
|
+
if (!info.undoable) return doStop();
|
|
614
|
+
confirmDlg({ title: `Stop lf ${info.argv[0]}?`, text: "What it changed so far stays. Undo it from History.", ok: "Stop it", danger: true, onOk: doStop });
|
|
615
|
+
};
|
|
616
|
+
// The output element stays across renders: rebuilding it would lose the reader's scroll position.
|
|
617
|
+
let out = $(".job-out", el);
|
|
618
|
+
const fresh = !out || out.dataset.job !== info.id;
|
|
619
|
+
if (fresh) {
|
|
620
|
+
out = Object.assign(document.createElement("div"), { className: "job-out", tabIndex: 0 });
|
|
621
|
+
out.dataset.job = info.id;
|
|
622
|
+
out.setAttribute("role", "region");
|
|
623
|
+
out.setAttribute("aria-label", "Output");
|
|
624
|
+
}
|
|
535
625
|
el.innerHTML = `
|
|
536
626
|
<div class="job-head">
|
|
537
627
|
<span class="title" title="${esc("lf " + info.argv.join(" "))}">$ lf ${esc(info.argv.join(" "))}</span>
|
|
538
|
-
${btn("✕", () => ((S.job = null), render()), "ghost sm", "Close (Esc)")}
|
|
628
|
+
${btn("✕", () => ((S.job = null), render()), "ghost sm", "Close (Esc)", "job-close").replace("<button", `<button aria-label="Close output"`)}
|
|
539
629
|
</div>
|
|
540
|
-
<div class="job-out"></div>
|
|
630
|
+
<div class="job-out-slot"></div>
|
|
541
631
|
<div class="job-foot">
|
|
542
632
|
<span class="status">${status}</span>
|
|
543
|
-
${running ? btn("Stop",
|
|
544
|
-
${(running ? [] : j.actions).map((a, i) => btn(esc(a.label), a.run, a.cls ?? (i === 0 ? "primary" : ""))).join("")}
|
|
633
|
+
${running ? btn("Stop", stop, "danger", info.undoable ? "Changes made so far stay" : "", "job-stop") : ""}
|
|
634
|
+
${(running ? [] : j.actions).map((a, i) => btn(esc(a.label), a.run, a.cls ?? (i === 0 ? "primary" : ""), "", `job-act:${a.label}`)).join("")}
|
|
545
635
|
</div>
|
|
546
636
|
${others.length > 1 ? `<div class="joblist">${others
|
|
547
|
-
.map((o) =>
|
|
548
|
-
|
|
637
|
+
.map((o) => {
|
|
638
|
+
const [cls, ico] = o.info.code === undefined ? ["", "…"] : o.info.code === 0 ? ["ok", "✓"] : o.info.code === null ? ["", "■"] : ["bad", "✗"];
|
|
639
|
+
return `<button type="button" class="${o.info.id === S.job ? "on" : ""}" data-f="job:${o.info.id}" ${on(() => showJob(o.info.id))}>
|
|
640
|
+
<span class="${cls}">${ico}</span><span>lf ${esc(o.info.argv.join(" "))}</span></button>`;
|
|
641
|
+
})
|
|
549
642
|
.join("")}</div>` : ""}`;
|
|
643
|
+
$(".job-out-slot", el).replaceWith(out);
|
|
550
644
|
appendOutput(j);
|
|
551
|
-
|
|
552
|
-
out.scrollTop = out.scrollHeight;
|
|
645
|
+
if (fresh) out.scrollTop = out.scrollHeight;
|
|
553
646
|
}
|
|
554
647
|
|
|
555
648
|
// ---- dialogs -------------------------------------------------------------
|
|
556
649
|
|
|
557
650
|
/**
|
|
558
651
|
* A modal form. fields: [{name, label, value, type: text|password|textarea|select|check, options, hint, placeholder}].
|
|
559
|
-
* onOk(values) runs on submit; return false to keep it open.
|
|
652
|
+
* onOk(values) runs on submit; return false to keep it open, or {field, error} to keep it open and show why.
|
|
653
|
+
* typeToConfirm: a name the user types before the (dangerous) action is enabled.
|
|
560
654
|
*/
|
|
561
|
-
function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "" }) {
|
|
655
|
+
function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "", typeToConfirm = "" }) {
|
|
562
656
|
const dlg = openDlg();
|
|
563
657
|
const field = (f) => {
|
|
564
658
|
const id = `f-${f.name}`;
|
|
@@ -576,34 +670,54 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
|
|
|
576
670
|
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>`;
|
|
577
671
|
};
|
|
578
672
|
const group = (f) => (f.legend ? `<fieldset><legend>${esc(f.legend)}</legend>${f.fields.map(field).join("")}</fieldset>` : field(f));
|
|
579
|
-
dlg.innerHTML = `<form method="dialog">
|
|
580
|
-
<h2>${esc(title)}</h2>
|
|
673
|
+
dlg.innerHTML = `<form method="dialog" novalidate>
|
|
674
|
+
<h2 id="dlg-title">${esc(title)}</h2>
|
|
581
675
|
${text ? `<div class="text">${esc(text)}</div>` : ""}
|
|
582
676
|
${extra}
|
|
583
677
|
${fields.map(group).join("")}
|
|
678
|
+
${typeToConfirm ? `<label>Type <b class="mono">${esc(typeToConfirm)}</b> to confirm<input id="dlg-confirm" autocomplete="off" spellcheck="false" autocapitalize="off"></label>` : ""}
|
|
679
|
+
<div class="note bad dlg-err" id="dlg-err" role="alert" hidden></div>
|
|
584
680
|
<div class="btns">
|
|
585
681
|
<button type="button" value="cancel" class="ghost" id="dlg-cancel">Cancel</button>
|
|
586
682
|
<button type="submit" value="ok" class="${danger ? "danger solid" : "primary"}">${esc(ok)}</button>
|
|
587
683
|
</div>
|
|
588
684
|
</form>`;
|
|
589
685
|
const form = $("form", dlg);
|
|
686
|
+
const submit = $("button[type=submit]", form);
|
|
590
687
|
$("#dlg-cancel", dlg).onclick = () => dlg.close();
|
|
688
|
+
if (typeToConfirm) {
|
|
689
|
+
submit.disabled = true;
|
|
690
|
+
$("#dlg-confirm", dlg).oninput = (e) => (submit.disabled = e.target.value.trim() !== typeToConfirm);
|
|
691
|
+
}
|
|
692
|
+
form.oninput = (e) => e.target.removeAttribute?.("aria-invalid");
|
|
591
693
|
form.onsubmit = (e) => {
|
|
592
694
|
e.preventDefault();
|
|
695
|
+
if (submit.disabled) return;
|
|
593
696
|
const values = {};
|
|
594
697
|
for (const f of fields.flatMap((f) => f.fields ?? [f])) {
|
|
595
698
|
const el = form.elements[f.name];
|
|
596
699
|
values[f.name] = f.type === "check" ? el.checked : el.value;
|
|
597
700
|
}
|
|
598
|
-
|
|
701
|
+
const r = onOk(values);
|
|
702
|
+
if (r === false) return;
|
|
703
|
+
if (r?.error) {
|
|
704
|
+
const err = $("#dlg-err", dlg), el = r.field && form.elements[r.field];
|
|
705
|
+
err.textContent = r.error;
|
|
706
|
+
err.hidden = false;
|
|
707
|
+
el?.setAttribute("aria-invalid", "true");
|
|
708
|
+
el?.focus();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
599
711
|
// Empty before closing: browsers offer to save a password they still see when the form goes away.
|
|
600
712
|
for (const el of form.querySelectorAll(".secret")) el.value = "";
|
|
601
713
|
dlg.close();
|
|
602
714
|
};
|
|
603
715
|
dlg.showModal();
|
|
716
|
+
// A dangerous confirm starts on Cancel, so a stray Enter does not delete anything.
|
|
604
717
|
const first = $("input:not([type=checkbox]), textarea, select", form);
|
|
605
|
-
(first || $("
|
|
718
|
+
(first || (danger ? $("#dlg-cancel", dlg) : submit)).focus();
|
|
606
719
|
}
|
|
720
|
+
const invalid = (field, error) => ({ field, error });
|
|
607
721
|
/**
|
|
608
722
|
* Passwords here are for servers and sites, not for this page: a masked text field, so browsers
|
|
609
723
|
* and password managers do not offer to save them. Browsers without text masking get a password field.
|
|
@@ -621,7 +735,7 @@ function openDlg(cls = "") {
|
|
|
621
735
|
const confirmDlg = (o) => dialog({ ...o, onOk: () => o.onOk() });
|
|
622
736
|
const promptDlg = (title, text, field, onOk, ok = "OK") => dialog({ title, text, fields: [{ name: "v", ...field }], ok, onOk: (v) => onOk(v.v.trim()) });
|
|
623
737
|
|
|
624
|
-
// ---- server flows
|
|
738
|
+
// ---- server flows ----------------------------------------------------------
|
|
625
739
|
|
|
626
740
|
const sv = (n) => ["-s", n];
|
|
627
741
|
const server = () => S.state?.servers.find((s) => s.name === S.sel);
|
|
@@ -730,10 +844,10 @@ function addServer() {
|
|
|
730
844
|
ok: "Add",
|
|
731
845
|
onOk: (v) => {
|
|
732
846
|
const [name, host, user, port, key] = [v.name, v.host, v.user, v.port, v.key].map((x) => x.trim());
|
|
733
|
-
if (!name) return
|
|
847
|
+
if (!name) return invalid("name", "Give the server a name");
|
|
734
848
|
const known = S.state.servers.some((s) => s.name === name);
|
|
735
|
-
if (!known && !host) return
|
|
736
|
-
if (port && !/^\d+$/.test(port)) return
|
|
849
|
+
if (!known && !host) return invalid("host", `${name} is not in your ssh config: give its IP or hostname`);
|
|
850
|
+
if (port && !/^\d+$/.test(port)) return invalid("port", "The port is a number");
|
|
737
851
|
const args = [name, ...(host ? ["--host", host] : []), ...(user ? ["-u", user] : []), ...(port ? ["-p", port] : []), ...(key ? ["-i", key] : [])];
|
|
738
852
|
// With a password: install the key now, then the setup wizard needs nothing but the alias.
|
|
739
853
|
const then = (j) => (j.info.code === 0 ? void (v.setup ? setupWizard([name]) : go(name)) : undefined);
|
|
@@ -797,15 +911,23 @@ function deployDialog(det, o = {}) {
|
|
|
797
911
|
{ name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
|
|
798
912
|
]
|
|
799
913
|
: []),
|
|
914
|
+
{ name: "authUser", label: "Login user (optional)", placeholder: "admin", hint: "Visitors log in with this user and password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
|
|
915
|
+
{ name: "authPassword", label: "Login password (at least 8 characters)", type: "password" },
|
|
800
916
|
],
|
|
801
917
|
ok: "Deploy",
|
|
802
918
|
onOk: (v) => {
|
|
803
919
|
const n = v.name.trim();
|
|
804
|
-
if (!n
|
|
920
|
+
if (!n) return invalid("name", "Give the site a name");
|
|
921
|
+
if (!v.server) return invalid("server", "Pick a server");
|
|
805
922
|
const domains = v.domains.split(/[\s,]+/).filter(Boolean);
|
|
806
923
|
const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...domains.flatMap((d) => ["-d", d])];
|
|
807
924
|
if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
|
|
808
925
|
if (v.spa && !p?.spa) argv.push("--spa");
|
|
926
|
+
const user = v.authUser.trim();
|
|
927
|
+
if (user && !/^[A-Za-z0-9._@-]{1,64}$/.test(user)) return invalid("authUser", "Only letters, digits and . _ @ -");
|
|
928
|
+
if (user && v.authPassword.length < 8) return invalid("authPassword", "The password needs at least 8 characters");
|
|
929
|
+
if (!user && v.authPassword) return invalid("authUser", "Give a user for the password");
|
|
930
|
+
if (user) argv.push("--auth-user", user, "--auth-password", v.authPassword);
|
|
809
931
|
if (o.upload) argv.push("--no-save");
|
|
810
932
|
run(argv, { server: v.server, cwd: det.path, after: openAfter });
|
|
811
933
|
},
|
|
@@ -957,8 +1079,10 @@ function domainsDialog(n, m) {
|
|
|
957
1079
|
const domains = v.domains.split(/[\s,]+/).filter(Boolean);
|
|
958
1080
|
if (v.free) domains.push(free);
|
|
959
1081
|
if (v.apex) domains.push(main);
|
|
960
|
-
|
|
961
|
-
|
|
1082
|
+
// None left: the site keeps running, unrouted.
|
|
1083
|
+
const args = domains.length ? domains.flatMap((d) => ["--set", d]) : m.domains.flatMap((d) => ["--rm", d]);
|
|
1084
|
+
if (!args.length) return;
|
|
1085
|
+
run(["domains", m.name, ...sv(n), ...args, "-y"], { server: n });
|
|
962
1086
|
},
|
|
963
1087
|
});
|
|
964
1088
|
}
|
|
@@ -978,8 +1102,8 @@ function authDialog(n, m) {
|
|
|
978
1102
|
ok: "Save",
|
|
979
1103
|
onOk: (v) => {
|
|
980
1104
|
if (v.off) return void run(["auth", m.name, ...sv(n), "--off", "-y"], { server: n });
|
|
981
|
-
if (!v.user.trim()) return
|
|
982
|
-
if (v.password.length < 8) return
|
|
1105
|
+
if (!v.user.trim()) return invalid("user", "Give a user name");
|
|
1106
|
+
if (v.password.length < 8) return invalid("password", "The password needs at least 8 characters");
|
|
983
1107
|
run(["auth", m.name, ...sv(n), "-u", v.user.trim(), "-p", v.password], { server: n });
|
|
984
1108
|
},
|
|
985
1109
|
});
|
|
@@ -1021,6 +1145,7 @@ function forget(n) {
|
|
|
1021
1145
|
text: "Removes it from landfall (and the Host block landfall wrote to ~/.ssh/config).\nNothing on the server is changed.",
|
|
1022
1146
|
ok: `Forget ${n}`,
|
|
1023
1147
|
danger: true,
|
|
1148
|
+
typeToConfirm: n,
|
|
1024
1149
|
onOk: () => run(["server", "rm", n, "-y"], { server: n, after: (j) => (j.info.code === 0 ? void go(null) : undefined) }),
|
|
1025
1150
|
});
|
|
1026
1151
|
}
|
|
@@ -1044,7 +1169,7 @@ function renderNav() {
|
|
|
1044
1169
|
const aliases = list.filter((s) => !s.registered);
|
|
1045
1170
|
const q = S.filter.toLowerCase();
|
|
1046
1171
|
const hit = (s) => !q || s.name.toLowerCase().includes(q) || s.target.toLowerCase().includes(q) || s.name === S.sel;
|
|
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"))}>
|
|
1172
|
+
const item = (s) => `<button type="button" class="srv ${s.name === S.sel ? "on" : ""}"${s.name === S.sel ? ` aria-current="page"` : ""} data-f="srv:${esc(s.name)}" ${on(() => go(s.name, S.sel === s.name ? S.tab : "sites"))}>
|
|
1048
1173
|
<div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
|
|
1049
1174
|
<div class="t">${esc(s.target)}</div></button>`;
|
|
1050
1175
|
const group = (title, l) => {
|
|
@@ -1055,7 +1180,7 @@ function renderNav() {
|
|
|
1055
1180
|
$("#add-server").textContent = list.length < 8 ? "+ Add server" : "+";
|
|
1056
1181
|
$("#navlist").innerHTML = `${group("Servers", own)}${group("From ~/.ssh/config", aliases)}
|
|
1057
1182
|
${list.length ? "" : `<div class="nav-none">No servers yet.</div>`}`;
|
|
1058
|
-
$("#pick").innerHTML = `<select onchange="go(this.value || null)">
|
|
1183
|
+
$("#pick").innerHTML = `<select aria-label="Server" onchange="go(this.value || null)">
|
|
1059
1184
|
<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("")}
|
|
1060
1185
|
</select>`;
|
|
1061
1186
|
}
|
|
@@ -1064,7 +1189,11 @@ function renderHeader() {
|
|
|
1064
1189
|
const st = S.state;
|
|
1065
1190
|
$("#version").textContent = st.version;
|
|
1066
1191
|
const p = st.project;
|
|
1067
|
-
|
|
1192
|
+
const running = runningJobs().length;
|
|
1193
|
+
const jobsBtn = S.jobs.size && !S.job
|
|
1194
|
+
? btn(`${running ? `<span class="spin" aria-hidden="true"></span>` : ""}Jobs${running ? ` · ${running} running` : ""}`, () => showJob([...S.jobs.keys()].pop()), "jobs-btn", "Show command output", "jobs")
|
|
1195
|
+
: "";
|
|
1196
|
+
$("#project").innerHTML = `${jobsBtn}
|
|
1068
1197
|
<span class="path" title="${esc(st.cwd)}">${p ? `<b>${esc(p.name)}</b> · ${esc(p.domains.join(", "))}` : esc(st.cwd.replace(/^\/(Users|home)\/[^/]+/, "~"))}</span>
|
|
1069
1198
|
${btn("Deploy folder…", () => folderDialog(st.cwd), "", "Pick a folder, a recent project, or drop one here")}
|
|
1070
1199
|
${btn(p ? "Deploy" : "Deploy this folder", deployHere, "primary", p ? `lf deploy (.landfall.json in ${st.cwd})` : `lf deploy from ${st.cwd}`)}`;
|
|
@@ -1079,7 +1208,10 @@ function errorHint(error) {
|
|
|
1079
1208
|
|
|
1080
1209
|
const isUp = (status) => /^up/i.test(status);
|
|
1081
1210
|
// A one-off service that finished (Exited (0)) is not down: grey, not red.
|
|
1082
|
-
const statusDot = (status) =>
|
|
1211
|
+
const statusDot = (status) => {
|
|
1212
|
+
const [cls, word] = isUp(status) ? (/unhealthy|restarting/i.test(status) ? ["warn", "unhealthy"] : ["up", "running"]) : /^exited \(0\)/i.test(status) ? ["", "finished"] : ["down", "down"];
|
|
1213
|
+
return `<span class="dot ${cls}" role="img" aria-label="${word}" title="${esc(status)}"></span>`;
|
|
1214
|
+
};
|
|
1083
1215
|
|
|
1084
1216
|
const size = (b) => {
|
|
1085
1217
|
const u = ["B", "K", "M", "G", "T"];
|
|
@@ -1120,6 +1252,11 @@ function renderMain() {
|
|
|
1120
1252
|
if (!S.sel) return void (main.innerHTML = homeView());
|
|
1121
1253
|
const n = S.sel;
|
|
1122
1254
|
const s = server();
|
|
1255
|
+
if (!s) {
|
|
1256
|
+
return void (main.innerHTML = `<div class="card"><div class="body empty"><h2>No server named ${esc(n)}</h2>
|
|
1257
|
+
<p>It is not in landfall's list or in ~/.ssh/config (anymore).</p>
|
|
1258
|
+
<p>${btn("All servers", () => go(null), "primary")} ${btn("Add a server…", addServer)}</p></div></div>`);
|
|
1259
|
+
}
|
|
1123
1260
|
const ov = S.overview[n] ?? { loading: true };
|
|
1124
1261
|
const o = ov.data;
|
|
1125
1262
|
const traefik = o
|
|
@@ -1128,14 +1265,16 @@ function renderMain() {
|
|
|
1128
1265
|
: o.ready ? `<span class="dot down"></span>no traefik` : ""
|
|
1129
1266
|
: "";
|
|
1130
1267
|
const sub = [esc(s?.target ?? ""), s && !s.registered ? "from ~/.ssh/config" : "", traefik].filter(Boolean).join(" · ");
|
|
1131
|
-
const tab = (t, label) => `<button type="button" class="${S.tab === t ? "on" : ""}" ${on(() => go(n, t))}>${label}</button>`;
|
|
1268
|
+
const tab = (t, label) => `<button type="button" class="${S.tab === t ? "on" : ""}"${S.tab === t ? ` aria-current="page"` : ""} data-f="tab:${t}" ${on(() => go(n, t))}>${label}</button>`;
|
|
1269
|
+
// Reloading with something already on screen: keep it, show a small spinner.
|
|
1270
|
+
const reloading = needs(S.tab).some((t) => S[STORE[t]][n]?.loading && S[STORE[t]][n]?.data);
|
|
1132
1271
|
main.innerHTML = `
|
|
1133
1272
|
<div class="head">
|
|
1134
1273
|
<div><h1>${esc(n)}${s?.isDefault ? ` <span class="star" title="default server">★</span>` : ""}</h1><div class="sub">${sub}</div>${factsLine(n)}</div>
|
|
1135
1274
|
<div class="actions">
|
|
1136
1275
|
${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
|
|
1137
1276
|
${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
|
|
1138
|
-
${btn("↻", () => load(n, S.tab, true)
|
|
1277
|
+
${btn("↻", () => (load(n, S.tab, true), render()), "", "Reload", "reload").replace("<button", `<button aria-label="Reload"`)}
|
|
1139
1278
|
${menuBtn([
|
|
1140
1279
|
{ label: "Server info", hint: "lf server info", run: () => run(["server", "info", n]) },
|
|
1141
1280
|
{ label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) },
|
|
@@ -1146,15 +1285,16 @@ function renderMain() {
|
|
|
1146
1285
|
</div>
|
|
1147
1286
|
</div>
|
|
1148
1287
|
${factsNote(n)}
|
|
1149
|
-
<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>
|
|
1288
|
+
<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")}${reloading ? `<span class="spin" role="img" aria-label="reloading" style="align-self:center;margin-left:8px"></span>` : ""}</div>
|
|
1150
1289
|
<div id="view"></div>`;
|
|
1151
1290
|
const view = $("#view");
|
|
1152
|
-
if (ov.loading) view.innerHTML = `<div class="loading"><span class="spin"></span> connecting to ${esc(n)}…</div>`;
|
|
1291
|
+
if (ov.loading && !o) view.innerHTML = `<div class="loading"><span class="spin"></span> connecting to ${esc(n)}…</div>`;
|
|
1153
1292
|
else if (ov.error) {
|
|
1154
1293
|
const hint = errorHint(ov.error);
|
|
1155
1294
|
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>
|
|
1156
1295
|
<div class="actions" style="margin:0">${btn("Set up", () => setupFlow([n]), "primary")} ${btn("Retry", () => (load(n, S.tab, true), render()))}</div>`;
|
|
1157
|
-
} else if (
|
|
1296
|
+
} else if (!o) view.innerHTML = "";
|
|
1297
|
+
else if (S.tab === "sites") view.innerHTML = sitesView(n, o);
|
|
1158
1298
|
else if (S.tab === "updates") view.innerHTML = updatesView(n);
|
|
1159
1299
|
else if (S.tab === "firewall") view.innerHTML = firewallView(n);
|
|
1160
1300
|
else if (S.tab === "settings") view.innerHTML = settingsView(n);
|
|
@@ -1188,7 +1328,7 @@ function othersCard(n, o) {
|
|
|
1188
1328
|
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
1329
|
return `<div class="card scroll"><h2>Not deployed by landfall <span class="badge">${o.others.length}</span></h2>
|
|
1190
1330
|
<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
|
|
1331
|
+
<table><thead><tr><th>Name</th><th class="hide-sm">Domains</th><th class="hide-sm">Folder</th><th><span class="sr">Actions</span></th></tr></thead><tbody>${o.others
|
|
1192
1332
|
.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
1333
|
<div class="chips show-sm">${links(e)}</div></td>
|
|
1194
1334
|
<td class="hide-sm"><div class="chips">${links(e)}</div></td>
|
|
@@ -1229,13 +1369,16 @@ function sitesView(n, o) {
|
|
|
1229
1369
|
: "Stops it and removes its files and releases. Its named volumes (databases, uploads) stay.\nThis cannot be undone.",
|
|
1230
1370
|
ok: m.adopted ? `Forget ${name}` : `Delete ${name}`,
|
|
1231
1371
|
danger: true,
|
|
1372
|
+
typeToConfirm: m.adopted ? "" : name,
|
|
1232
1373
|
onOk: () => run(["sites", "rm", name, ...sv(n), "-y"]),
|
|
1233
1374
|
});
|
|
1234
1375
|
const svcMenu = (x) =>
|
|
1235
1376
|
menuBtn([
|
|
1236
1377
|
{ label: "Logs", hint: `lf logs ${name} ${x.service}`, run: () => run(["logs", name, x.service, ...sv(n), "-f"]) },
|
|
1237
1378
|
{ label: "Restart", hint: "lf restart", run: () => run(["restart", name, x.service, ...sv(n)]) },
|
|
1238
|
-
|
|
1379
|
+
isUp(x.status)
|
|
1380
|
+
? { label: "Stop…", hint: "lf stop", run: () => confirmDlg({ title: `Stop ${x.service} of ${name}?`, text: "It stays stopped until you start it again.", ok: "Stop it", danger: true, onOk: () => run(["stop", name, x.service, ...sv(n)]) }) }
|
|
1381
|
+
: { label: "Start", hint: "lf start", run: () => run(["start", name, x.service, ...sv(n)]) },
|
|
1239
1382
|
], `${x.service} of ${name}`, "sm ghost");
|
|
1240
1383
|
const acts = [
|
|
1241
1384
|
proj && !m.adopted
|
|
@@ -1244,7 +1387,9 @@ function sitesView(n, o) {
|
|
|
1244
1387
|
old ? "" : btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the logs of all its services"),
|
|
1245
1388
|
menuBtn([
|
|
1246
1389
|
old ? null : { label: "Restart", hint: "lf restart", run: () => run(["restart", name, ...sv(n)]) },
|
|
1247
|
-
old ? null
|
|
1390
|
+
old ? null
|
|
1391
|
+
: up ? { label: "Stop…", hint: "lf stop", run: () => confirmDlg({ title: `Stop ${name}?`, text: `${domains.join(", ") || "The site"} stops answering until you start it again.`, ok: "Stop it", danger: true, onOk: () => run(["stop", name, ...sv(n)]) }) }
|
|
1392
|
+
: { label: "Start", hint: "lf start", run: () => run(["start", name, ...sv(n)]) },
|
|
1248
1393
|
managed && older.length
|
|
1249
1394
|
? { 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
1395
|
: null,
|
|
@@ -1280,7 +1425,7 @@ function sitesView(n, o) {
|
|
|
1280
1425
|
</dl></td></tr>`
|
|
1281
1426
|
: "";
|
|
1282
1427
|
return `<tr class="click" ${on(toggle)}>
|
|
1283
|
-
<td><div class="site-name">${statusDot(status)}<b>${esc(name)}</b>${m.auth?.length ? `<span class="lock" title="password protected"
|
|
1428
|
+
<td><div class="site-name">${statusDot(status)}<button type="button" class="row-toggle" aria-expanded="${open}" data-f="row:${esc(name)}" ${on(toggle)}><b>${esc(name)}</b> <span class="faint" aria-hidden="true">${open ? "▾" : "▸"}</span></button>${m.auth?.length ? `<span class="lock" title="password protected" role="img" aria-label="password protected">🔒</span>` : ""}</div>
|
|
1284
1429
|
<div class="chips show-sm">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
|
|
1285
1430
|
<td class="hide-sm"><div class="chips">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
|
|
1286
1431
|
<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>
|
|
@@ -1289,7 +1434,7 @@ function sitesView(n, o) {
|
|
|
1289
1434
|
})
|
|
1290
1435
|
.join("");
|
|
1291
1436
|
return `<div class="card scroll"><table>
|
|
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>
|
|
1437
|
+
<thead><tr><th>Site</th><th class="hide-sm">Domains</th><th class="hide-sm">Kind</th><th class="hide-sm">Deployed</th><th><span class="sr">Actions</span></th></tr></thead>
|
|
1293
1438
|
<tbody>${rows}</tbody></table></div>${othersCard(n, o)}`;
|
|
1294
1439
|
}
|
|
1295
1440
|
|
|
@@ -1353,7 +1498,7 @@ function refreshUpdates(n) {
|
|
|
1353
1498
|
|
|
1354
1499
|
function updatesView(n) {
|
|
1355
1500
|
const st = S.updates[n] ?? { loading: true };
|
|
1356
|
-
if (st.loading) return `<div class="loading"><span class="spin"></span> ${st.refreshing ? "refreshing package lists…" : "checking packages and images…"}</div>`;
|
|
1501
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> ${st.refreshing ? "refreshing package lists…" : "checking packages and images…"}</div>`;
|
|
1357
1502
|
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>`;
|
|
1358
1503
|
const { packages: p, images, engine: e } = st.data;
|
|
1359
1504
|
const s = sv(n);
|
|
@@ -1429,7 +1574,7 @@ const REACH = { open: "ok", limited: "accent", blocked: "bad", local: "" };
|
|
|
1429
1574
|
|
|
1430
1575
|
function firewallView(n) {
|
|
1431
1576
|
const st = S.firewall[n] ?? { loading: true };
|
|
1432
|
-
if (st.loading) return `<div class="loading"><span class="spin"></span> reading the firewall…</div>`;
|
|
1577
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the firewall…</div>`;
|
|
1433
1578
|
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1434
1579
|
const fw = st.data;
|
|
1435
1580
|
const s = sv(n);
|
|
@@ -1453,11 +1598,11 @@ function firewallView(n) {
|
|
|
1453
1598
|
const port = r.port ? `${r.port}${r.proto ? `/${r.proto}` : ""}` : "";
|
|
1454
1599
|
return `<tr>
|
|
1455
1600
|
<td class="faint">${num}</td>
|
|
1456
|
-
<td>${btn(`<span class="badge ${cls}">${esc(r.action)}${r.out ? " out" : ""}</span>`, () => fwRun(["firewall", "update",
|
|
1601
|
+
<td>${btn(`<span class="badge ${cls}">${esc(r.action)}${r.out ? " out" : ""}</span>`, () => confirmDlg({ title: `Switch rule ${num} to ${flip}?`, text: `${r.spec}${r.from ? ` from ${r.from}` : ""}`, ok: `Switch to ${flip}`, danger: flip === "deny", onOk: () => fwRun(["firewall", "update", r.spec, "--action", flip, ...s]) }), "ghost sm", `Switch to ${flip}`)}</td>
|
|
1457
1602
|
<td class="mono">${esc(port || r.spec)}</td>
|
|
1458
1603
|
<td>${r.from ? `<span class="mono">${esc(r.from)}</span>` : `<span class="dim">anywhere</span>`}</td>
|
|
1459
1604
|
<td class="dim hide-sm">${esc(r.comment ?? "")}</td>
|
|
1460
|
-
<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",
|
|
1605
|
+
<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", r.spec, ...s, "-y"]) }), "sm danger")}</td>
|
|
1461
1606
|
</tr>`;
|
|
1462
1607
|
})
|
|
1463
1608
|
.join("");
|
|
@@ -1470,7 +1615,7 @@ function firewallView(n) {
|
|
|
1470
1615
|
<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>
|
|
1471
1616
|
<span class="actions">${toggle}</span></h2>
|
|
1472
1617
|
<div class="scroll"><table>
|
|
1473
|
-
<thead><tr><th>#</th><th>Action</th><th>Port</th><th>From</th><th class="hide-sm">Comment</th><th></th></tr></thead>
|
|
1618
|
+
<thead><tr><th>#</th><th>Action</th><th>Port</th><th>From</th><th class="hide-sm">Comment</th><th><span class="sr">Actions</span></th></tr></thead>
|
|
1474
1619
|
<tbody>${ruleRows || `<tr><td colspan="6" class="dim">no rules</td></tr>`}</tbody>
|
|
1475
1620
|
</table></div>
|
|
1476
1621
|
<div class="body" style="border-top:1px solid var(--line)">
|
|
@@ -1498,7 +1643,9 @@ document.addEventListener("submit", (e) => {
|
|
|
1498
1643
|
const port = f.port.value.trim();
|
|
1499
1644
|
if (!port) return;
|
|
1500
1645
|
const from = f.from.value.trim();
|
|
1501
|
-
|
|
1646
|
+
const action = f.action.value;
|
|
1647
|
+
e.target.reset();
|
|
1648
|
+
run(["firewall", action, port, ...sv(S.sel), ...(from && from !== "anywhere" ? ["--from", from] : [])], { server: S.sel });
|
|
1502
1649
|
});
|
|
1503
1650
|
|
|
1504
1651
|
function editRule(n, num, r) {
|
|
@@ -1513,7 +1660,8 @@ function editRule(n, num, r) {
|
|
|
1513
1660
|
],
|
|
1514
1661
|
ok: "Save",
|
|
1515
1662
|
onOk: (v) => {
|
|
1516
|
-
|
|
1663
|
+
// By its text, not its number: numbers shift when another firewall command lands first.
|
|
1664
|
+
const argv = ["firewall", "update", r.spec, ...sv(n)];
|
|
1517
1665
|
if (v.port.trim() && v.port.trim() !== port) argv.push("--port", v.port.trim());
|
|
1518
1666
|
argv.push("--from", v.from.trim() || "any");
|
|
1519
1667
|
if (v.action !== r.action) argv.push("--action", v.action);
|
|
@@ -1526,7 +1674,7 @@ function editRule(n, num, r) {
|
|
|
1526
1674
|
|
|
1527
1675
|
function settingsView(n) {
|
|
1528
1676
|
const st = S.settings[n] ?? { loading: true };
|
|
1529
|
-
if (st.loading) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
1677
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
1530
1678
|
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1531
1679
|
const x = st.data;
|
|
1532
1680
|
const reload = { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "settings", true), render()) : undefined) };
|
|
@@ -1541,6 +1689,9 @@ function settingsView(n) {
|
|
|
1541
1689
|
if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
|
|
1542
1690
|
}, "Apply");
|
|
1543
1691
|
const sites = S.overview[n]?.data?.sites ?? [];
|
|
1692
|
+
// Sites behind a Traefik the server already had (proxy.env): lf traefik setup refuses to touch it.
|
|
1693
|
+
const proxy = S.overview[n]?.data?.proxy;
|
|
1694
|
+
const foreign = proxy && proxy !== "traefik" ? proxy : "";
|
|
1544
1695
|
const external = (d) => S.overview[n]?.data?.others?.find((e) => e.domains.includes(d))?.name;
|
|
1545
1696
|
const servedBy = (d) => sites.find((s) => s.domains.includes(d))?.meta.name;
|
|
1546
1697
|
const serve = (d) => {
|
|
@@ -1577,8 +1728,8 @@ function settingsView(n) {
|
|
|
1577
1728
|
${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>
|
|
1578
1729
|
</div>
|
|
1579
1730
|
<div class="card">
|
|
1580
|
-
<h2>Traefik <span class="actions">${btn("Dashboard…", dashboard, "sm")}</span></h2>
|
|
1581
|
-
<div class="body">${rows([
|
|
1731
|
+
<h2>Traefik <span class="actions">${foreign ? "" : btn("Dashboard…", dashboard, "sm")}</span></h2>
|
|
1732
|
+
<div class="body">${foreign ? `<div class="note">Sites go through <b class="mono">${esc(foreign)}</b>, a Traefik landfall did not install and never changes: its dashboard is set up in its own config.</div>` : ""}${rows([
|
|
1582
1733
|
["image", esc(x.traefik.summary)],
|
|
1583
1734
|
["e-mail", esc(x.traefik.email ?? "none")],
|
|
1584
1735
|
["dashboard", x.traefik.dashboard ? `<a href="https://${esc(x.traefik.dashboard)}" target="_blank" rel="noopener">${esc(x.traefik.dashboard)}</a>` : "off"],
|
|
@@ -1612,7 +1763,7 @@ function settingsView(n) {
|
|
|
1612
1763
|
|
|
1613
1764
|
function historyView(n) {
|
|
1614
1765
|
const st = S.history[n] ?? { loading: true };
|
|
1615
|
-
if (st.loading) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
|
|
1766
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
|
|
1616
1767
|
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1617
1768
|
const runs = st.data;
|
|
1618
1769
|
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>`;
|
|
@@ -1650,7 +1801,7 @@ function historyView(n) {
|
|
|
1650
1801
|
return `
|
|
1651
1802
|
<div class="card">
|
|
1652
1803
|
<h2>Journal <span class="dim" style="font-weight:400">/opt/landfall/journal.log · newest first</span>
|
|
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>
|
|
1804
|
+
<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, typeToConfirm: n, onOk: () => undo(["--all"]) }), "sm danger") : ""}</span></h2>
|
|
1654
1805
|
${items}
|
|
1655
1806
|
</div>`;
|
|
1656
1807
|
}
|
|
@@ -1662,16 +1813,28 @@ function render() {
|
|
|
1662
1813
|
// Every render re-registers the buttons it draws (dialogs wire their own buttons).
|
|
1663
1814
|
handlers = new Map();
|
|
1664
1815
|
const scroll = $("#main").scrollTop;
|
|
1816
|
+
// innerHTML drops the focused button: find its redrawn twin by its data-f key.
|
|
1817
|
+
const f = document.activeElement?.dataset?.f;
|
|
1818
|
+
// So is what someone was typing into the add-rule form.
|
|
1819
|
+
const form = $("#addrule"), typed = form && [...form.elements].filter((el) => el.name).map((el) => [el.name, el.value]);
|
|
1820
|
+
const typing = form?.contains(document.activeElement) && document.activeElement.name;
|
|
1665
1821
|
renderHeader();
|
|
1666
1822
|
renderNav();
|
|
1667
1823
|
renderMain();
|
|
1668
1824
|
renderJob();
|
|
1669
1825
|
$("#main").scrollTop = scroll;
|
|
1826
|
+
const again = typed && $("#addrule");
|
|
1827
|
+
if (again) {
|
|
1828
|
+
for (const [k, v] of typed) again.elements[k].value = v;
|
|
1829
|
+
if (typing) again.elements[typing].focus({ preventScroll: true });
|
|
1830
|
+
}
|
|
1831
|
+
if (f && !document.activeElement?.closest?.("#dlg, #menu")) [...document.querySelectorAll("[data-f]")].find((el) => el.dataset.f === f)?.focus({ preventScroll: true });
|
|
1832
|
+
document.title = S.sel ? `${S.sel} · ${S.tab} · landfall` : "landfall";
|
|
1670
1833
|
}
|
|
1671
1834
|
|
|
1672
1835
|
document.addEventListener("keydown", (e) => {
|
|
1673
1836
|
if (e.key === "Escape" && $("#menu")) return closeMenu();
|
|
1674
|
-
if (e.key === "Escape" && !$("#dlg").open && S.job) {
|
|
1837
|
+
if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
|
|
1675
1838
|
S.job = null;
|
|
1676
1839
|
render();
|
|
1677
1840
|
}
|
|
@@ -1687,17 +1850,22 @@ $("#add-server").onclick = () => addServer();
|
|
|
1687
1850
|
try {
|
|
1688
1851
|
await refreshState();
|
|
1689
1852
|
// Jobs started before a reload keep running on the server: list them, and show one still running.
|
|
1853
|
+
const old = [];
|
|
1690
1854
|
for (const info of S.state.jobs.slice().reverse()) {
|
|
1691
1855
|
const j = { info, lines: [], actions: [] };
|
|
1692
1856
|
S.jobs.set(info.id, j);
|
|
1693
1857
|
if (info.code === undefined) stream(j);
|
|
1694
|
-
else
|
|
1695
|
-
const full = await http(`/api/jobs/${info.id}`);
|
|
1696
|
-
j.lines = full.lines;
|
|
1697
|
-
}
|
|
1858
|
+
else old.push(j);
|
|
1698
1859
|
}
|
|
1699
1860
|
S.job = S.state.jobs.find((j) => j.code === undefined)?.id ?? null;
|
|
1700
1861
|
route();
|
|
1862
|
+
// Finished jobs' output only matters once someone opens them: fetch it after the page is up.
|
|
1863
|
+
for (const j of old) {
|
|
1864
|
+
http(`/api/jobs/${j.info.id}`).then((full) => {
|
|
1865
|
+
j.lines = full.lines;
|
|
1866
|
+
if (S.job === j.info.id) render();
|
|
1867
|
+
}, () => {});
|
|
1868
|
+
}
|
|
1701
1869
|
} catch (e) {
|
|
1702
1870
|
$("#main").innerHTML = `<div class="empty"><h2>lf ui</h2><p>${esc(e.message)}</p></div>`;
|
|
1703
1871
|
}
|