Quality of life: in-app dialogs, selections in the URL, editable widgets, token dialog, gallery search, status history, stream errors that say why, tab reordering and shortcuts, log out everywhere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8ee2a2d293
commit
d7ed4d8549
+43
-9
@@ -1,7 +1,8 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './sensors'
|
||||
import { api, Me } from './api'
|
||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||
import { Btn, Loading, toast } from './ui'
|
||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||
import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } from './prefs'
|
||||
|
||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||
@@ -33,6 +34,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
const [refresh, setRefresh] = useState(getRefreshMs())
|
||||
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null)
|
||||
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
|
||||
const [making, setMaking] = useState(false)
|
||||
const [cur, setCur] = useState('')
|
||||
const [pw1, setPw1] = useState('')
|
||||
const [pw2, setPw2] = useState('')
|
||||
@@ -59,7 +61,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') }
|
||||
}
|
||||
async function revoke(name: string) {
|
||||
if (!window.confirm(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
||||
if (!await askYes(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
||||
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
@@ -118,16 +120,18 @@ export default function Settings({ me }: { me: Me }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
<Btn className="btn" onClick={async () => {
|
||||
const name = window.prompt('Name for the read-only token (what will use it)', 'glance')?.trim()
|
||||
if (!name) return
|
||||
const pats = window.prompt('Limit it to sensor patterns? Comma-separated, e.g. cpu.*, temp.*, zfs.tank.* — leave empty for every sensor', '')
|
||||
if (pats === null) return
|
||||
try { setFresh(await api.createReadToken(name, pats.split(',').map((x) => x.trim()).filter(Boolean))); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||
}}>+ Read-only sensor token</Btn>
|
||||
<Btn className="btn" onClick={() => setMaking(true)}>+ Read-only sensor token</Btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Sessions</h2></div>
|
||||
<section className="card">
|
||||
<div className="muted small" style={{ marginBottom: 8 }}>Signs out every browser and phone that is logged in to this Ham Lab, this one included. Access tokens are not sessions and keep working.</div>
|
||||
<Btn className="btn danger" confirm="Log out everywhere? Every device, including this one, will need the password again." onClick={async () => { try { await api.logoutAll() } finally { window.dispatchEvent(new Event('hamlab:unauthenticated')) } }}>Log out everywhere</Btn>
|
||||
</section>
|
||||
|
||||
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
|
||||
|
||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||
<section className="card">
|
||||
<div className="props">
|
||||
@@ -141,3 +145,33 @@ export default function Settings({ me }: { me: Me }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One dialog for a read-only token: what will use it, and (optionally) which sensor
|
||||
// families it may see — picked from this machine's own catalog, or typed as patterns.
|
||||
function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
|
||||
const [name, setName] = useState('glance')
|
||||
const [pats, setPats] = useState('')
|
||||
const [fams, setFams] = useState<string[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
useEffect(() => { fetchCatalog({ machine: null }).then((c) => setFams([...new Set(c.map((s) => s.id.split('.')[0]))].sort())).catch(() => {}) }, [])
|
||||
const list = pats.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
const toggle = (f: string) => { const p = f === 'uptime' ? f : f + '.*'; setPats((list.includes(p) ? list.filter((x) => x !== p) : [...list, p]).join(', ')) }
|
||||
const make = async () => { try { onMade(await api.createReadToken(name.trim(), list)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}>
|
||||
<h3>Read-only sensor token</h3>
|
||||
<label className="muted">Name — what will use it
|
||||
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={64} spellCheck={false} autoCapitalize="off" />
|
||||
</label>
|
||||
<label className="muted">Limit it to these sensors — empty means every sensor
|
||||
<input className="mono" value={pats} onChange={(e) => setPats(e.target.value)} placeholder="cpu.*, temp.*, zfs.tank.*" spellCheck={false} autoCapitalize="off" />
|
||||
</label>
|
||||
<div className="chips">{fams.map((f) => <button type="button" key={f} className={'chip' + (list.includes(f === 'uptime' ? f : f + '.*') ? ' on' : '')} onClick={() => toggle(f)}>{f}</button>)}</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions"><button type="submit" className="btn on" disabled={!name.trim()}>Create</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user