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/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,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
- function closeMenu() {
377
- $("#menu")?.remove();
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
- // 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) {
458
+ for (const t of needs(tab)) {
407
459
  const store = S[STORE[t]];
408
460
  if (store[name] && !force) continue;
409
- store[name] = { loading: true };
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
- for (const k of [...Object.values(STORE), "dns"]) delete S[k][name];
418
- if (S.sel === name) load(name, S.tab);
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.actions.push({ label: "Do it anyway (--force)", cls: "danger", run: () => run([...info.argv, "--force"], { server: info.server }) });
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", () => 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("")}
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) => `<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>`)
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
- const out = $("#job .job-out");
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
- if (onOk(values) === false) return;
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 || $("button[type=submit]", form)).focus();
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 (mirror the terminal dashboard) -------------------------
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 false;
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 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;
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 || !v.server) return false;
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
- 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 });
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 void toast("Give a user name"), false;
982
- if (v.password.length < 8) return void toast("The password needs at least 8 characters"), false;
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
- $("#project").innerHTML = `
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) => `<span class="dot ${isUp(status) ? (/unhealthy|restarting/i.test(status) ? "warn" : "up") : /^exited \(0\)/i.test(status) ? "" : "down"}"></span>`;
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) || render(), "", "Reload")}
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 (S.tab === "sites") view.innerHTML = sitesView(n, o);
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>&nbsp;<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
- { 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)]) },
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 : { label: up ? "Stop" : "Start", hint: `lf ${up ? "stop" : "start"}`, run: () => run([up ? "stop" : "start", name, ...sv(n)]) },
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">🔒</span>` : ""} <span class="faint">${open ? "▾" : "▸"}</span></div>
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", num, "--action", flip, ...s]), "ghost sm", `Switch to ${flip}`)}</td>
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", num, ...s, "-y"]) }), "sm danger")}</td>
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
- run(["firewall", f.action.value, port, ...sv(S.sel), ...(from && from !== "anywhere" ? ["--from", from] : [])], { server: S.sel });
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
- const argv = ["firewall", "update", num, ...sv(n)];
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
  }