a73x

6f87a600

feat(web): the console uses the width it is given

a73x   2026-08-11 04:55

Commit message
feat(web): the console uses the width it is given

The VM page learned to split when the display allowed it. The rest of the
console did not, so a host's page and your settings stayed a narrow ribbon
down the left of a wide screen.

The same split now runs through both, on the same terms: one breakpoint,
the wider share on the left, space alone dividing the columns, tables that
scroll inside their own box rather than being squeezed, and no grid at all
below the breakpoint—there the blocks stack in the order they are written.

A host reads as what it is beside what it is doing. The identity it
enrolled with and the machine it turned out to be run down the left. On
the right stands what you can do about it, then how full it is and how it
is coping. The VM list keeps the full measure below both columns: seven
columns want it.

Settings stands its two registers side by side. A token authenticates you
to the API and a CA authenticates you to a guest—they are peers, and no
longer a screen apart. Who you are sits above both, because it qualifies
either one.

The fleet page keeps its single column. Its two tables are 137 and 112
characters wide and already take the measure they are given; splitting
them would have cost every row its capacity figures and its buttons to
save nothing.

web/src/routes/hosts/[id]/+page.svelte
Old New
@@ -45,71 +45,86 @@
45 <p class="hint">Host not found (it may have been decommissioned).</p> 45 <p class="hint">Host not found (it may have been decommissioned).</p>
46 {:else} 46 {:else}
47 <h2>{host.name}</h2> 47 <h2>{host.name}</h2>
48 <table class="kv">
49 <tbody>
50 <tr><th>ID</th><td>{host.id}</td></tr>
51 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr>
52 <tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr>
53 <tr>
54 <th>Agent</th>
55 <td>
56 {host.agent_version || '—'}
57 {#if host.agent_update_available}
58 <button class="ghost upgrade" onclick={upgrade}>↑ {fleet.latest_version}</button>
59 {/if}
60 </td>
61 </tr>
62 <tr><th>Kernel</th><td>{host.kernel || '—'}</td></tr>
63 <tr><th>CPU</th><td>{host.cpu_model || '—'}</td></tr>
64 <tr><th>Virtualization</th><td>{host.virt || '—'}</td></tr>
65 <tr><th>Provisioner</th><td>{host.provisioner}</td></tr>
66 <tr>
67 <th>Guest network</th>
68 <td>
69 {#if host.bridge_cidr}
70 {host.bridge_cidr}
71 {:else}
72 <span class="hint">not yet reported</span>
73 {/if}
74 </td>
75 </tr>
76 <tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr>
77 <tr><th>Enrolled</th><td>{host.enrolled_at}</td></tr>
78 </tbody>
79 </table>
80 48
81 <h2>Resources</h2> 49 <div class="columns">
82 <div class="resources"> 50 <div class="facts">
83 <ResourceBar label="vCPU" used={host.allocated.vcpus} total={host.capacity.vcpus} /> 51 <div class="scroll">
84 <ResourceBar label="Memory" used={host.allocated.mem_mb} total={host.capacity.mem_mb} unit="MB" /> 52 <table class="kv">
85 <ResourceBar label="Disk" used={host.allocated.disk_gb} total={host.capacity.disk_gb} unit="GB" /> 53 <tbody>
86 </div> 54 <tr><th>ID</th><td>{host.id}</td></tr>
87 <p class="note"> 55 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr>
88 Allocated = sum of live VM specs. Capacity is reported by the agent (shown when online). 56 <tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr>
89 vCPU is commonly oversubscribed; memory and disk are hard limits. 57 <tr>
90 </p> 58 <th>Agent</th>
59 <td>
60 {host.agent_version || '—'}
61 {#if host.agent_update_available}
62 <button class="ghost upgrade" onclick={upgrade}>↑ {fleet.latest_version}</button>
63 {/if}
64 </td>
65 </tr>
66 <tr><th>Kernel</th><td>{host.kernel || '—'}</td></tr>
67 <tr><th>CPU</th><td>{host.cpu_model || '—'}</td></tr>
68 <tr><th>Virtualization</th><td>{host.virt || '—'}</td></tr>
69 <tr><th>Provisioner</th><td>{host.provisioner}</td></tr>
70 <tr>
71 <th>Guest network</th>
72 <td>
73 {#if host.bridge_cidr}
74 {host.bridge_cidr}
75 {:else}
76 <span class="hint">not yet reported</span>
77 {/if}
78 </td>
79 </tr>
80 <tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr>
81 <tr><th>Enrolled</th><td>{host.enrolled_at}</td></tr>
82 </tbody>
83 </table>
84 </div>
85 </div>
91 86
92 {#if host.online && host.metrics} 87 <div class="doing">
93 <h2>Host metrics (measured)</h2> 88 <div class="actions">
94 <table class="kv"> 89 {#if host.status !== 'decommissioning'}
95 <tbody> 90 <button class="danger" onclick={decommission}>Decommission host</button>
96 <tr><th>Uptime</th><td>{fmtUptime(host.metrics.uptime_s)}</td></tr> 91 {:else}
97 <tr><th>Load avg</th><td>{host.metrics.load1.toFixed(2)} / {host.metrics.load5.toFixed(2)} / {host.metrics.load15.toFixed(2)}</td></tr> 92 <span class="hint">Decommissioning—draining {vms.length} VM(s)…</span>
98 <tr><th>Memory used</th><td>{host.metrics.mem_used_mb}MB used · {host.metrics.mem_available_mb}MB available</td></tr> 93 {/if}
99 <tr><th>Disk used</th><td>{host.metrics.disk_used_gb}GB used · {host.metrics.disk_free_gb}GB free</td></tr> 94 </div>
100 </tbody>
101 </table>
102 <p class="note">
103 Measured off the host—distinct from the allocated bars above, which sum committed VM specs (quotas).
104 </p>
105 {/if}
106 95
107 {#if host.status !== 'decommissioning'} 96 <h2>Resources</h2>
108 <button class="danger" onclick={decommission}>Decommission host</button> 97 <div class="resources">
109 {:else} 98 <ResourceBar label="vCPU" used={host.allocated.vcpus} total={host.capacity.vcpus} />
110 <p class="hint">Decommissioning—draining {vms.length} VM(s)…</p> 99 <ResourceBar label="Memory" used={host.allocated.mem_mb} total={host.capacity.mem_mb} unit="MB" />
111 {/if} 100 <ResourceBar label="Disk" used={host.allocated.disk_gb} total={host.capacity.disk_gb} unit="GB" />
101 </div>
102 <p class="note">
103 Allocated = sum of live VM specs. Capacity is reported by the agent (shown when online).
104 vCPU is commonly oversubscribed; memory and disk are hard limits.
105 </p>
106
107 {#if host.online && host.metrics}
108 <h2>Host metrics (measured)</h2>
109 <div class="scroll">
110 <table class="kv">
111 <tbody>
112 <tr><th>Uptime</th><td>{fmtUptime(host.metrics.uptime_s)}</td></tr>
113 <tr><th>Load avg</th><td>{host.metrics.load1.toFixed(2)} / {host.metrics.load5.toFixed(2)} / {host.metrics.load15.toFixed(2)}</td></tr>
114 <tr><th>Memory used</th><td>{host.metrics.mem_used_mb}MB used · {host.metrics.mem_available_mb}MB available</td></tr>
115 <tr><th>Disk used</th><td>{host.metrics.disk_used_gb}GB used · {host.metrics.disk_free_gb}GB free</td></tr>
116 </tbody>
117 </table>
118 </div>
119 <p class="note">
120 Measured off the host—distinct from the allocated bars above, which sum committed VM specs (quotas).
121 </p>
122 {/if}
123 </div>
124 </div>
112 125
126 <!-- The VM list is a seven-column table and takes the full measure below
127 both columns, as the VM page's console does. -->
113 <h2>VMs on this host ({vms.length})</h2> 128 <h2>VMs on this host ({vms.length})</h2>
114 {#if vms.length === 0} 129 {#if vms.length === 0}
115 <p class="hint">None.</p> 130 <p class="hint">None.</p>
@@ -137,6 +152,45 @@
137 h2 { 152 h2 {
138 font-size: inherit; 153 font-size: inherit;
139 } 154 }
155 /* Given the width, the page splits the way a VM's does. What this host IS
156 reads down the left—the identity it enrolled with and the machine it
157 turned out to be. What it is doing right now, and what you can do about
158 it, stands on the right: decommission, then how full it is and how it is
159 coping. Space alone divides them—a vertical rule beside a ruled table
160 would read as a box. Narrower than the breakpoint there is no grid at
161 all: the blocks stack in source order. */
162 @media (min-width: 1100px) {
163 .columns {
164 display: grid;
165 /* The pilot's fractions. The identity table takes the wider share:
166 its rows are fixed key/value pairs that cannot reflow, while the
167 right column is built to give—the bars are a percentage and the
168 note wraps. */
169 grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
170 column-gap: 3em;
171 align-items: start;
172 }
173 /* Both columns start on the same line as the table's first rule. */
174 .doing .actions {
175 margin-top: 0.5em;
176 }
177 /* A column is not a page: a table keeps its natural width and scrolls
178 sideways inside its own box, so a CPU model or an enrolment stamp is
179 scrolled to rather than squeezed. The box belongs to the columns—
180 stacked, each table already has the full measure. */
181 .scroll {
182 overflow-x: auto;
183 }
184 .scroll table {
185 min-width: 100%;
186 }
187 }
188 .actions {
189 display: flex;
190 align-items: center;
191 gap: 0.5em;
192 margin-top: 0.8em;
193 }
140 .resources { 194 .resources {
141 max-width: 60ch; 195 max-width: 60ch;
142 } 196 }
web/src/routes/settings/+page.svelte
Old New
@@ -123,107 +123,116 @@
123 {/if} 123 {/if}
124 </section> 124 </section>
125 125
126 <section> 126 <!-- The two things a tenant registers are peers: a token authenticates you to
127 <div class="row"> 127 the API, a CA authenticates you to a guest. Given the width they stand side
128 <h2>Personal access tokens ({tokens.length})</h2> 128 by side; narrower, they stack in the order they are written. -->
129 </div> 129 <div class="columns">
130 <p class="hint"> 130 <section>
131 Use a PAT to authenticate the CLI and automation over the <code>EITRI_TOKEN</code> env var. The 131 <div class="row">
132 secret is shown once at creation and stored only as a hash. 132 <h2>Personal access tokens ({tokens.length})</h2>
133 </p> 133 </div>
134 <p class="hint">
135 Use a PAT to authenticate the CLI and automation over the <code>EITRI_TOKEN</code> env var.
136 The secret is shown once at creation and stored only as a hash.
137 </p>
134 138
135 {#if minted} 139 {#if minted}
136 <div class="secret-box"> 140 <div class="secret-box">
137 <div class="secret-head"> 141 <div class="secret-head">
138 <strong>Token “{minted.name}” created</strong> 142 <strong>Token “{minted.name}” created</strong>
139 <button type="button" class="ghost" onclick={() => (minted = null)}>Dismiss</button> 143 <button type="button" class="ghost" onclick={() => (minted = null)}>Dismiss</button>
144 </div>
145 <p class="warn">Copy it now—you won't be able to see this token again.</p>
146 <div class="secret-value">
147 <code id="minted-token">{minted.token}</code>
148 <button type="button" onclick={copySecret}>{copied ? 'Copied' : 'Copy'}</button>
149 </div>
140 </div> 150 </div>
141 <p class="warn">Copy it now—you won't be able to see this token again.</p> 151 {/if}
142 <div class="secret-value">
143 <code id="minted-token">{minted.token}</code>
144 <button type="button" onclick={copySecret}>{copied ? 'Copied' : 'Copy'}</button>
145 </div>
146 </div>
147 {/if}
148 152
149 <form class="pat-form" onsubmit={submitCreateToken}> 153 <form class="pat-form" onsubmit={submitCreateToken}>
150 <label>Name<input bind:value={patForm.name} placeholder="laptop-cli" /></label> 154 <label>Name<input bind:value={patForm.name} placeholder="laptop-cli" /></label>
151 <label 155 <label
152 >Expires 156 >Expires
153 <select bind:value={patForm.ttl}> 157 <select bind:value={patForm.ttl}>
154 {#each ttlPresets as p (p.seconds)} 158 {#each ttlPresets as p (p.seconds)}
155 <option value={p.seconds}>{p.label}</option> 159 <option value={p.seconds}>{p.label}</option>
156 {/each} 160 {/each}
157 </select> 161 </select>
158 </label> 162 </label>
159 <button type="submit" disabled={patBusy || !patForm.name.trim()}>{patBusy ? 'Creating…' : 'Create token'}</button> 163 <button type="submit" disabled={patBusy || !patForm.name.trim()}>{patBusy ? 'Creating…' : 'Create token'}</button>
160 </form> 164 </form>
161 165
162 {#if tokens.length > 0} 166 {#if tokens.length > 0}
163 <table> 167 <div class="scroll">
164 <thead> 168 <table>
165 <tr><th>Name</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr> 169 <thead>
166 </thead> 170 <tr><th>Name</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
167 <tbody> 171 </thead>
168 {#each tokens as t (t.id)} 172 <tbody>
169 <tr> 173 {#each tokens as t (t.id)}
170 <td>{t.name}</td> 174 <tr>
171 <td>{fmtTime(t.created_at, '—')}</td> 175 <td>{t.name}</td>
172 <td>{fmtTime(t.expires_at, 'never')}</td> 176 <td>{fmtTime(t.created_at, '—')}</td>
173 <td>{fmtTime(t.last_used_at, 'never')}</td> 177 <td>{fmtTime(t.expires_at, 'never')}</td>
174 <td class="actions"> 178 <td>{fmtTime(t.last_used_at, 'never')}</td>
175 {#if t.revoked_at} 179 <td class="actions">
176 <span class="hint">revoked</span> 180 {#if t.revoked_at}
177 {:else if confirmRevoke === t.id} 181 <span class="hint">revoked</span>
178 <button class="danger" onclick={() => doRevoke(t.id)}>Confirm</button> 182 {:else if confirmRevoke === t.id}
179 <button class="ghost" onclick={() => (confirmRevoke = '')}>Cancel</button> 183 <button class="danger" onclick={() => doRevoke(t.id)}>Confirm</button>
180 {:else} 184 <button class="ghost" onclick={() => (confirmRevoke = '')}>Cancel</button>
181 <button class="danger" onclick={() => (confirmRevoke = t.id)}>Revoke</button> 185 {:else}
182 {/if} 186 <button class="danger" onclick={() => (confirmRevoke = t.id)}>Revoke</button>
183 </td> 187 {/if}
184 </tr> 188 </td>
185 {/each} 189 </tr>
186 </tbody> 190 {/each}
187 </table> 191 </tbody>
188 {/if} 192 </table>
189 </section> 193 </div>
190 194 {/if}
191 <section> 195 </section>
192 <div class="row">
193 <h2>SSH Access ({fleet.userCAs.length})</h2>
194 </div>
195 196
196 {#if fleet.userCAs.length === 0} 197 <section>
197 <p class="hint">No SSH CA registered—a VM cannot be created until the tenant has one.</p> 198 <div class="row">
198 <div class="enroll"> 199 <h2>SSH Access ({fleet.userCAs.length})</h2>
199 Generate a CA, paste its public key below, then connect:
200 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
201 <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code>
202 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh &lt;vm-name&gt;</code>
203 </div> 200 </div>
204 {:else}
205 <table>
206 <thead>
207 <tr><th>Fingerprint</th><th>Label</th><th></th></tr>
208 </thead>
209 <tbody>
210 {#each fleet.userCAs as ca (ca.pubkey)}
211 <tr>
212 <td>{ca.fingerprint}</td>
213 <td>{ca.label || '—'}</td>
214 <td><button class="danger" onclick={() => removeCA(ca.pubkey)}>Remove</button></td>
215 </tr>
216 {/each}
217 </tbody>
218 </table>
219 {/if}
220 201
221 <form class="ca-add" onsubmit={submitUploadCA}> 202 {#if fleet.userCAs.length === 0}
222 <label>SSH CA public key<input bind:value={caForm.public_key} placeholder="ssh-ed25519 AAAA… (contents of your ~/.ssh/eitri_user_ca.pub)" /></label> 203 <p class="hint">No SSH CA registered—a VM cannot be created until the tenant has one.</p>
223 <label>Label (optional)<input bind:value={caForm.label} placeholder="laptop" /></label> 204 <div class="enroll">
224 <button type="submit" disabled={caBusy || !caForm.public_key.trim()}>{caBusy ? 'Adding…' : 'Add CA'}</button> 205 Generate a CA, paste its public key below, then connect:
225 </form> 206 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
226 </section> 207 <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code>
208 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh &lt;vm-name&gt;</code>
209 </div>
210 {:else}
211 <div class="scroll">
212 <table>
213 <thead>
214 <tr><th>Fingerprint</th><th>Label</th><th></th></tr>
215 </thead>
216 <tbody>
217 {#each fleet.userCAs as ca (ca.pubkey)}
218 <tr>
219 <td>{ca.fingerprint}</td>
220 <td>{ca.label || '—'}</td>
221 <td><button class="danger" onclick={() => removeCA(ca.pubkey)}>Remove</button></td>
222 </tr>
223 {/each}
224 </tbody>
225 </table>
226 </div>
227 {/if}
228
229 <form class="ca-add" onsubmit={submitUploadCA}>
230 <label>SSH CA public key<input bind:value={caForm.public_key} placeholder="ssh-ed25519 AAAA… (contents of your ~/.ssh/eitri_user_ca.pub)" /></label>
231 <label>Label (optional)<input bind:value={caForm.label} placeholder="laptop" /></label>
232 <button type="submit" disabled={caBusy || !caForm.public_key.trim()}>{caBusy ? 'Adding…' : 'Add CA'}</button>
233 </form>
234 </section>
235 </div>
227 236
228 <style> 237 <style>
229 .row { 238 .row {
@@ -235,6 +244,31 @@
235 font-size: inherit; 244 font-size: inherit;
236 margin: 1.5em 0 0.5em; 245 margin: 1.5em 0 0.5em;
237 } 246 }
247 /* Given the width, the two registers stand side by side instead of a screen
248 apart—the same breakpoint, fractions and gap the VM and host pages use, so
249 the console reads as one layout rather than three. Tokens take the wider
250 share: their table carries three timestamps and a name. Who you are sits
251 above both, because it qualifies either one. Narrower than the split there
252 is no grid at all: the sections stack in the order they are written. */
253 @media (min-width: 1100px) {
254 .columns {
255 display: grid;
256 grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
257 column-gap: 3em;
258 align-items: start;
259 }
260 /* A column is not a page: a table keeps its natural width and scrolls
261 sideways inside its own box, so a fingerprint is scrolled to rather
262 than squeezed. The rule stays inside the query—an always-on
263 overflow-x makes each box a formatting context, which stops the
264 margins collapsing and changes the stacked spacing. */
265 .scroll {
266 overflow-x: auto;
267 }
268 .scroll table {
269 min-width: 100%;
270 }
271 }
238 .actions { 272 .actions {
239 display: flex; 273 display: flex;
240 gap: 0.5em; 274 gap: 0.5em;