Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf60deffc | ||
|
|
1eb59fd08c | ||
|
|
e184b4a149 |
@@ -252,6 +252,30 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
use", and "Remove unused" takes every image no container uses. Nothing is ever forced:
|
||||
Docker refuses what is still in use, and that refusal is the answer. Still absent:
|
||||
removing networks.
|
||||
- **Top merged into Processes** — **2026-09-23.** The two tabs answered the same question
|
||||
from the same data (Top already read `/api/procs`, the endpoint the Processes table was
|
||||
built on), so at Mitchell's instruction there is now one tab, **Processes**, showing what
|
||||
Top showed: the btop-style whole-machine screen. `Procs.tsx` is deleted; the `top` view
|
||||
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
|
||||
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
|
||||
Top sat rather than falling to the bottom.
|
||||
- **Dashboard retired** — **2026-09-23**, at Mitchell's instruction, the same reasoning that
|
||||
merged Top into Processes: the app repeated itself. The customisable module list is gone
|
||||
(`Dashboard.tsx` deleted), and with it `/api/prefs/dashboard`, `applyPrefs`, and the
|
||||
per-user `dashboard` blob in `state.json`. The Processes tab already shows those numbers
|
||||
on one screen. `fmtBytes`/`fmtRate` moved to `fmt.ts` (Disks, Files and Space use them);
|
||||
`MONITOR_HISTORY` and `seriesOf` went with the page. The default view after login, after
|
||||
switching machines, and when a hub-only page is left behind, is now **Processes**. A saved
|
||||
rail order naming `dashboard` degrades quietly — `orderBy` drops ids with no page.
|
||||
**Widgets stays and says what it is for**: a notice at the top explains these are for
|
||||
dashboards *outside* Ham Lab — Glance, Home Assistant, a wall display — served from
|
||||
`/embed/widget` with a read-only sensor token, not a second copy of the console's screens.
|
||||
**It also closed a real bug.** `applyPrefs` set the machine's sampling rate from
|
||||
`intervalMs` inside the dashboard layout, and its `else` branch reset the sampler to the
|
||||
default whenever that field was absent. Since layouts became per-user, one account saving
|
||||
a dashboard could silently reset the sampling rate for the whole machine, overriding the
|
||||
fleet refresh rate set in Settings. The sampler now has one source: `refreshMs` in the UI
|
||||
prefs, which the hub fans out, remembered per machine as `sampler_ms`.
|
||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||
@@ -487,6 +511,48 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
- *Not app work, but the trigger:* nothing here blocks starting the migration except the
|
||||
inventory. Do that first.
|
||||
|
||||
- **Rename the Space tab** — asked for 2026-09-23, not started. "Space" is vague; the tab
|
||||
is WizTree for a disk or pool. Candidates: **Usage**, **Disk Usage**, **Storage**. Note
|
||||
`Disks` already exists and `Storage` would muddle with it, so Usage is probably the one.
|
||||
The rename touches the rail label, the `View` id, the hash route (`#/<machine>/space`,
|
||||
which people may have bookmarked — decide whether to keep `space` as the id and change
|
||||
only the label), the footer's page name and `Space.tsx`'s own strings.
|
||||
|
||||
- **ZFS gets its own tab, under Disks** — asked for 2026-09-23, not started. Today ZFS is a
|
||||
section inside `Disks.tsx`: pools with health, capacity, fragmentation, scrub controls, a
|
||||
dataset tree and per-dataset snapshot lists with "Snapshot now". Moving it out makes room
|
||||
for what is actually wanted: **scheduled snapshots with a retention policy**, configured
|
||||
from the console rather than by hand. What exists to build on — `grid` runs `zfSnap`
|
||||
nightly at 02:30 from root cron with 6-week expiry (`zfsnap-cron-6w-daily.sh`, predates
|
||||
this repo), and Ham Lab's Cron tab can already read, write and run root crontabs, so the
|
||||
job can be managed rather than reinvented. Decide: does Ham Lab drive `zfSnap`/`zfs-auto-
|
||||
snapshot` if present, or write and own its own timer? Per-dataset policies or one for the
|
||||
pool? And it must show what a retention rule *will* delete before it runs.
|
||||
|
||||
- **Rename Cron to something generic** — asked for 2026-09-23, not started. Mitchell's
|
||||
reasoning: the Services tab is systemd but "Services" is the right generic name for it,
|
||||
so the scheduling tab should be named for the job it does, not the tool. **Scheduled** or
|
||||
**Jobs**. The tab already covers more than cron — `/etc/crontab`, `/etc/cron.d`, user
|
||||
crontabs, `run-parts` directories *and* systemd timers — so the generic name is the
|
||||
honest one. Same rename surface as the Space tab: rail label, `View` id, hash route,
|
||||
footer name.
|
||||
|
||||
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
|
||||
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
|
||||
which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
|
||||
What a future session has to settle before writing code: which stack (libvirt/KVM +
|
||||
`virsh` is the obvious one for VMs; LXC or Incus for containers, and Incus covers both);
|
||||
whether it manages what is already installed or installs it; where disk images live
|
||||
(`tank` on `grid`, local NVMe elsewhere); console access (SPICE/VNC proxied through the
|
||||
daemon, or a serial console over the existing terminal machinery, which would reuse
|
||||
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
||||
at all, which is every host here today. Worth scoping as its own plan before starting.
|
||||
|
||||
- **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
|
||||
General polish and ease of use. The **Deprecate copyparty**
|
||||
item names the heavier pieces (multi-select and bulk actions, folder download, resumable
|
||||
uploads, a gallery, search) — worth doing the two together, since they are the same tab.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
||||
|
||||
+4
-58
@@ -102,18 +102,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
s.sensors.Add(newStatusSensors(s))
|
||||
s.alerts = newAlertEngine(s)
|
||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||
var raw json.RawMessage
|
||||
var histSec int
|
||||
st.View(func(x *store.State) {
|
||||
raw, histSec = x.Dashboard, x.HistorySec
|
||||
if len(raw) == 0 {
|
||||
for _, u := range x.Users {
|
||||
raw = u.Dashboard
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
s.applyPrefs(raw)
|
||||
st.View(func(x *store.State) { histSec = x.HistorySec })
|
||||
if histSec > 0 {
|
||||
col.SetWindow(time.Duration(histSec) * time.Second)
|
||||
}
|
||||
@@ -289,8 +279,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
||||
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
||||
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
||||
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
||||
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
||||
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
||||
m.HandleFunc("/", s.static)
|
||||
s.mux = m
|
||||
@@ -930,15 +918,11 @@ func (s *Server) migrateUsers() {
|
||||
st.Sessions[tok] = ses
|
||||
}
|
||||
}
|
||||
if len(st.UI) > 0 || len(st.Dashboard) > 0 {
|
||||
u := st.UserState(first)
|
||||
if len(u.UI) == 0 {
|
||||
if len(st.UI) > 0 {
|
||||
if u := st.UserState(first); len(u.UI) == 0 {
|
||||
u.UI = st.UI
|
||||
}
|
||||
if len(u.Dashboard) == 0 {
|
||||
u.Dashboard = st.Dashboard
|
||||
}
|
||||
st.UI, st.Dashboard = nil, nil
|
||||
st.UI = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2282,44 +2266,6 @@ func (s *Server) pushHistoryWindow(seconds int) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
||||
|
||||
func (s *Server) getDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
jsonOK(w, 200, s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.Dashboard }))
|
||||
}
|
||||
|
||||
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
|
||||
if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' {
|
||||
jsonErr(w, 400, "expected a JSON object")
|
||||
return
|
||||
}
|
||||
if user := actorOf(r).User; user != "" {
|
||||
if err := s.store.Update(func(st *store.State) { st.UserState(user).Dashboard = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
s.applyPrefs(b)
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// applyPrefs picks the few layout fields the server acts on (just the interval).
|
||||
func (s *Server) applyPrefs(raw []byte) {
|
||||
var p struct {
|
||||
IntervalMs int `json:"intervalMs"`
|
||||
}
|
||||
if len(raw) == 0 || json.Unmarshal(raw, &p) != nil {
|
||||
return
|
||||
}
|
||||
if p.IntervalMs > 0 {
|
||||
s.metrics.SetInterval(time.Duration(p.IntervalMs) * time.Millisecond)
|
||||
} else {
|
||||
s.metrics.SetInterval(metrics.DefaultInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- terminals ----
|
||||
|
||||
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -41,17 +41,15 @@ func (s *Session) UnmarshalJSON(b []byte) error {
|
||||
// UserState is what follows an account rather than the machine: how the console looks
|
||||
// to that person and how their dashboard is laid out.
|
||||
type UserState struct {
|
||||
Dashboard json.RawMessage `json:"dashboard,omitempty"` // layout prefs, opaque to the server
|
||||
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
||||
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Terminals []Terminal `json:"terminals"`
|
||||
Sessions map[string]Session `json:"sessions"` // token -> session
|
||||
Users map[string]*UserState `json:"users,omitempty"`
|
||||
// Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they
|
||||
// move to the first account. Kept so an old state file still reads.
|
||||
Dashboard json.RawMessage `json:"dashboard,omitempty"`
|
||||
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
|
||||
// account. Kept so an old state file still reads.
|
||||
UI json.RawMessage `json:"ui,omitempty"`
|
||||
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
||||
import Sparkline from './Sparkline'
|
||||
import { Loading, Spinner, askYes, toast } from './ui'
|
||||
import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
|
||||
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
|
||||
import WidgetPicker from './WidgetPicker'
|
||||
|
||||
const MAX = 300
|
||||
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
||||
|
||||
// ---- module registry: the customizable blocks, in default order ----
|
||||
type Kind = 'tile' | 'card'
|
||||
interface Module { id: string; label: string; kind: Kind }
|
||||
const MODULES: Module[] = [
|
||||
{ id: 'cpu', label: 'CPU', kind: 'tile' },
|
||||
{ id: 'mem', label: 'Memory', kind: 'tile' },
|
||||
{ id: 'load', label: 'Load', kind: 'tile' },
|
||||
{ id: 'uptime', label: 'Uptime', kind: 'tile' },
|
||||
{ id: 'temps', label: 'Temperatures', kind: 'card' },
|
||||
{ id: 'fans', label: 'Fans', kind: 'card' },
|
||||
{ id: 'net', label: 'Network', kind: 'card' },
|
||||
{ id: 'disks', label: 'Disks', kind: 'card' },
|
||||
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
||||
{ id: 'ups', label: 'UPS', kind: 'card' },
|
||||
]
|
||||
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
||||
|
||||
const DEFAULT_INTERVAL = 2000
|
||||
const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
|
||||
|
||||
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
|
||||
|
||||
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
|
||||
function normalize(raw: DashPrefs): Layout {
|
||||
// Saved prefs are whatever some client last wrote; a wrong shape must not take the page down.
|
||||
const list = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
|
||||
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
|
||||
const known = new Set(MODULES.map((m) => m.id))
|
||||
const order = (p.order ?? []).filter((id) => known.has(id))
|
||||
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
|
||||
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
|
||||
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv,
|
||||
widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') }
|
||||
}
|
||||
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets })
|
||||
|
||||
// ---- formatting ----
|
||||
// A live value either starts a new point or is averaged into the one being built — the same
|
||||
// rule the machine itself uses to keep its history, so the tail of a six-hour graph isn't a
|
||||
// minute of 2-second samples crushed against the right edge. n is how many values this point
|
||||
// has had, including this one.
|
||||
function pushPoint(arr: number[], v: number, fresh: boolean, n: number) {
|
||||
if (!fresh && arr.length) { arr[arr.length - 1] += (v - arr[arr.length - 1]) / n; return }
|
||||
arr.push(v)
|
||||
if (arr.length > MAX) arr.shift()
|
||||
}
|
||||
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
|
||||
|
||||
export function fmtBytes(b: number): string {
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
let i = 0
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
||||
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
||||
}
|
||||
export function fmtRate(bps: number): string {
|
||||
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
|
||||
let i = 0
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
|
||||
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
|
||||
}
|
||||
function fmtUptime(s: number): string {
|
||||
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`
|
||||
}
|
||||
const pct = (v: number) => `${Math.round(v)}%`
|
||||
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' { return frac >= bad ? 'bad' : frac >= warn ? 'warn' : '' }
|
||||
|
||||
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
|
||||
function collapseTemps(temps: Temp[]): (Temp & { key: string })[] {
|
||||
const byChip = new Map<string, Temp[]>()
|
||||
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
|
||||
const out: (Temp & { key: string })[] = []
|
||||
for (const [chip, list] of byChip) {
|
||||
if (list.length <= 8) { out.push(...list.map((t) => ({ ...t, key: `${t.chip}/${t.label}` }))); continue }
|
||||
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
|
||||
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip, key: `${chip}/*` })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- small building blocks ----
|
||||
function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
||||
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
||||
}
|
||||
function Legend({ items }: { items: { name: string; color: string }[] }) {
|
||||
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
on: boolean
|
||||
hideItem: (key: string) => void
|
||||
unhideItem: (key: string) => void
|
||||
hiddenItems: Set<string>
|
||||
}
|
||||
// In customize mode every row gets a ✕; hidden rows come back as chips under the module.
|
||||
function HideBtn({ k, edit }: { k: string; edit: Edit }) {
|
||||
return edit.on ? <button className="hide-item" title="hide" onClick={() => edit.hideItem(k)}>✕</button> : null
|
||||
}
|
||||
function HiddenChips({ prefix, edit, label }: { prefix: string; edit: Edit; label: (k: string) => string }) {
|
||||
if (!edit.on) return null
|
||||
const keys = [...edit.hiddenItems].filter((k) => k.startsWith(prefix + ':'))
|
||||
if (keys.length === 0) return null
|
||||
return <div className="chips inline">{keys.map((k) => <button key={k} className="chip" title="show again" onClick={() => edit.unhideItem(k)}>+ {label(k.slice(prefix.length + 1))}</button>)}</div>
|
||||
}
|
||||
|
||||
// ---- module bodies ----
|
||||
interface Ctx { s: Sample; h: Series | null; edit: Edit }
|
||||
|
||||
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
|
||||
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
|
||||
}
|
||||
|
||||
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
||||
const shown = (key: string) => !edit.hiddenItems.has(key)
|
||||
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
||||
switch (id) {
|
||||
case 'cpu':
|
||||
return (
|
||||
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
|
||||
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
|
||||
</Tile>
|
||||
)
|
||||
case 'mem':
|
||||
return (
|
||||
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
|
||||
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
|
||||
</Tile>
|
||||
)
|
||||
case 'load':
|
||||
return <Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
|
||||
case 'uptime':
|
||||
return <Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
|
||||
|
||||
case 'temps': {
|
||||
const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`))
|
||||
return (
|
||||
<>
|
||||
<h2>Temperatures</h2>
|
||||
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
|
||||
{rows.map((t) => {
|
||||
const limit = t.crit || t.max || 100
|
||||
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
|
||||
return (
|
||||
<div className="row" key={t.key}>
|
||||
<span className="k">{t.label}<small>{t.chip}</small></span>
|
||||
<Meter frac={t.c / limit} cls={cls} />
|
||||
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}<HideBtn k={`temp:${t.key}`} edit={edit} /></span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<HiddenChips prefix="temp" edit={edit} label={(k) => k.replace('/*', '')} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
case 'fans': {
|
||||
const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`))
|
||||
return (
|
||||
<>
|
||||
<h2>Fans</h2>
|
||||
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
|
||||
{rows.map((f) => (
|
||||
<div className="row" key={`${f.chip}/${f.label}`}>
|
||||
<span className="k">{f.label}<small>{f.chip}</small></span>
|
||||
<span />
|
||||
<span className="num">{f.rpm} rpm<HideBtn k={`fan:${f.chip}/${f.label}`} edit={edit} /></span>
|
||||
</div>
|
||||
))}
|
||||
<HiddenChips prefix="fan" edit={edit} label={(k) => k} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
case 'net':
|
||||
return (
|
||||
<>
|
||||
<h2>Network</h2>
|
||||
{s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => (
|
||||
<div className="sub-card" key={x.iface}>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (x.up ? 'ok' : '')} />
|
||||
<span>{x.iface}</span>
|
||||
{!x.up && <span className="muted small">down</span>}
|
||||
<span className="spacer" />
|
||||
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
|
||||
<HideBtn k={`net:${x.iface}`} edit={edit} />
|
||||
</div>
|
||||
{x.up && h?.net[x.iface] && (
|
||||
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
|
||||
)}
|
||||
{x.up && <div className="legend">
|
||||
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
|
||||
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
|
||||
</div>}
|
||||
</div>
|
||||
))}
|
||||
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
|
||||
</>
|
||||
)
|
||||
case 'disks':
|
||||
return (
|
||||
<>
|
||||
<h2>Disks</h2>
|
||||
{s.disks.length === 0 && <div className="empty">No block devices found.</div>}
|
||||
{s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => (
|
||||
<div className="sub-card" key={d.name}>
|
||||
<div className="sub-head">
|
||||
<span>{d.name}</span>
|
||||
<span className="model">{d.model}</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
|
||||
<HideBtn k={`disk:${d.name}`} edit={edit} />
|
||||
</div>
|
||||
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
|
||||
{h?.disk[d.name] && (
|
||||
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
|
||||
)}
|
||||
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
|
||||
</div>
|
||||
))}
|
||||
<HiddenChips prefix="disk" edit={edit} label={(k) => k} />
|
||||
</>
|
||||
)
|
||||
case 'fs':
|
||||
return (
|
||||
<>
|
||||
<h2>Filesystems</h2>
|
||||
{s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => (
|
||||
<div className="row" key={f.mount}>
|
||||
<span className="k">{f.mount}<small>{f.type}</small></span>
|
||||
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
|
||||
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}<HideBtn k={`fs:${f.mount}`} edit={edit} /></span>
|
||||
</div>
|
||||
))}
|
||||
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
||||
</>
|
||||
)
|
||||
case 'ups':
|
||||
return (
|
||||
<>
|
||||
<h2>UPS</h2>
|
||||
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
|
||||
{s.ups && (
|
||||
<>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
|
||||
<span>{s.ups.state} · {s.ups.source}</span>
|
||||
<span className="spacer" />
|
||||
<span className="model">{s.ups.model} · {s.ups.driver}</span>
|
||||
</div>
|
||||
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
|
||||
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
|
||||
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
|
||||
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- the dashboard ----
|
||||
export default function Dashboard() {
|
||||
const [latest, setLatest] = useState<Sample | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [layout, setLayout] = useState<Layout>(() => normalize({}))
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
|
||||
const savedTimer = useRef<number | undefined>(undefined)
|
||||
const hist = useRef<Series | null>(null)
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const refreshMs = useRefreshMs()
|
||||
const historySec = useHistorySec()
|
||||
|
||||
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
|
||||
const update = useCallback((fn: (l: Layout) => Layout) => {
|
||||
setLayout((prev) => {
|
||||
const next = fn(prev)
|
||||
clearTimeout(saveTimer.current)
|
||||
setSaving('saving')
|
||||
saveTimer.current = window.setTimeout(async () => {
|
||||
try {
|
||||
await api.saveDashPrefs(toPrefs(next))
|
||||
setSaving('saved')
|
||||
clearTimeout(savedTimer.current)
|
||||
savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
|
||||
} catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}, 300)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
|
||||
return () => { stop = true }
|
||||
}, [])
|
||||
|
||||
// Everything on this page comes from the sensor API: the catalog for names, labels and
|
||||
// thresholds; one batch of histories to seed the curves; one stream for the values.
|
||||
const catalog = useRef<Catalog>(new Map())
|
||||
const refetching = useRef(false)
|
||||
const binN = useRef(1) // values averaged into the point currently being built
|
||||
const lastPush = useRef(0) // when that point was started, by this browser's clock
|
||||
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
|
||||
const seed = useCallback(async () => {
|
||||
try {
|
||||
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
|
||||
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
|
||||
lastPush.current = Date.now(); binN.current = 1
|
||||
setErr('')
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill
|
||||
useEffect(() => {
|
||||
if (!stock.data || catalog.current.size === 0) return
|
||||
const v: Values = mergeValues(stock.data)
|
||||
if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels
|
||||
refetching.current = true
|
||||
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
|
||||
}
|
||||
const s = sampleOf(v, catalog.current)
|
||||
const h = hist.current
|
||||
if (h) {
|
||||
// The stride is timed on this clock alone (lastPush), never against the history's own
|
||||
// timestamps: those are the machine's, and a viewer whose clock runs minutes slow would
|
||||
// otherwise wait those minutes before its curves gained a point.
|
||||
const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs)
|
||||
const fresh = np.fresh
|
||||
lastPush.current = np.start
|
||||
binN.current = fresh ? 1 : binN.current + 1
|
||||
if (fresh) pushPoint(h.t, s.t, true, 1)
|
||||
const n = h.t.length
|
||||
// a curve that has no point in this one yet starts one, whatever the others are doing:
|
||||
// an interface or disk that appeared mid-point must still line up with the time axis
|
||||
const push = (arr: number[], x: number) => pushPoint(arr, x, fresh || arr.length < n, binN.current)
|
||||
push(h.cpu, s.cpu); push(h.mem, s.mem.used)
|
||||
for (const x of s.net) {
|
||||
const c = (h.net[x.iface] ??= { rx: [], tx: [] })
|
||||
pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps)
|
||||
}
|
||||
for (const d of s.disks) {
|
||||
const c = (h.disk[d.name] ??= { r: [], w: [], u: [] })
|
||||
pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct)
|
||||
}
|
||||
for (const t of s.temps) {
|
||||
const c = (h.temp[`${t.chip}/${t.label}`] ??= [])
|
||||
pad(c, n - 1); push(c, t.c)
|
||||
}
|
||||
}
|
||||
setLatest(s)
|
||||
}, [stock.data, historySec, refreshMs])
|
||||
useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest])
|
||||
|
||||
// The sampler on this machine follows the global rate (clamped to what it supports).
|
||||
useEffect(() => {
|
||||
if (!loaded) return
|
||||
const want = clampSampler(refreshMs)
|
||||
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
|
||||
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [editingWidget, setEditingWidget] = useState<WidgetSpec | null>(null)
|
||||
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
|
||||
|
||||
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
||||
|
||||
const edit: Edit = {
|
||||
on: editing,
|
||||
hiddenItems: layout.hiddenItems,
|
||||
hideItem: (k) => update((l) => ({ ...l, hiddenItems: new Set([...l.hiddenItems, k]) })),
|
||||
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
|
||||
}
|
||||
const ctx: Ctx = { s: latest, h: hist.current, edit }
|
||||
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
|
||||
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
|
||||
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
|
||||
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
|
||||
|
||||
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
|
||||
const move = (id: string, dir: -1 | 1) => update((l) => {
|
||||
const kind = byId(id)!.kind
|
||||
const same = l.order.filter((x) => byId(x)?.kind === kind && !l.hidden.has(x))
|
||||
const i = same.indexOf(id), j = i + dir
|
||||
if (j < 0 || j >= same.length) return l
|
||||
const order = [...l.order]
|
||||
const a = order.indexOf(id), b = order.indexOf(same[j])
|
||||
;[order[a], order[b]] = [order[b], order[a]]
|
||||
return { ...l, order }
|
||||
})
|
||||
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
|
||||
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
|
||||
const reset = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
|
||||
|
||||
const tools = (id: string, kind: Kind) => {
|
||||
if (!editing) return null
|
||||
const same = visible(kind), i = same.indexOf(id)
|
||||
return (
|
||||
<div className="mod-tools">
|
||||
<button title="move up" disabled={i === 0} onClick={() => move(id, -1)}>▲</button>
|
||||
<button title="move down" disabled={i === same.length - 1} onClick={() => move(id, 1)}>▼</button>
|
||||
<button title="hide module" onClick={() => hideMod(id)}>hide</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'dash' + (editing ? ' editing' : '')}>
|
||||
<div className="dash-bar">
|
||||
{err && <span className="error">{err}</span>}
|
||||
{editing ? <span className="hint">last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
|
||||
: <span className="hint">last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
|
||||
<span className="spacer" />
|
||||
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
|
||||
{saving === 'saved' && <span className="saved">saved ✓</span>}
|
||||
{editing && <button className="btn" onClick={reset}>Reset</button>}
|
||||
<button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
|
||||
</div>
|
||||
|
||||
{visible('tile').length > 0 && (
|
||||
<div className="kpis">
|
||||
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{(layout.widgets.length > 0 || editing) && (
|
||||
<div className="kpis widgets">
|
||||
{layout.widgets.map((w, i) => (
|
||||
<div className={'tile w-' + w.type} key={w.id}>
|
||||
{editing && (
|
||||
<div className="mod-tools">
|
||||
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
|
||||
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
|
||||
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
|
||||
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
|
||||
</div>
|
||||
)}
|
||||
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
|
||||
</div>
|
||||
))}
|
||||
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
||||
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering: {live.error}.</div>}
|
||||
</div>
|
||||
)}
|
||||
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
|
||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
||||
{visible('card').length > 0 && (
|
||||
<div className="cards">
|
||||
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && hiddenMods.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>Hidden modules</h2>
|
||||
<div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
|
||||
import { fmtBytes, fmtRate } from './Dashboard'
|
||||
import { fmtBytes, fmtRate } from './fmt'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import { Btn, Loading, Spinner, askText, toast } from './ui'
|
||||
import { useRefreshMs } from './prefs'
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, FileEntry, FileInfo } from './api'
|
||||
import { fmtBytes } from './Dashboard'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||
import { useHashSel } from './hashsel'
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ export default function Gallery() {
|
||||
|
||||
return (
|
||||
<div className="dash gallery">
|
||||
<div className="notice" style={{ marginBottom: 10 }}>
|
||||
These widgets are for <b>dashboards outside Ham Lab</b> — Glance, Home Assistant, a wall
|
||||
display. Each one is a page at <span className="mono">/embed/widget</span> you put in an
|
||||
iframe, reading this machine through a <b>read-only sensor token</b> (Settings → Access
|
||||
tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them
|
||||
to something else.
|
||||
</div>
|
||||
<div className="dash-bar">
|
||||
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
||||
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { api, Proc, ProcTable } from './api'
|
||||
import { fmtBytes, fmtRate } from './Dashboard'
|
||||
import { Btn, Loading, askYes, toast } from './ui'
|
||||
import { useRefreshMs } from './prefs'
|
||||
|
||||
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
|
||||
const pct = (v: number) => `${Math.round(v)}%`
|
||||
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` }
|
||||
const fmtDur = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}` : `${s.toFixed(1)}s`)
|
||||
const fmtStart = (ms: number) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) }
|
||||
const STATE: Record<string, string> = { R: 'running', S: 'sleeping', D: 'disk wait', Z: 'zombie', T: 'stopped', t: 'traced', I: 'idle' }
|
||||
|
||||
const BLOCKS = '▁▂▃▄▅▆▇█'
|
||||
|
||||
// btop-style gradient bar: the track carries the green→yellow→red gradient and a cover
|
||||
// from the right hides the unfilled part, so colour depends on position not on level.
|
||||
function GBar({ frac }: { frac: number }) {
|
||||
return <div className="gbar"><div className="gcover" style={{ width: `${100 - Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
||||
}
|
||||
function Row({ label, frac, value }: { label: string; frac: number; value: string }) {
|
||||
return <div className="brow"><span className="bl">{label}</span><GBar frac={frac} /><span className="bv">{value}</span></div>
|
||||
}
|
||||
// Block-character graph, coloured per column by its value, like btop's cpu graph.
|
||||
function Graph({ values, max = 100 }: { values: number[]; max?: number }) {
|
||||
const cols = 60
|
||||
const v = values.slice(-cols)
|
||||
const pad = cols - v.length
|
||||
return (
|
||||
<div className="graph" aria-hidden="true">
|
||||
{pad > 0 && <span className="gpad">{'\u2581'.repeat(pad)}</span>}
|
||||
{v.map((x, i) => { const f = Math.min(1, Math.max(0, x / max)); return <span key={i} className={f >= 0.8 ? 'g3' : f >= 0.5 ? 'g2' : 'g1'}>{BLOCKS[Math.min(7, Math.round(f * 7))]}</span> })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function Box({ title, right, children, className = '' }: { title: string; right?: string; children: React.ReactNode; className?: string }) {
|
||||
return <section className={'box ' + className}><span className="box-title">{title}</span>{right && <span className="box-right">{right}</span>}{children}</section>
|
||||
}
|
||||
|
||||
export default function Procs() {
|
||||
const [t, setT] = useState<ProcTable | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [sort, setSort] = useState<Col>('cpu')
|
||||
const [asc, setAsc] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
const [mine, setMine] = useState(false)
|
||||
const [sel, setSel] = useState<number | null>(null)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [showTop, setShowTop] = useState<boolean>(() => localStorage.getItem('hamlab.procs.top') !== '0')
|
||||
const interval = useRefreshMs()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load, paused, interval])
|
||||
|
||||
const head = (c: Col, label: string, cls = '') => (
|
||||
<button className={'th ' + cls + (sort === c ? ' on' : '')} title={{ cpu: 'share of one core, right now', rss: 'resident memory (RSS)', cpu_time: 'total CPU time used since it started', started: 'when the process started', pid: 'process id', name: 'program name', user: 'owner' }[c]} onClick={() => { if (sort === c) setAsc(!asc); else { setSort(c); setAsc(c === 'name' || c === 'user' || c === 'pid') } }}>
|
||||
{label}{sort === c ? (asc ? ' ▲' : ' ▼') : ''}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!t) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Reading processes…" />}</div>
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = t.procs
|
||||
.filter((p) => (!needle || p.name.toLowerCase().includes(needle) || (p.cmd ?? '').toLowerCase().includes(needle) || p.user.toLowerCase().includes(needle) || String(p.pid) === needle) && (!mine || p.user !== 'root'))
|
||||
.sort((a, b) => {
|
||||
const va = a[sort] as number | string, vb = b[sort] as number | string
|
||||
const r = typeof va === 'string' ? (va as string).localeCompare(vb as string) : (va as number) - (vb as number)
|
||||
return asc ? r : -r
|
||||
})
|
||||
const selected = sel !== null ? t.procs.find((p) => p.pid === sel) : undefined
|
||||
const memFrac = t.mem.total ? t.mem.used / t.mem.total : 0
|
||||
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
|
||||
|
||||
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
|
||||
if (!await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
|
||||
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="procs-page">
|
||||
{showTop && <div className="procs-top">
|
||||
<Box title="cpu" right={`${t.cpu_model ?? ''}${t.cpu_mhz ? ` · ${(t.cpu_mhz / 1000).toFixed(2)}${t.cpu_max_mhz ? ` / ${(t.cpu_max_mhz / 1000).toFixed(1)}` : ''} GHz` : ''}${t.cpu_temp ? ` · ${t.cpu_temp.toFixed(0)}°C` : ''}`}>
|
||||
<div className="cpu-total">
|
||||
<Graph values={t.cpu_hist} />
|
||||
<div className="cpu-total-v"><span className="bl">CPU</span><span className="bv big">{pct(t.cpu)}</span><span className="muted small">total, all cores</span></div>
|
||||
</div>
|
||||
<div className="cores">
|
||||
{t.cores.map((c, i) => <Row key={i} label={`C${i}`} frac={c / 100} value={pct(c)} />)}
|
||||
</div>
|
||||
<div className="box-foot muted">
|
||||
<span>load avg <b>{t.load[0].toFixed(2)}</b> {t.load[1].toFixed(2)} {t.load[2].toFixed(2)} <small>(1 · 5 · 15 min, {t.cores.length} cores)</small></span>
|
||||
<span>up <b>{fmtUptime(t.uptime)}</b></span>
|
||||
<span><b>{t.total}</b> tasks, <b>{t.running}</b> running</span>
|
||||
</div>
|
||||
</Box>
|
||||
<Box title="mem" right={`${fmtBytes(t.mem.total)} total`}>
|
||||
<Row label="Used" frac={memFrac} value={`${fmtBytes(t.mem.used)} · ${pct(memFrac * 100)}`} />
|
||||
<Row label="Available" frac={t.mem.total ? t.mem.avail / t.mem.total : 0} value={fmtBytes(t.mem.avail)} />
|
||||
<Row label="Cached" frac={t.mem.total ? (t.mem.cached ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.cached ?? 0)} />
|
||||
<Row label="Free" frac={t.mem.total ? (t.mem.free ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.free ?? 0)} />
|
||||
{t.swap.total > 0 ? (
|
||||
<><div className="box-sub">swap · {fmtBytes(t.swap.total)} total</div>
|
||||
<Row label="Used" frac={swapFrac} value={`${fmtBytes(t.swap.used)} · ${pct(swapFrac * 100)}`} />
|
||||
<Row label="Free" frac={t.swap.total ? t.swap.avail / t.swap.total : 0} value={fmtBytes(t.swap.avail)} /></>
|
||||
) : <div className="box-sub muted">no swap</div>}
|
||||
<div className="box-foot muted small">Used = total − available. Cached is file cache the kernel gives back under pressure, so "available" is the honest headroom.</div>
|
||||
</Box>
|
||||
<Box title="net" className="netbox">
|
||||
{t.net.filter((n) => n.up).map((n) => (
|
||||
<div className="netrow" key={n.iface}>
|
||||
<span className="bl mono">{n.iface}</span>
|
||||
<span className="bv"><span className="dn">▼ {fmtRate(n.rx_bps)}</span> <span className="upc">▲ {fmtRate(n.tx_bps)}</span></span>
|
||||
<span className="muted small">{fmtBytes(n.rx_total)} in · {fmtBytes(n.tx_total)} out</span>
|
||||
</div>
|
||||
))}
|
||||
{t.net.filter((n) => n.up).length === 0 && <div className="muted">no interfaces up</div>}
|
||||
</Box>
|
||||
</div>}
|
||||
<div className="procs-sticky">
|
||||
<div className="procs-tools">
|
||||
<button className={'chip' + (showTop ? ' on' : '')} title="show or hide the cpu/mem/net boxes" onClick={() => { setShowTop(!showTop); localStorage.setItem('hamlab.procs.top', showTop ? '0' : '1') }}>{showTop ? 'metrics ▴' : 'metrics ▾'}</button>
|
||||
<input placeholder="filter name, command, user, pid" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className={'chip' + (mine ? ' on' : '')} onClick={() => setMine(!mine)}>hide root</button>
|
||||
<button className={'chip' + (paused ? ' on' : '')} onClick={() => setPaused(!paused)}>{paused ? 'paused' : 'live'}</button>
|
||||
<span className="muted small">{rows.length} shown</span>
|
||||
</div>
|
||||
|
||||
<div className="prow phead">
|
||||
{head('pid', 'PID', 'num')}{head('user', 'User')}{head('name', 'Program')}{head('cpu', 'CPU %', 'num')}{head('rss', 'Memory', 'num')}{head('cpu_time', 'CPU time', 'num')}{head('started', 'Started', 'num')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ptable">
|
||||
{rows.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
{rows.map((p) => (
|
||||
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
|
||||
<span className="num muted">{p.pid}</span>
|
||||
<span className="ell">{p.user}</span>
|
||||
<span className="ell mono">{p.name}{p.threads > 1 && <small className="muted"> ×{p.threads}</small>}</span>
|
||||
<span className={'num' + (p.cpu >= 50 ? ' hot' : '')}>{p.cpu.toFixed(1)}</span>
|
||||
<span className="num">{fmtBytes(p.rss)}</span>
|
||||
<span className="num muted">{fmtDur(p.cpu_time)}</span>
|
||||
<span className="num muted">{fmtStart(p.started)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="pdetail">
|
||||
<div className="sub-head"><b className="mono">{selected.name}</b><span className="muted">pid {selected.pid} · parent {selected.ppid} · {selected.user} · {STATE[selected.state] ?? selected.state} · nice {selected.nice} · {selected.threads} threads</span><span className="spacer" /><button className="icon-btn" onClick={() => setSel(null)}>✕</button></div>
|
||||
<div className="mono cmdline">{selected.cmd || '(kernel thread)'}</div>
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn" onClick={() => signal(selected, 'TERM')}>Terminate</Btn>
|
||||
<Btn className="btn danger" onClick={() => signal(selected, 'KILL')}>Kill −9</Btn>
|
||||
<span className="muted">cpu {selected.cpu.toFixed(1)}% · {fmtBytes(selected.rss)} · running {fmtDur(selected.cpu_time)} of cpu since {new Date(selected.started).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+8
-14
@@ -4,7 +4,6 @@ import Rail, { Group } from './Rail'
|
||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||
import TermTabs from './TermTabs'
|
||||
import KeyBar from './KeyBar'
|
||||
import Dashboard from './Dashboard'
|
||||
import Gallery from './Gallery'
|
||||
import Docker from './Docker'
|
||||
import Alerts from './Alerts'
|
||||
@@ -13,7 +12,6 @@ import Cron from './Cron'
|
||||
import Updates from './Updates'
|
||||
import Disks from './Disks'
|
||||
import Files from './Files'
|
||||
import Procs from './Procs'
|
||||
import Top from './Top'
|
||||
import Machines from './Machines'
|
||||
import Settings from './Settings'
|
||||
@@ -52,9 +50,7 @@ const BellIcon = (
|
||||
)
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
|
||||
{ id: 'top', label: 'Top', icon: '▚' },
|
||||
{ id: 'procs', label: 'Processes', icon: '≡' },
|
||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '◈' },
|
||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||
@@ -70,8 +66,8 @@ const GROUPS: Group[] = [
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings']
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||
|
||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||
@@ -104,7 +100,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
const listRate = useRefreshMs(LIST_FLOOR)
|
||||
const [terms, setTerms] = useState<Term[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard')
|
||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
const [online, setOnline] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -144,7 +140,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
||||
// inside the machine change read the page we were leaving — so a link from Settings to a
|
||||
// machine's Disks arrived at its Dashboard.
|
||||
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('dashboard') }, [machine, view])
|
||||
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
|
||||
|
||||
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
||||
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
||||
@@ -267,13 +263,11 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
{view === 'settings' && <Settings me={hubMe} />}
|
||||
{view === 'users' && <Users me={hubMe} />}
|
||||
{view === 'space' && <Space key={machine ?? ''} />}
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||
{view === 'widgets' && <Gallery />}
|
||||
{view === 'docker' && <Docker />}
|
||||
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
||||
{view === 'top' && <Top />}
|
||||
{view === 'procs' && <Procs />}
|
||||
{view === 'procs' && <Top />}
|
||||
{view === 'services' && <Services />}
|
||||
{view === 'cron' && <Cron />}
|
||||
{view === 'updates' && <Updates />}
|
||||
@@ -304,7 +298,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
|
||||
import { fmtBytes } from './Dashboard'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||
|
||||
@@ -90,7 +90,6 @@ export interface Series {
|
||||
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
|
||||
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
|
||||
export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number }
|
||||
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
|
||||
|
||||
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
|
||||
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
|
||||
@@ -208,8 +207,6 @@ export const api = {
|
||||
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
|
||||
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
|
||||
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
|
||||
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
||||
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
||||
login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
|
||||
users: () => req<User[]>('GET', '/api/users', undefined, true),
|
||||
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Sizes and rates, shown the way the rest of the console shows them. These lived in
|
||||
// Dashboard.tsx until that page was retired (2026-09-23); Disks, Files and Space all use
|
||||
// them, so they belong on their own.
|
||||
|
||||
// fmtBytes is binary — what a disk or a file actually occupies.
|
||||
export function fmtBytes(b: number): string {
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
let i = 0
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
||||
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
||||
}
|
||||
|
||||
// fmtRate is decimal — how throughput is quoted everywhere else.
|
||||
export function fmtRate(bps: number): string {
|
||||
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
|
||||
let i = 0
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
|
||||
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// The stock Dashboard modules were written against one Sample object. They now read the
|
||||
// sensor API like everything else; this file is the seam — it rebuilds that object from
|
||||
// sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds).
|
||||
import { Sample, Series } from './api'
|
||||
import { Sample } from './api'
|
||||
import { Sensor } from './sensors'
|
||||
|
||||
export type Values = Record<string, number | boolean | string>
|
||||
@@ -19,7 +19,6 @@ export function mergeValues(parts: unknown[]): Values {
|
||||
return out
|
||||
}
|
||||
// The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature.
|
||||
export const MONITOR_HISTORY = 'cpu.usage,mem.used,net.*.rx,net.*.tx,disk.*.read,disk.*.write,disk.*.util,temp.*'
|
||||
|
||||
const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0)
|
||||
const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '')
|
||||
@@ -63,18 +62,3 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
|
||||
|
||||
// The modules' history: one shared time axis (the CPU's), every other curve right-aligned
|
||||
// to it and zero-padded on the left where the sensor is younger than the ring.
|
||||
export function seriesOf(hist: { id: string; t: number[]; v: number[] }[], cat: Catalog): Series {
|
||||
const by = new Map(hist.map((h) => [h.id, h]))
|
||||
const t = by.get('cpu.usage')?.t ?? []
|
||||
const n = t.length
|
||||
const fit = (id: string) => { const v = (by.get(id)?.v ?? []).slice(-n); return [...new Array(Math.max(0, n - v.length)).fill(0), ...v] as number[] }
|
||||
const out: Series = { t: [...t], cpu: fit('cpu.usage'), mem: fit('mem.used'), net: {}, disk: {}, temp: {} }
|
||||
for (const h of hist) {
|
||||
const l = cat.get(h.id)?.labels
|
||||
const stem = h.id.slice(0, h.id.lastIndexOf('.'))
|
||||
if (h.id.startsWith('net.') && l?.iface) out.net[l.iface] ??= { rx: fit(stem + '.rx'), tx: fit(stem + '.tx') }
|
||||
else if (h.id.startsWith('disk.') && l?.device) out.disk[l.device] ??= { r: fit(stem + '.read'), w: fit(stem + '.write'), u: fit(stem + '.util') }
|
||||
else if (h.id.startsWith('temp.') && l?.chip !== undefined) out.temp[`${l.chip}/${l.label}`] = fit(h.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user