landfall 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/cli.mjs +191 -205
- package/dist/index.html +300 -139
- 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,31 @@ 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
|
+
m.addEventListener("focusout", (e) => {
|
|
414
|
+
if (!m.contains(e.relatedTarget)) closeMenu(false);
|
|
415
|
+
});
|
|
370
416
|
document.body.append(m);
|
|
371
417
|
const r = anchor.getBoundingClientRect();
|
|
372
418
|
m.style.left = `${Math.max(8, Math.min(r.right - m.offsetWidth, innerWidth - m.offsetWidth - 8))}px`;
|
|
373
419
|
m.style.top = `${r.bottom + 4 + m.offsetHeight > innerHeight - 8 ? Math.max(8, r.top - m.offsetHeight - 4) : r.bottom + 4}px`;
|
|
374
420
|
$("button", m)?.focus({ preventScroll: true });
|
|
375
421
|
}
|
|
376
|
-
|
|
377
|
-
|
|
422
|
+
/** restore: focus goes back to the "⋯" button (Esc, or an item that opens no dialog). */
|
|
423
|
+
function closeMenu(restore = true) {
|
|
424
|
+
const m = $("#menu");
|
|
425
|
+
if (!m) return;
|
|
426
|
+
m.remove();
|
|
427
|
+
menuAnchor?.setAttribute("aria-expanded", "false");
|
|
428
|
+
if (restore && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true });
|
|
429
|
+
menuAnchor = null;
|
|
378
430
|
}
|
|
379
|
-
addEventListener("resize", closeMenu);
|
|
380
|
-
document.addEventListener("scroll", closeMenu, true);
|
|
431
|
+
addEventListener("resize", () => closeMenu(false));
|
|
432
|
+
document.addEventListener("scroll", (e) => e.target.closest?.("#menu") || closeMenu(false), true);
|
|
381
433
|
|
|
382
434
|
// ---- routing: #/<server>/<tab> ---------------------------------------------
|
|
383
435
|
|
|
@@ -390,7 +442,7 @@ function route() {
|
|
|
390
442
|
const [, name, tab] = location.hash.split("/");
|
|
391
443
|
S.sel = name ? decodeURIComponent(name) : null;
|
|
392
444
|
S.tab = tab in STORE ? tab : "sites";
|
|
393
|
-
if (S.sel) load(S.sel, S.tab);
|
|
445
|
+
if (S.sel && server()) load(S.sel, S.tab);
|
|
394
446
|
render();
|
|
395
447
|
}
|
|
396
448
|
window.addEventListener("hashchange", route);
|
|
@@ -400,13 +452,14 @@ window.addEventListener("hashchange", route);
|
|
|
400
452
|
const STORE = { sites: "overview", facts: "facts", updates: "updates", firewall: "firewall", history: "history", settings: "settings" };
|
|
401
453
|
const URLS = { sites: "", facts: "/facts", updates: "/updates", firewall: "/firewall", history: "/history", settings: "/config" };
|
|
402
454
|
|
|
455
|
+
// The overview and facts drive the header, so every tab needs them.
|
|
456
|
+
const needs = (tab) => (tab === "sites" ? ["sites", "facts"] : ["sites", "facts", tab]);
|
|
403
457
|
function load(name, tab, force = false) {
|
|
404
|
-
|
|
405
|
-
const needed = tab === "sites" ? ["sites", "facts"] : ["sites", "facts", tab];
|
|
406
|
-
for (const t of needed) {
|
|
458
|
+
for (const t of needs(tab)) {
|
|
407
459
|
const store = S[STORE[t]];
|
|
408
460
|
if (store[name] && !force) continue;
|
|
409
|
-
|
|
461
|
+
// Keep what is shown while it reloads; views show a spinner only when they have nothing yet.
|
|
462
|
+
store[name] = { ...store[name], loading: true, error: undefined };
|
|
410
463
|
http(`/api/servers/${encodeURIComponent(name)}${URLS[t]}`).then(
|
|
411
464
|
(data) => ((store[name] = { data }), render()),
|
|
412
465
|
(e) => ((store[name] = { error: e.message }), render()),
|
|
@@ -414,8 +467,11 @@ function load(name, tab, force = false) {
|
|
|
414
467
|
}
|
|
415
468
|
}
|
|
416
469
|
function invalidate(name) {
|
|
417
|
-
|
|
418
|
-
|
|
470
|
+
delete S.dns[name];
|
|
471
|
+
// What is on screen reloads in place; other tabs reload when opened.
|
|
472
|
+
const shown = new Set(S.sel === name ? needs(S.tab).map((t) => STORE[t]) : []);
|
|
473
|
+
for (const k of Object.values(STORE)) if (!shown.has(k)) delete S[k][name];
|
|
474
|
+
if (S.sel === name) load(name, S.tab, true);
|
|
419
475
|
}
|
|
420
476
|
async function refreshState() {
|
|
421
477
|
S.state = await http("/api/state");
|
|
@@ -440,6 +496,7 @@ async function run(argv, o = {}) {
|
|
|
440
496
|
S.job = info.id;
|
|
441
497
|
stream(j);
|
|
442
498
|
render();
|
|
499
|
+
say(`Running lf ${argv.join(" ")}`);
|
|
443
500
|
}
|
|
444
501
|
|
|
445
502
|
function stream(j) {
|
|
@@ -469,7 +526,12 @@ function finished(j) {
|
|
|
469
526
|
j.actions = (j.after && j.after(j)) || [];
|
|
470
527
|
// Safety checks refuse what would lock out ssh or break running services; --force overrides after reading why.
|
|
471
528
|
if (failed && !info.argv.includes("--force") && j.lines.some((l) => l.includes("--force"))) {
|
|
472
|
-
j.
|
|
529
|
+
const why = j.lines.map((l) => l.replace(ANSI, "").trim()).filter(Boolean).slice(-4).join("\n");
|
|
530
|
+
j.actions.push({
|
|
531
|
+
label: "Do it anyway (--force)…",
|
|
532
|
+
cls: "danger",
|
|
533
|
+
run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server }) }),
|
|
534
|
+
});
|
|
473
535
|
}
|
|
474
536
|
if (info.undoable && info.server && info.code !== null) {
|
|
475
537
|
j.actions.push({
|
|
@@ -486,7 +548,17 @@ function finished(j) {
|
|
|
486
548
|
if (info.server) invalidate(info.server);
|
|
487
549
|
refreshState().catch(() => {});
|
|
488
550
|
render();
|
|
551
|
+
const what = `lf ${info.argv.join(" ")} ${info.code === 0 ? "done" : info.code === null ? "stopped" : `failed (exit ${info.code})`}`;
|
|
552
|
+
// Closed or showing another job: say so, with a way back to it.
|
|
553
|
+
if (S.job !== info.id) toast(`${info.code === 0 ? "✓" : "✗"} ${what}`, { label: "Show", run: () => showJob(info.id) });
|
|
554
|
+
else say(what);
|
|
555
|
+
}
|
|
556
|
+
function showJob(id) {
|
|
557
|
+
S.job = id;
|
|
558
|
+
render();
|
|
559
|
+
$("#job .job-out")?.focus({ preventScroll: true });
|
|
489
560
|
}
|
|
561
|
+
const runningJobs = () => [...S.jobs.values()].filter((j) => j.info.code === undefined);
|
|
490
562
|
|
|
491
563
|
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
492
564
|
function lineClass(l) {
|
|
@@ -532,33 +604,51 @@ function renderJob() {
|
|
|
532
604
|
? `<span style="color:var(--ok)">✓ done</span>`
|
|
533
605
|
: `<span style="color:var(--bad)">✗ failed (exit ${info.code})</span>`;
|
|
534
606
|
const others = [...S.jobs.values()].reverse();
|
|
607
|
+
const stop = () => {
|
|
608
|
+
const doStop = () => http(`/api/jobs/${info.id}/stop`, {}).catch((e) => toast(e.message));
|
|
609
|
+
if (!info.undoable) return doStop();
|
|
610
|
+
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 });
|
|
611
|
+
};
|
|
612
|
+
// The output element stays across renders: rebuilding it would lose the reader's scroll position.
|
|
613
|
+
let out = $(".job-out", el);
|
|
614
|
+
const fresh = !out || out.dataset.job !== info.id;
|
|
615
|
+
if (fresh) {
|
|
616
|
+
out = Object.assign(document.createElement("div"), { className: "job-out", tabIndex: 0 });
|
|
617
|
+
out.dataset.job = info.id;
|
|
618
|
+
out.setAttribute("role", "region");
|
|
619
|
+
out.setAttribute("aria-label", "Output");
|
|
620
|
+
}
|
|
535
621
|
el.innerHTML = `
|
|
536
622
|
<div class="job-head">
|
|
537
623
|
<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)")}
|
|
624
|
+
${btn("✕", () => ((S.job = null), render()), "ghost sm", "Close (Esc)", "job-close").replace("<button", `<button aria-label="Close output"`)}
|
|
539
625
|
</div>
|
|
540
|
-
<div class="job-out"></div>
|
|
626
|
+
<div class="job-out-slot"></div>
|
|
541
627
|
<div class="job-foot">
|
|
542
628
|
<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("")}
|
|
629
|
+
${running ? btn("Stop", stop, "danger", info.undoable ? "Changes made so far stay" : "", "job-stop") : ""}
|
|
630
|
+
${(running ? [] : j.actions).map((a, i) => btn(esc(a.label), a.run, a.cls ?? (i === 0 ? "primary" : ""), "", `job-act:${a.label}`)).join("")}
|
|
545
631
|
</div>
|
|
546
632
|
${others.length > 1 ? `<div class="joblist">${others
|
|
547
|
-
.map((o) =>
|
|
548
|
-
|
|
633
|
+
.map((o) => {
|
|
634
|
+
const [cls, ico] = o.info.code === undefined ? ["", "…"] : o.info.code === 0 ? ["ok", "✓"] : o.info.code === null ? ["", "■"] : ["bad", "✗"];
|
|
635
|
+
return `<button type="button" class="${o.info.id === S.job ? "on" : ""}" data-f="job:${o.info.id}" ${on(() => showJob(o.info.id))}>
|
|
636
|
+
<span class="${cls}">${ico}</span><span>lf ${esc(o.info.argv.join(" "))}</span></button>`;
|
|
637
|
+
})
|
|
549
638
|
.join("")}</div>` : ""}`;
|
|
639
|
+
$(".job-out-slot", el).replaceWith(out);
|
|
550
640
|
appendOutput(j);
|
|
551
|
-
|
|
552
|
-
out.scrollTop = out.scrollHeight;
|
|
641
|
+
if (fresh) out.scrollTop = out.scrollHeight;
|
|
553
642
|
}
|
|
554
643
|
|
|
555
644
|
// ---- dialogs -------------------------------------------------------------
|
|
556
645
|
|
|
557
646
|
/**
|
|
558
647
|
* 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.
|
|
648
|
+
* onOk(values) runs on submit; return false to keep it open, or {field, error} to keep it open and show why.
|
|
649
|
+
* typeToConfirm: a name the user types before the (dangerous) action is enabled.
|
|
560
650
|
*/
|
|
561
|
-
function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "" }) {
|
|
651
|
+
function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "", typeToConfirm = "" }) {
|
|
562
652
|
const dlg = openDlg();
|
|
563
653
|
const field = (f) => {
|
|
564
654
|
const id = `f-${f.name}`;
|
|
@@ -576,34 +666,54 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
|
|
|
576
666
|
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
667
|
};
|
|
578
668
|
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>
|
|
669
|
+
dlg.innerHTML = `<form method="dialog" novalidate>
|
|
670
|
+
<h2 id="dlg-title">${esc(title)}</h2>
|
|
581
671
|
${text ? `<div class="text">${esc(text)}</div>` : ""}
|
|
582
672
|
${extra}
|
|
583
673
|
${fields.map(group).join("")}
|
|
674
|
+
${typeToConfirm ? `<label>Type <b class="mono">${esc(typeToConfirm)}</b> to confirm<input id="dlg-confirm" autocomplete="off" spellcheck="false" autocapitalize="off"></label>` : ""}
|
|
675
|
+
<div class="note bad dlg-err" id="dlg-err" role="alert" hidden></div>
|
|
584
676
|
<div class="btns">
|
|
585
677
|
<button type="button" value="cancel" class="ghost" id="dlg-cancel">Cancel</button>
|
|
586
678
|
<button type="submit" value="ok" class="${danger ? "danger solid" : "primary"}">${esc(ok)}</button>
|
|
587
679
|
</div>
|
|
588
680
|
</form>`;
|
|
589
681
|
const form = $("form", dlg);
|
|
682
|
+
const submit = $("button[type=submit]", form);
|
|
590
683
|
$("#dlg-cancel", dlg).onclick = () => dlg.close();
|
|
684
|
+
if (typeToConfirm) {
|
|
685
|
+
submit.disabled = true;
|
|
686
|
+
$("#dlg-confirm", dlg).oninput = (e) => (submit.disabled = e.target.value.trim() !== typeToConfirm);
|
|
687
|
+
}
|
|
688
|
+
form.oninput = (e) => e.target.removeAttribute?.("aria-invalid");
|
|
591
689
|
form.onsubmit = (e) => {
|
|
592
690
|
e.preventDefault();
|
|
691
|
+
if (submit.disabled) return;
|
|
593
692
|
const values = {};
|
|
594
693
|
for (const f of fields.flatMap((f) => f.fields ?? [f])) {
|
|
595
694
|
const el = form.elements[f.name];
|
|
596
695
|
values[f.name] = f.type === "check" ? el.checked : el.value;
|
|
597
696
|
}
|
|
598
|
-
|
|
697
|
+
const r = onOk(values);
|
|
698
|
+
if (r === false) return;
|
|
699
|
+
if (r?.error) {
|
|
700
|
+
const err = $("#dlg-err", dlg), el = r.field && form.elements[r.field];
|
|
701
|
+
err.textContent = r.error;
|
|
702
|
+
err.hidden = false;
|
|
703
|
+
el?.setAttribute("aria-invalid", "true");
|
|
704
|
+
el?.focus();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
599
707
|
// Empty before closing: browsers offer to save a password they still see when the form goes away.
|
|
600
708
|
for (const el of form.querySelectorAll(".secret")) el.value = "";
|
|
601
709
|
dlg.close();
|
|
602
710
|
};
|
|
603
711
|
dlg.showModal();
|
|
712
|
+
// A dangerous confirm starts on Cancel, so a stray Enter does not delete anything.
|
|
604
713
|
const first = $("input:not([type=checkbox]), textarea, select", form);
|
|
605
|
-
(first || $("
|
|
714
|
+
(first || (danger ? $("#dlg-cancel", dlg) : submit)).focus();
|
|
606
715
|
}
|
|
716
|
+
const invalid = (field, error) => ({ field, error });
|
|
607
717
|
/**
|
|
608
718
|
* Passwords here are for servers and sites, not for this page: a masked text field, so browsers
|
|
609
719
|
* and password managers do not offer to save them. Browsers without text masking get a password field.
|
|
@@ -621,7 +731,7 @@ function openDlg(cls = "") {
|
|
|
621
731
|
const confirmDlg = (o) => dialog({ ...o, onOk: () => o.onOk() });
|
|
622
732
|
const promptDlg = (title, text, field, onOk, ok = "OK") => dialog({ title, text, fields: [{ name: "v", ...field }], ok, onOk: (v) => onOk(v.v.trim()) });
|
|
623
733
|
|
|
624
|
-
// ---- server flows
|
|
734
|
+
// ---- server flows ----------------------------------------------------------
|
|
625
735
|
|
|
626
736
|
const sv = (n) => ["-s", n];
|
|
627
737
|
const server = () => S.state?.servers.find((s) => s.name === S.sel);
|
|
@@ -730,10 +840,10 @@ function addServer() {
|
|
|
730
840
|
ok: "Add",
|
|
731
841
|
onOk: (v) => {
|
|
732
842
|
const [name, host, user, port, key] = [v.name, v.host, v.user, v.port, v.key].map((x) => x.trim());
|
|
733
|
-
if (!name) return
|
|
843
|
+
if (!name) return invalid("name", "Give the server a name");
|
|
734
844
|
const known = S.state.servers.some((s) => s.name === name);
|
|
735
|
-
if (!known && !host) return
|
|
736
|
-
if (port && !/^\d+$/.test(port)) return
|
|
845
|
+
if (!known && !host) return invalid("host", `${name} is not in your ssh config: give its IP or hostname`);
|
|
846
|
+
if (port && !/^\d+$/.test(port)) return invalid("port", "The port is a number");
|
|
737
847
|
const args = [name, ...(host ? ["--host", host] : []), ...(user ? ["-u", user] : []), ...(port ? ["-p", port] : []), ...(key ? ["-i", key] : [])];
|
|
738
848
|
// With a password: install the key now, then the setup wizard needs nothing but the alias.
|
|
739
849
|
const then = (j) => (j.info.code === 0 ? void (v.setup ? setupWizard([name]) : go(name)) : undefined);
|
|
@@ -797,15 +907,23 @@ function deployDialog(det, o = {}) {
|
|
|
797
907
|
{ name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
|
|
798
908
|
]
|
|
799
909
|
: []),
|
|
910
|
+
{ 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." },
|
|
911
|
+
{ name: "authPassword", label: "Login password (at least 8 characters)", type: "password" },
|
|
800
912
|
],
|
|
801
913
|
ok: "Deploy",
|
|
802
914
|
onOk: (v) => {
|
|
803
915
|
const n = v.name.trim();
|
|
804
|
-
if (!n
|
|
916
|
+
if (!n) return invalid("name", "Give the site a name");
|
|
917
|
+
if (!v.server) return invalid("server", "Pick a server");
|
|
805
918
|
const domains = v.domains.split(/[\s,]+/).filter(Boolean);
|
|
806
919
|
const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...domains.flatMap((d) => ["-d", d])];
|
|
807
920
|
if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
|
|
808
921
|
if (v.spa && !p?.spa) argv.push("--spa");
|
|
922
|
+
const user = v.authUser.trim();
|
|
923
|
+
if (user && !/^[A-Za-z0-9._@-]{1,64}$/.test(user)) return invalid("authUser", "Only letters, digits and . _ @ -");
|
|
924
|
+
if (user && v.authPassword.length < 8) return invalid("authPassword", "The password needs at least 8 characters");
|
|
925
|
+
if (!user && v.authPassword) return invalid("authUser", "Give a user for the password");
|
|
926
|
+
if (user) argv.push("--auth-user", user, "--auth-password", v.authPassword);
|
|
809
927
|
if (o.upload) argv.push("--no-save");
|
|
810
928
|
run(argv, { server: v.server, cwd: det.path, after: openAfter });
|
|
811
929
|
},
|
|
@@ -957,8 +1075,10 @@ function domainsDialog(n, m) {
|
|
|
957
1075
|
const domains = v.domains.split(/[\s,]+/).filter(Boolean);
|
|
958
1076
|
if (v.free) domains.push(free);
|
|
959
1077
|
if (v.apex) domains.push(main);
|
|
960
|
-
|
|
961
|
-
|
|
1078
|
+
// None left: the site keeps running, unrouted.
|
|
1079
|
+
const args = domains.length ? domains.flatMap((d) => ["--set", d]) : m.domains.flatMap((d) => ["--rm", d]);
|
|
1080
|
+
if (!args.length) return;
|
|
1081
|
+
run(["domains", m.name, ...sv(n), ...args, "-y"], { server: n });
|
|
962
1082
|
},
|
|
963
1083
|
});
|
|
964
1084
|
}
|
|
@@ -978,8 +1098,8 @@ function authDialog(n, m) {
|
|
|
978
1098
|
ok: "Save",
|
|
979
1099
|
onOk: (v) => {
|
|
980
1100
|
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
|
|
1101
|
+
if (!v.user.trim()) return invalid("user", "Give a user name");
|
|
1102
|
+
if (v.password.length < 8) return invalid("password", "The password needs at least 8 characters");
|
|
983
1103
|
run(["auth", m.name, ...sv(n), "-u", v.user.trim(), "-p", v.password], { server: n });
|
|
984
1104
|
},
|
|
985
1105
|
});
|
|
@@ -1021,6 +1141,7 @@ function forget(n) {
|
|
|
1021
1141
|
text: "Removes it from landfall (and the Host block landfall wrote to ~/.ssh/config).\nNothing on the server is changed.",
|
|
1022
1142
|
ok: `Forget ${n}`,
|
|
1023
1143
|
danger: true,
|
|
1144
|
+
typeToConfirm: n,
|
|
1024
1145
|
onOk: () => run(["server", "rm", n, "-y"], { server: n, after: (j) => (j.info.code === 0 ? void go(null) : undefined) }),
|
|
1025
1146
|
});
|
|
1026
1147
|
}
|
|
@@ -1044,7 +1165,7 @@ function renderNav() {
|
|
|
1044
1165
|
const aliases = list.filter((s) => !s.registered);
|
|
1045
1166
|
const q = S.filter.toLowerCase();
|
|
1046
1167
|
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"))}>
|
|
1168
|
+
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
1169
|
<div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
|
|
1049
1170
|
<div class="t">${esc(s.target)}</div></button>`;
|
|
1050
1171
|
const group = (title, l) => {
|
|
@@ -1055,7 +1176,7 @@ function renderNav() {
|
|
|
1055
1176
|
$("#add-server").textContent = list.length < 8 ? "+ Add server" : "+";
|
|
1056
1177
|
$("#navlist").innerHTML = `${group("Servers", own)}${group("From ~/.ssh/config", aliases)}
|
|
1057
1178
|
${list.length ? "" : `<div class="nav-none">No servers yet.</div>`}`;
|
|
1058
|
-
$("#pick").innerHTML = `<select onchange="go(this.value || null)">
|
|
1179
|
+
$("#pick").innerHTML = `<select aria-label="Server" onchange="go(this.value || null)">
|
|
1059
1180
|
<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
1181
|
</select>`;
|
|
1061
1182
|
}
|
|
@@ -1064,7 +1185,11 @@ function renderHeader() {
|
|
|
1064
1185
|
const st = S.state;
|
|
1065
1186
|
$("#version").textContent = st.version;
|
|
1066
1187
|
const p = st.project;
|
|
1067
|
-
|
|
1188
|
+
const running = runningJobs().length;
|
|
1189
|
+
const jobsBtn = S.jobs.size && !S.job
|
|
1190
|
+
? btn(`${running ? `<span class="spin" aria-hidden="true"></span>` : ""}Jobs${running ? ` · ${running} running` : ""}`, () => showJob([...S.jobs.keys()].pop()), "jobs-btn", "Show command output", "jobs")
|
|
1191
|
+
: "";
|
|
1192
|
+
$("#project").innerHTML = `${jobsBtn}
|
|
1068
1193
|
<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
1194
|
${btn("Deploy folder…", () => folderDialog(st.cwd), "", "Pick a folder, a recent project, or drop one here")}
|
|
1070
1195
|
${btn(p ? "Deploy" : "Deploy this folder", deployHere, "primary", p ? `lf deploy (.landfall.json in ${st.cwd})` : `lf deploy from ${st.cwd}`)}`;
|
|
@@ -1079,7 +1204,10 @@ function errorHint(error) {
|
|
|
1079
1204
|
|
|
1080
1205
|
const isUp = (status) => /^up/i.test(status);
|
|
1081
1206
|
// A one-off service that finished (Exited (0)) is not down: grey, not red.
|
|
1082
|
-
const statusDot = (status) =>
|
|
1207
|
+
const statusDot = (status) => {
|
|
1208
|
+
const [cls, word] = isUp(status) ? (/unhealthy|restarting/i.test(status) ? ["warn", "unhealthy"] : ["up", "running"]) : /^exited \(0\)/i.test(status) ? ["", "finished"] : ["down", "down"];
|
|
1209
|
+
return `<span class="dot ${cls}" role="img" aria-label="${word}" title="${esc(status)}"></span>`;
|
|
1210
|
+
};
|
|
1083
1211
|
|
|
1084
1212
|
const size = (b) => {
|
|
1085
1213
|
const u = ["B", "K", "M", "G", "T"];
|
|
@@ -1120,6 +1248,11 @@ function renderMain() {
|
|
|
1120
1248
|
if (!S.sel) return void (main.innerHTML = homeView());
|
|
1121
1249
|
const n = S.sel;
|
|
1122
1250
|
const s = server();
|
|
1251
|
+
if (!s) {
|
|
1252
|
+
return void (main.innerHTML = `<div class="card"><div class="body empty"><h2>No server named ${esc(n)}</h2>
|
|
1253
|
+
<p>It is not in landfall's list or in ~/.ssh/config (anymore).</p>
|
|
1254
|
+
<p>${btn("All servers", () => go(null), "primary")} ${btn("Add a server…", addServer)}</p></div></div>`);
|
|
1255
|
+
}
|
|
1123
1256
|
const ov = S.overview[n] ?? { loading: true };
|
|
1124
1257
|
const o = ov.data;
|
|
1125
1258
|
const traefik = o
|
|
@@ -1128,14 +1261,16 @@ function renderMain() {
|
|
|
1128
1261
|
: o.ready ? `<span class="dot down"></span>no traefik` : ""
|
|
1129
1262
|
: "";
|
|
1130
1263
|
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>`;
|
|
1264
|
+
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>`;
|
|
1265
|
+
// Reloading with something already on screen: keep it, show a small spinner.
|
|
1266
|
+
const reloading = needs(S.tab).some((t) => S[STORE[t]][n]?.loading && S[STORE[t]][n]?.data);
|
|
1132
1267
|
main.innerHTML = `
|
|
1133
1268
|
<div class="head">
|
|
1134
1269
|
<div><h1>${esc(n)}${s?.isDefault ? ` <span class="star" title="default server">★</span>` : ""}</h1><div class="sub">${sub}</div>${factsLine(n)}</div>
|
|
1135
1270
|
<div class="actions">
|
|
1136
1271
|
${btn("Set up / check", () => setupFlow([n]), "", "Shows what is installed and what setup would change; asks before applying")}
|
|
1137
1272
|
${btn("Run…", () => runCommand(n), "", "Run a shell command over ssh")}
|
|
1138
|
-
${btn("↻", () => load(n, S.tab, true)
|
|
1273
|
+
${btn("↻", () => (load(n, S.tab, true), render()), "", "Reload", "reload").replace("<button", `<button aria-label="Reload"`)}
|
|
1139
1274
|
${menuBtn([
|
|
1140
1275
|
{ label: "Server info", hint: "lf server info", run: () => run(["server", "info", n]) },
|
|
1141
1276
|
{ label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) },
|
|
@@ -1146,15 +1281,16 @@ function renderMain() {
|
|
|
1146
1281
|
</div>
|
|
1147
1282
|
</div>
|
|
1148
1283
|
${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>
|
|
1284
|
+
<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
1285
|
<div id="view"></div>`;
|
|
1151
1286
|
const view = $("#view");
|
|
1152
|
-
if (ov.loading) view.innerHTML = `<div class="loading"><span class="spin"></span> connecting to ${esc(n)}…</div>`;
|
|
1287
|
+
if (ov.loading && !o) view.innerHTML = `<div class="loading"><span class="spin"></span> connecting to ${esc(n)}…</div>`;
|
|
1153
1288
|
else if (ov.error) {
|
|
1154
1289
|
const hint = errorHint(ov.error);
|
|
1155
1290
|
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
1291
|
<div class="actions" style="margin:0">${btn("Set up", () => setupFlow([n]), "primary")} ${btn("Retry", () => (load(n, S.tab, true), render()))}</div>`;
|
|
1157
|
-
} else if (
|
|
1292
|
+
} else if (!o) view.innerHTML = "";
|
|
1293
|
+
else if (S.tab === "sites") view.innerHTML = sitesView(n, o);
|
|
1158
1294
|
else if (S.tab === "updates") view.innerHTML = updatesView(n);
|
|
1159
1295
|
else if (S.tab === "firewall") view.innerHTML = firewallView(n);
|
|
1160
1296
|
else if (S.tab === "settings") view.innerHTML = settingsView(n);
|
|
@@ -1188,7 +1324,7 @@ function othersCard(n, o) {
|
|
|
1188
1324
|
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
1325
|
return `<div class="card scroll"><h2>Not deployed by landfall <span class="badge">${o.others.length}</span></h2>
|
|
1190
1326
|
<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
|
|
1327
|
+
<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
1328
|
.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
1329
|
<div class="chips show-sm">${links(e)}</div></td>
|
|
1194
1330
|
<td class="hide-sm"><div class="chips">${links(e)}</div></td>
|
|
@@ -1229,13 +1365,16 @@ function sitesView(n, o) {
|
|
|
1229
1365
|
: "Stops it and removes its files and releases. Its named volumes (databases, uploads) stay.\nThis cannot be undone.",
|
|
1230
1366
|
ok: m.adopted ? `Forget ${name}` : `Delete ${name}`,
|
|
1231
1367
|
danger: true,
|
|
1368
|
+
typeToConfirm: m.adopted ? "" : name,
|
|
1232
1369
|
onOk: () => run(["sites", "rm", name, ...sv(n), "-y"]),
|
|
1233
1370
|
});
|
|
1234
1371
|
const svcMenu = (x) =>
|
|
1235
1372
|
menuBtn([
|
|
1236
1373
|
{ label: "Logs", hint: `lf logs ${name} ${x.service}`, run: () => run(["logs", name, x.service, ...sv(n), "-f"]) },
|
|
1237
1374
|
{ label: "Restart", hint: "lf restart", run: () => run(["restart", name, x.service, ...sv(n)]) },
|
|
1238
|
-
|
|
1375
|
+
isUp(x.status)
|
|
1376
|
+
? { 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)]) }) }
|
|
1377
|
+
: { label: "Start", hint: "lf start", run: () => run(["start", name, x.service, ...sv(n)]) },
|
|
1239
1378
|
], `${x.service} of ${name}`, "sm ghost");
|
|
1240
1379
|
const acts = [
|
|
1241
1380
|
proj && !m.adopted
|
|
@@ -1244,7 +1383,9 @@ function sitesView(n, o) {
|
|
|
1244
1383
|
old ? "" : btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the logs of all its services"),
|
|
1245
1384
|
menuBtn([
|
|
1246
1385
|
old ? null : { label: "Restart", hint: "lf restart", run: () => run(["restart", name, ...sv(n)]) },
|
|
1247
|
-
old ? null
|
|
1386
|
+
old ? null
|
|
1387
|
+
: 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)]) }) }
|
|
1388
|
+
: { label: "Start", hint: "lf start", run: () => run(["start", name, ...sv(n)]) },
|
|
1248
1389
|
managed && older.length
|
|
1249
1390
|
? { 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
1391
|
: null,
|
|
@@ -1280,7 +1421,7 @@ function sitesView(n, o) {
|
|
|
1280
1421
|
</dl></td></tr>`
|
|
1281
1422
|
: "";
|
|
1282
1423
|
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"
|
|
1424
|
+
<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
1425
|
<div class="chips show-sm">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
|
|
1285
1426
|
<td class="hide-sm"><div class="chips">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
|
|
1286
1427
|
<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 +1430,7 @@ function sitesView(n, o) {
|
|
|
1289
1430
|
})
|
|
1290
1431
|
.join("");
|
|
1291
1432
|
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>
|
|
1433
|
+
<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
1434
|
<tbody>${rows}</tbody></table></div>${othersCard(n, o)}`;
|
|
1294
1435
|
}
|
|
1295
1436
|
|
|
@@ -1353,7 +1494,7 @@ function refreshUpdates(n) {
|
|
|
1353
1494
|
|
|
1354
1495
|
function updatesView(n) {
|
|
1355
1496
|
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>`;
|
|
1497
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> ${st.refreshing ? "refreshing package lists…" : "checking packages and images…"}</div>`;
|
|
1357
1498
|
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
1499
|
const { packages: p, images, engine: e } = st.data;
|
|
1359
1500
|
const s = sv(n);
|
|
@@ -1429,7 +1570,7 @@ const REACH = { open: "ok", limited: "accent", blocked: "bad", local: "" };
|
|
|
1429
1570
|
|
|
1430
1571
|
function firewallView(n) {
|
|
1431
1572
|
const st = S.firewall[n] ?? { loading: true };
|
|
1432
|
-
if (st.loading) return `<div class="loading"><span class="spin"></span> reading the firewall…</div>`;
|
|
1573
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the firewall…</div>`;
|
|
1433
1574
|
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1434
1575
|
const fw = st.data;
|
|
1435
1576
|
const s = sv(n);
|
|
@@ -1453,11 +1594,11 @@ function firewallView(n) {
|
|
|
1453
1594
|
const port = r.port ? `${r.port}${r.proto ? `/${r.proto}` : ""}` : "";
|
|
1454
1595
|
return `<tr>
|
|
1455
1596
|
<td class="faint">${num}</td>
|
|
1456
|
-
<td>${btn(`<span class="badge ${cls}">${esc(r.action)}${r.out ? " out" : ""}</span>`, () => fwRun(["firewall", "update",
|
|
1597
|
+
<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
1598
|
<td class="mono">${esc(port || r.spec)}</td>
|
|
1458
1599
|
<td>${r.from ? `<span class="mono">${esc(r.from)}</span>` : `<span class="dim">anywhere</span>`}</td>
|
|
1459
1600
|
<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",
|
|
1601
|
+
<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
1602
|
</tr>`;
|
|
1462
1603
|
})
|
|
1463
1604
|
.join("");
|
|
@@ -1470,7 +1611,7 @@ function firewallView(n) {
|
|
|
1470
1611
|
<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
1612
|
<span class="actions">${toggle}</span></h2>
|
|
1472
1613
|
<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>
|
|
1614
|
+
<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
1615
|
<tbody>${ruleRows || `<tr><td colspan="6" class="dim">no rules</td></tr>`}</tbody>
|
|
1475
1616
|
</table></div>
|
|
1476
1617
|
<div class="body" style="border-top:1px solid var(--line)">
|
|
@@ -1498,7 +1639,9 @@ document.addEventListener("submit", (e) => {
|
|
|
1498
1639
|
const port = f.port.value.trim();
|
|
1499
1640
|
if (!port) return;
|
|
1500
1641
|
const from = f.from.value.trim();
|
|
1501
|
-
|
|
1642
|
+
const action = f.action.value;
|
|
1643
|
+
e.target.reset();
|
|
1644
|
+
run(["firewall", action, port, ...sv(S.sel), ...(from && from !== "anywhere" ? ["--from", from] : [])], { server: S.sel });
|
|
1502
1645
|
});
|
|
1503
1646
|
|
|
1504
1647
|
function editRule(n, num, r) {
|
|
@@ -1513,7 +1656,8 @@ function editRule(n, num, r) {
|
|
|
1513
1656
|
],
|
|
1514
1657
|
ok: "Save",
|
|
1515
1658
|
onOk: (v) => {
|
|
1516
|
-
|
|
1659
|
+
// By its text, not its number: numbers shift when another firewall command lands first.
|
|
1660
|
+
const argv = ["firewall", "update", r.spec, ...sv(n)];
|
|
1517
1661
|
if (v.port.trim() && v.port.trim() !== port) argv.push("--port", v.port.trim());
|
|
1518
1662
|
argv.push("--from", v.from.trim() || "any");
|
|
1519
1663
|
if (v.action !== r.action) argv.push("--action", v.action);
|
|
@@ -1526,7 +1670,7 @@ function editRule(n, num, r) {
|
|
|
1526
1670
|
|
|
1527
1671
|
function settingsView(n) {
|
|
1528
1672
|
const st = S.settings[n] ?? { loading: true };
|
|
1529
|
-
if (st.loading) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
1673
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the settings…</div>`;
|
|
1530
1674
|
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1531
1675
|
const x = st.data;
|
|
1532
1676
|
const reload = { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "settings", true), render()) : undefined) };
|
|
@@ -1612,7 +1756,7 @@ function settingsView(n) {
|
|
|
1612
1756
|
|
|
1613
1757
|
function historyView(n) {
|
|
1614
1758
|
const st = S.history[n] ?? { loading: true };
|
|
1615
|
-
if (st.loading) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
|
|
1759
|
+
if (st.loading && !st.data) return `<div class="loading"><span class="spin"></span> reading the journal…</div>`;
|
|
1616
1760
|
if (st.error) return `<div class="note bad">${esc(st.error)}</div>`;
|
|
1617
1761
|
const runs = st.data;
|
|
1618
1762
|
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 +1794,7 @@ function historyView(n) {
|
|
|
1650
1794
|
return `
|
|
1651
1795
|
<div class="card">
|
|
1652
1796
|
<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>
|
|
1797
|
+
<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
1798
|
${items}
|
|
1655
1799
|
</div>`;
|
|
1656
1800
|
}
|
|
@@ -1662,16 +1806,28 @@ function render() {
|
|
|
1662
1806
|
// Every render re-registers the buttons it draws (dialogs wire their own buttons).
|
|
1663
1807
|
handlers = new Map();
|
|
1664
1808
|
const scroll = $("#main").scrollTop;
|
|
1809
|
+
// innerHTML drops the focused button: find its redrawn twin by its data-f key.
|
|
1810
|
+
const f = document.activeElement?.dataset?.f;
|
|
1811
|
+
// So is what someone was typing into the add-rule form.
|
|
1812
|
+
const form = $("#addrule"), typed = form && [...form.elements].filter((el) => el.name).map((el) => [el.name, el.value]);
|
|
1813
|
+
const typing = form?.contains(document.activeElement) && document.activeElement.name;
|
|
1665
1814
|
renderHeader();
|
|
1666
1815
|
renderNav();
|
|
1667
1816
|
renderMain();
|
|
1668
1817
|
renderJob();
|
|
1669
1818
|
$("#main").scrollTop = scroll;
|
|
1819
|
+
const again = typed && $("#addrule");
|
|
1820
|
+
if (again) {
|
|
1821
|
+
for (const [k, v] of typed) again.elements[k].value = v;
|
|
1822
|
+
if (typing) again.elements[typing].focus({ preventScroll: true });
|
|
1823
|
+
}
|
|
1824
|
+
if (f && !document.activeElement?.closest?.("#dlg, #menu")) [...document.querySelectorAll("[data-f]")].find((el) => el.dataset.f === f)?.focus({ preventScroll: true });
|
|
1825
|
+
document.title = S.sel ? `${S.sel} · ${S.tab} · landfall` : "landfall";
|
|
1670
1826
|
}
|
|
1671
1827
|
|
|
1672
1828
|
document.addEventListener("keydown", (e) => {
|
|
1673
1829
|
if (e.key === "Escape" && $("#menu")) return closeMenu();
|
|
1674
|
-
if (e.key === "Escape" && !$("#dlg").open && S.job) {
|
|
1830
|
+
if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
|
|
1675
1831
|
S.job = null;
|
|
1676
1832
|
render();
|
|
1677
1833
|
}
|
|
@@ -1687,17 +1843,22 @@ $("#add-server").onclick = () => addServer();
|
|
|
1687
1843
|
try {
|
|
1688
1844
|
await refreshState();
|
|
1689
1845
|
// Jobs started before a reload keep running on the server: list them, and show one still running.
|
|
1846
|
+
const old = [];
|
|
1690
1847
|
for (const info of S.state.jobs.slice().reverse()) {
|
|
1691
1848
|
const j = { info, lines: [], actions: [] };
|
|
1692
1849
|
S.jobs.set(info.id, j);
|
|
1693
1850
|
if (info.code === undefined) stream(j);
|
|
1694
|
-
else
|
|
1695
|
-
const full = await http(`/api/jobs/${info.id}`);
|
|
1696
|
-
j.lines = full.lines;
|
|
1697
|
-
}
|
|
1851
|
+
else old.push(j);
|
|
1698
1852
|
}
|
|
1699
1853
|
S.job = S.state.jobs.find((j) => j.code === undefined)?.id ?? null;
|
|
1700
1854
|
route();
|
|
1855
|
+
// Finished jobs' output only matters once someone opens them: fetch it after the page is up.
|
|
1856
|
+
for (const j of old) {
|
|
1857
|
+
http(`/api/jobs/${j.info.id}`).then((full) => {
|
|
1858
|
+
j.lines = full.lines;
|
|
1859
|
+
if (S.job === j.info.id) render();
|
|
1860
|
+
}, () => {});
|
|
1861
|
+
}
|
|
1701
1862
|
} catch (e) {
|
|
1702
1863
|
$("#main").innerHTML = `<div class="empty"><h2>lf ui</h2><p>${esc(e.message)}</p></div>`;
|
|
1703
1864
|
}
|