6f87a600
feat(web): the console uses the width it is given
a73x 2026-08-11 04:55
Commit message
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 <vm-name></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 <vm-name></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; |