Widgets tab links the external demo when config.json names one (demo_url)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 08:58:36 -07:00
co-authored by Claude Fable 5.1
parent 65a9888784
commit 62605d57eb
7 changed files with 18 additions and 5 deletions
+3
View File
@@ -15,3 +15,6 @@ Anyone who can load the page can read the token, so serve it only where that's a
(it can read sensors and nothing else). Deployed on `core` as the `hamlab-demo` stack — (it can read sensors and nothing else). Deployed on `core` as the `hamlab-demo` stack —
see Hamlet `docs/docker.md`. Update it with see Hamlet `docs/docker.md`. Update it with
`cp examples/external-demo/index.html /home/core/apps/hamlab-demo/site/`. `cp examples/external-demo/index.html /home/core/apps/hamlab-demo/site/`.
To link it from the Widgets tab, set `"demo_url": "http://…"` in the hub's `config.json`
and restart Ham Lab.
+3 -1
View File
@@ -736,10 +736,12 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
} }
} }
_, rebootErr := os.Stat("/run/reboot-required") _, rebootErr := os.Stat("/run/reboot-required")
var demoURL string
s.cfg.View(func(c *config.Config) { demoURL = c.DemoURL })
jsonOK(w, 200, map[string]any{ jsonOK(w, 200, map[string]any{
"host": s.host, "user": s.user, "version": s.version, "host": s.host, "user": s.user, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started, "load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL,
}) })
} }
+3
View File
@@ -44,6 +44,9 @@ type Config struct {
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"` Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"` Nodes []Node `json:"nodes,omitempty"`
// DemoURL, when set, is linked from the Widgets tab: a page outside Ham Lab that
// shows the sensor API being used (examples/external-demo).
DemoURL string `json:"demo_url,omitempty"`
path string path string
mu sync.Mutex mu sync.Mutex
+6 -2
View File
@@ -31,7 +31,7 @@ function Tile({ spec, data, note }: { spec: WidgetSpec; data: WidgetData; note:
) )
} }
export default function Gallery() { export default function Gallery({ demoUrl }: { demoUrl?: string }) {
const [cat, setCat] = useState<Sensor[] | null>(null) const [cat, setCat] = useState<Sensor[] | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const rate = useRefreshMs() const rate = useRefreshMs()
@@ -54,7 +54,11 @@ export default function Gallery() {
return ( return (
<div className="dash gallery"> <div className="dash gallery">
<div className="dash-bar"><span className="hint">{sensors.length} sensors on this machine · live · tap a tile to copy its ID</span></div> <div className="dash-bar">
<span className="hint">{sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
<span className="spacer" />
{demoUrl && <a className="btn" href={demoUrl} target="_blank" rel="noreferrer" title="a page outside Ham Lab, built on the same sensor API">External demo ↗</a>}
</div>
<h2 className="section">Widget types</h2> <h2 className="section">Widget types</h2>
<div className="kpis"> <div className="kpis">
+1 -1
View File
@@ -179,7 +179,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'settings' && <Settings me={hubMe} />} {view === 'settings' && <Settings me={hubMe} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />} {view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery />} {view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />}
{view === 'procs' && <Procs />} {view === 'procs' && <Procs />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
{view === 'cron' && <Cron />} {view === 'cron' && <Cron />}
+1 -1
View File
@@ -51,7 +51,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
return r.json() return r.json()
} }
export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } export interface Me { demo_url?: string; host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
export interface PushResult { name: string; ok: boolean; version?: string; error?: string } export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string } export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string }
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number } export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
+1
View File
@@ -427,3 +427,4 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.gallery-tile .value { font-size: 18px; overflow-wrap: anywhere; } .gallery-tile .value { font-size: 18px; overflow-wrap: anywhere; }
.gallery-id { margin-top: auto; padding-top: 8px; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; } .gallery-id { margin-top: auto; padding-top: 8px; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
@media (max-width: 800px) { .gallery .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 800px) { .gallery .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
a.btn { text-decoration: none; display: inline-flex; align-items: center; }