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/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
- --bg: #f6f7f9; --panel: #fff; --line: #e3e6eb; --text: #1b1f24; --dim: #6a7280; --faint: #9aa1ad;
11
- --accent: #2563eb; --accent-bg: #e8effd; --ok: #15803d; --ok-bg: #e5f5ea; --warn: #b45309; --warn-bg: #fdf3e2;
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: #5f6775;
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: 12.5px; }
32
+ code, .mono { font-family: var(--mono); font-size: var(--fs-sm); }
30
33
  button {
31
- font: inherit; font-size: 13px; border: 1px solid var(--line); background: var(--panel); color: var(--text);
32
- padding: 5px 11px; border-radius: 7px; cursor: pointer; white-space: nowrap;
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: 12px; }
47
+ button.sm { padding: 2px 8px; font-size: var(--fs-sm); }
44
48
  input, select, textarea {
45
- font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 6px 9px; width: 100%;
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: 2px solid var(--accent-bg); border-color: var(--accent); }
49
- textarea { font-family: var(--mono); font-size: 12.5px; min-height: 90px; }
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) minmax(380px, 40%); }
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: 12px; letter-spacing: 0; }
58
- .project { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; min-width: 0; }
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: 13px; }
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: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; display: flex; justify-content: space-between; }
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: 7px; }
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: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73
- .nav-none { color: var(--faint); font-size: 12.5px; padding: 6px 10px; }
74
- .star { color: var(--warn); font-size: 11px; }
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: 13px; margin-top: 2px; }
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: 12px; box-shadow: var(--shadow); }
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: 13px; border-bottom: 1px solid var(--line); }
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: 10px; box-shadow: var(--shadow); padding: 4px; display: grid; }
104
- .menu button { border: 0; background: none; text-align: left; padding: 6px 10px; border-radius: 6px; display: flex; justify-content: space-between; gap: 16px; }
105
- .menu button:hover, .menu button:focus-visible { background: var(--bg); outline: none; }
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: 11.5px; }
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: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); width: 100%; }
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: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 13px; color: var(--dim); }
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: 11.5px; color: var(--faint); text-transform: uppercase; letter-spacing: .04em; padding: 8px 12px; border-bottom: 1px solid var(--line); }
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: 11.5px; padding: 1px 7px; border-radius: 99px; background: var(--bg); color: var(--dim); border: 1px solid var(--line); }
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: 8px; background: var(--warn-bg); color: var(--warn); margin-bottom: 14px; }
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: 13px; margin: 0; }
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: 6px; overflow-x: auto; font-size: 12px; margin: 6px 0 0; }
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: 13px; }
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: 13px; }
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: 12.5px; color: var(--dim); }
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: 13.5px; }
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: 12px; color: var(--faint); margin-top: 3px; }
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: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
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: 13px; }
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: 12px; color: var(--dim); }
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: 12px; padding: 0; background: var(--panel); color: var(--text); box-shadow: var(--shadow); width: min(520px, calc(100vw - 32px)); }
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: 13.5px; }
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: 8px; padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
200
- legend { font-size: 12.5px; font-weight: 600; padding: 0 4px; }
201
- .toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 8px 14px; border-radius: 8px; font-size: 13px; z-index: 10; }
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: 12.5px; margin-top: 4px; }
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: 12px; opacity: .55; }
209
- .chip button:hover { opacity: 1; }
210
- .lock { font-size: 11px; margin-left: 4px; }
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: 8px; max-height: 260px; overflow: auto; }
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: 12.5px; }
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: 13px; padding: 8px 12px; border-radius: 8px; background: var(--bg); }
220
- .drop { border: 1.5px dashed var(--line); border-radius: 8px; padding: 12px; text-align: center; color: var(--dim); font-size: 13px; }
221
- .drop label { display: inline; color: var(--accent); cursor: pointer; font-size: 13px; }
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: 14px; background: color-mix(in srgb, var(--bg) 85%, transparent); color: var(--accent); font-size: 18px; font-weight: 600; pointer-events: none;
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: 13px; white-space: nowrap; }
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
- let t = $(".toast");
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(() => t.remove(), 3500);
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
- function btn(label, fn, cls = "", title = "") {
320
- return `<button type="button" class="${cls}" ${on(fn)}${title ? ` title="${esc(title)}"` : ""}>${label}</button>`;
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 === "-" ? "<hr>" : `<button type="button" role="menuitem" data-i="${i}" class="${it.cls ?? ""}"${it.title ? ` title="${esc(it.title)}"` : ""}><span>${esc(it.label)}</span>${it.hint ? `<span class="k">${esc(it.hint)}</span>` : ""}</button>`)
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
- function closeMenu() {
377
- $("#menu")?.remove();
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
- // The overview and facts drive the header, so every tab needs them.
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
- store[name] = { loading: true };
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
- for (const k of [...Object.values(STORE), "dns"]) delete S[k][name];
418
- if (S.sel === name) load(name, S.tab);
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.actions.push({ label: "Do it anyway (--force)", cls: "danger", run: () => run([...info.argv, "--force"], { server: info.server }) });
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", () => http(`/api/jobs/${info.id}/stop`, {}).catch((e) => toast(e.message)), "danger") : ""}
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) => `<button type="button" class="${o.info.id === S.job ? "on" : ""}" ${on(() => ((S.job = o.info.id), render()))}>
548
- <span>${o.info.code === undefined ? "…" : o.info.code === 0 ? "✓" : o.info.code === null ? "■" : "✗"}</span><span>lf ${esc(o.info.argv.join(" "))}</span></button>`)
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
- const out = $("#job .job-out");
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
- if (onOk(values) === false) return;
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 || $("button[type=submit]", form)).focus();
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 (mirror the terminal dashboard) -------------------------
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 false;
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 void toast(`${name} is not in your ssh config: give its IP or hostname`), false;
736
- if (port && !/^\d+$/.test(port)) return void toast("The port is a number"), false;
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 || !v.server) return false;
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
- if (!domains.length) return void toast("A site needs at least one domain"), false;
961
- run(["domains", m.name, ...sv(n), ...domains.flatMap((d) => ["--set", d]), "-y"], { server: n });
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 void toast("Give a user name"), false;
982
- if (v.password.length < 8) return void toast("The password needs at least 8 characters"), false;
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
- $("#project").innerHTML = `
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) => `<span class="dot ${isUp(status) ? (/unhealthy|restarting/i.test(status) ? "warn" : "up") : /^exited \(0\)/i.test(status) ? "" : "down"}"></span>`;
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) || render(), "", "Reload")}
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 (S.tab === "sites") view.innerHTML = sitesView(n, o);
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>&nbsp;<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
- { label: isUp(x.status) ? "Stop" : "Start", hint: `lf ${isUp(x.status) ? "stop" : "start"}`, run: () => run([isUp(x.status) ? "stop" : "start", name, x.service, ...sv(n)]) },
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 : { label: up ? "Stop" : "Start", hint: `lf ${up ? "stop" : "start"}`, run: () => run([up ? "stop" : "start", name, ...sv(n)]) },
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">🔒</span>` : ""} <span class="faint">${open ? "▾" : "▸"}</span></div>
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", num, "--action", flip, ...s]), "ghost sm", `Switch to ${flip}`)}</td>
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", num, ...s, "-y"]) }), "sm danger")}</td>
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
- run(["firewall", f.action.value, port, ...sv(S.sel), ...(from && from !== "anywhere" ? ["--from", from] : [])], { server: S.sel });
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
- const argv = ["firewall", "update", num, ...sv(n)];
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
  }