Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eb59fd08c | ||
|
|
e184b4a149 | ||
|
|
363ff02bda |
@@ -2,7 +2,6 @@
|
|||||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||||
GO ?= go
|
GO ?= go
|
||||||
PREFIX ?= /usr/local/bin
|
PREFIX ?= /usr/local/bin
|
||||||
PUBLISH ?= /home/core/apps/hamlab-demo/site
|
|
||||||
|
|
||||||
# every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi
|
# every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi
|
||||||
ARCHES ?= amd64 arm64 arm 386 riscv64
|
ARCHES ?= amd64 arm64 arm 386 riscv64
|
||||||
@@ -10,7 +9,7 @@ LDFLAGS = -s -w -X main.version=$(VERSION)
|
|||||||
# static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel
|
# static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel
|
||||||
GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)"
|
GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)"
|
||||||
|
|
||||||
.PHONY: all web build go dev cross release publish gitea-release deploy push-nodes push-nodes-force test clean
|
.PHONY: all web build go dev cross release gitea-release deploy push-nodes push-nodes-force test clean
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@@ -61,9 +60,7 @@ gitea-release: release
|
|||||||
deploy: build cross
|
deploy: build cross
|
||||||
./bin/hamlab install --prefix $(PREFIX)
|
./bin/hamlab install --prefix $(PREFIX)
|
||||||
@sleep 1; $(PREFIX)/hamlab push-nodes || true
|
@sleep 1; $(PREFIX)/hamlab push-nodes || true
|
||||||
@# The public installer must never lag the fleet: it would offer everyone an older build, and
|
@echo "deployed $(VERSION); 'make gitea-release' publishes it for everyone else"
|
||||||
@# "upgrade" a current machine backwards. Publish only where this hub serves the download site.
|
|
||||||
@if [ -d $(PUBLISH) ]; then $(MAKE) --no-print-directory publish; fi
|
|
||||||
|
|
||||||
# push the installed binary to every managed machine (what deploy does last)
|
# push the installed binary to every managed machine (what deploy does last)
|
||||||
push-nodes:
|
push-nodes:
|
||||||
@@ -75,13 +72,3 @@ push-nodes-force:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin dist internal/web/dist web/dist
|
rm -rf bin dist internal/web/dist web/dist
|
||||||
|
|
||||||
# Put the release where `curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh` finds it:
|
|
||||||
# the static site beside the demo page (nginx, public through NPM). Binaries first, VERSION
|
|
||||||
# last, so nobody is ever pointed at files that aren't there yet.
|
|
||||||
publish: release
|
|
||||||
mkdir -p $(PUBLISH)/dl
|
|
||||||
cp dist/hamlab-linux-* dist/SHA256SUMS $(PUBLISH)/dl/
|
|
||||||
cp dist/install.sh $(PUBLISH)/dl/install.sh && cp dist/install.sh $(PUBLISH)/install.sh
|
|
||||||
cp dist/VERSION $(PUBLISH)/dl/VERSION
|
|
||||||
@echo "published $(VERSION): curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh"
|
|
||||||
|
|||||||
@@ -186,7 +186,12 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets
|
a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets
|
||||||
on the Dashboard tab (briefly renamed "Monitors" 2026-09-19, back to "Dashboard"
|
on the Dashboard tab (briefly renamed "Monitors" 2026-09-19, back to "Dashboard"
|
||||||
2026-09-20 at Mitchell's request; the view id and URL were always `dashboard`), and a
|
2026-09-20 at Mitchell's request; the view id and URL were always `dashboard`), and a
|
||||||
frameable `/embed/widget` for Glance and friends. The stock Dashboard modules and the Disks
|
frameable `/embed/widget` for Glance and friends. (An external demo page was deployed at
|
||||||
|
`hamlab-demo.bigham.dev` and linked from the Widgets tab 2026-09-19; **both removed
|
||||||
|
2026-09-23** at Mitchell's request — the Widgets tab shows the same thing against the
|
||||||
|
machine you are signed in to, so the second copy earned nothing and cost a public
|
||||||
|
read-only token, which was revoked with it. `examples/external-demo/` stays as an example
|
||||||
|
to read.) The stock Dashboard modules and the Disks
|
||||||
tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired.
|
tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired.
|
||||||
Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited
|
Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited
|
||||||
to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request.
|
to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request.
|
||||||
@@ -247,6 +252,13 @@ 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:
|
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:
|
Docker refuses what is still in use, and that refusal is the answer. Still absent:
|
||||||
removing networks.
|
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.
|
||||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
- **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
|
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||||
@@ -406,8 +418,7 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
still default to private. No token is needed by anything any more (the read-only one was
|
still default to private. No token is needed by anything any more (the read-only one was
|
||||||
revoked; only `hamlab-publish`, which creates releases, remains). Gitea resolves
|
revoked; only `hamlab-publish`, which creates releases, remains). Gitea resolves
|
||||||
`releases/download/latest/<asset>`, so `install.sh` and `HAMLAB_URL` now point straight
|
`releases/download/latest/<asset>`, so `install.sh` and `HAMLAB_URL` now point straight
|
||||||
at the newest release and never need changing; the rolling copy on
|
at the newest release and never need changing. What is public is the source, the history,
|
||||||
`hamlab-demo.bigham.dev` stays as a mirror. What is public is the source, the history,
|
|
||||||
`BUGS.md` and this file — which describes the lab and names the console's hostname.
|
`BUGS.md` and this file — which describes the lab and names the console's hostname.
|
||||||
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, optional NPM
|
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, optional NPM
|
||||||
access list restricting to WireGuard/LAN.
|
access list restricting to WireGuard/LAN.
|
||||||
@@ -483,6 +494,60 @@ 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
|
- *Not app work, but the trigger:* nothing here blocks starting the migration except the
|
||||||
inventory. Do that first.
|
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.
|
||||||
|
|
||||||
|
- **Deprecate the Dashboard tab** — asked for 2026-09-23, not started. Mitchell: it is nice
|
||||||
|
but the app repeats itself — the same reasoning that merged Top into Processes the same
|
||||||
|
day. Dashboard is the customisable module list (CPU, memory, load, uptime, temps, fans,
|
||||||
|
network, disks, filesystems, UPS) plus custom sensor widgets; the Processes tab shows
|
||||||
|
most of the same numbers on one screen already. **Widgets stays**, reframed: its page should say plainly that the
|
||||||
|
widgets there are for **external dashboards** (Glance, Home Assistant, a wall display)
|
||||||
|
via `/embed/widget` and read-only sensor tokens, not a second copy of this console's own
|
||||||
|
screens. Care needed on the way out: the dashboard layout is per-user in `state.json`
|
||||||
|
(`users.<name>.dashboard`) and carries `intervalMs`, which the server reads in
|
||||||
|
`applyPrefs` to set the sampler — that wiring has to move or the sampling rate loses its
|
||||||
|
source. `dashboard` is also the default view after login and after switching machines.
|
||||||
|
|
||||||
|
- **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
|
## Open questions
|
||||||
|
|
||||||
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ window.HAMLAB = { base: 'http://10.0.0.234:8100', token: '<read-only sensor toke
|
|||||||
```
|
```
|
||||||
|
|
||||||
Anyone who can load the page can read the token. If the page is public, limit the token to
|
Anyone who can load the page can read the token. If the page is public, limit the token to
|
||||||
patterns that name nothing you'd mind the world reading (the `hamlab-demo` one shows
|
patterns that name nothing you'd mind the world reading — hardware numbers and counts,
|
||||||
hardware numbers and counts, not container or service names). Deployed on `core` as the `hamlab-demo` stack —
|
not container or service names.
|
||||||
see Hamlet `docs/docker.md`. Update it with
|
|
||||||
`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`
|
This is an example to read and copy, not something that runs anywhere. It was deployed on
|
||||||
and restart Ham Lab.
|
`core` as the `hamlab-demo` stack and linked from the Widgets tab until 2026-09-23, when
|
||||||
|
both were removed: the Widgets tab shows the same widgets against the machine you are
|
||||||
|
already signed in to, so a second public copy earned nothing and cost a public token.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Ham Lab installer.
|
# Ham Lab installer.
|
||||||
#
|
#
|
||||||
# curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh
|
# curl -fsSL https://git.bigham.dev/mitchell/hamlab/releases/download/latest/install.sh | sh
|
||||||
#
|
#
|
||||||
# With a terminal it walks you through it: who the console runs as, where it listens,
|
# With a terminal it walks you through it: who the console runs as, where it listens,
|
||||||
# HTTPS, the firewall, the password — then downloads the build for this machine, checks
|
# HTTPS, the firewall, the password — then downloads the build for this machine, checks
|
||||||
|
|||||||
@@ -1013,9 +1013,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_, rebootErr := os.Stat("/run/reboot-required")
|
_, rebootErr := os.Stat("/run/reboot-required")
|
||||||
updateVersion, _ := s.behind()
|
updateVersion, _ := s.behind()
|
||||||
var demoURL, addr string
|
var addr string
|
||||||
s.cfg.View(func(c *config.Config) {
|
s.cfg.View(func(c *config.Config) {
|
||||||
demoURL = c.DemoURL
|
|
||||||
if h, _, err := net.SplitHostPort(c.Listen); err == nil && h != "" && h != "0.0.0.0" && h != "::" {
|
if h, _, err := net.SplitHostPort(c.Listen); err == nil && h != "" && h != "0.0.0.0" && h != "::" {
|
||||||
addr = h // the address this console listens on, for the machine menu
|
addr = h // the address this console listens on, for the machine menu
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1022,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, 200, map[string]any{
|
jsonOK(w, 200, map[string]any{
|
||||||
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
|
"host": s.hostName(), "user": s.user, "account": actorOf(r).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, "demo_url": demoURL, "addr": addr,
|
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
||||||
"update_available": updateVersion,
|
"update_available": updateVersion,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ 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"`
|
|
||||||
// Updates: where a newer build comes from. See UpdateSource.
|
// Updates: where a newer build comes from. See UpdateSource.
|
||||||
Updates UpdateSource `json:"update,omitempty"`
|
Updates UpdateSource `json:"update,omitempty"`
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -31,7 +31,7 @@ function Tile({ spec, data, note }: { spec: WidgetSpec; data: WidgetData; note:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Gallery({ demoUrl }: { demoUrl?: string }) {
|
export default function Gallery() {
|
||||||
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()
|
||||||
@@ -61,7 +61,6 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
|
|||||||
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
<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" />
|
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
|
||||||
<span className="spacer" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{!needle && <h2 className="section">Widget types</h2>}
|
{!needle && <h2 className="section">Widget types</h2>}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+6
-9
@@ -13,7 +13,6 @@ import Cron from './Cron'
|
|||||||
import Updates from './Updates'
|
import Updates from './Updates'
|
||||||
import Disks from './Disks'
|
import Disks from './Disks'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
import Procs from './Procs'
|
|
||||||
import Top from './Top'
|
import Top from './Top'
|
||||||
import Machines from './Machines'
|
import Machines from './Machines'
|
||||||
import Settings from './Settings'
|
import Settings from './Settings'
|
||||||
@@ -53,8 +52,7 @@ const BellIcon = (
|
|||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
|
{ 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: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
{ id: 'services', label: 'Services', icon: '◈' },
|
{ id: 'services', label: 'Services', icon: '◈' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
@@ -70,8 +68,8 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
type View = 'terminal' | 'dashboard' | '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']
|
const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
@@ -269,11 +267,10 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
{view === 'space' && <Space key={machine ?? ''} />}
|
{view === 'space' && <Space key={machine ?? ''} />}
|
||||||
{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 demoUrl={hubMe.demo_url} />}
|
{view === 'widgets' && <Gallery />}
|
||||||
{view === 'docker' && <Docker />}
|
{view === 'docker' && <Docker />}
|
||||||
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
{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' && <Top />}
|
||||||
{view === 'procs' && <Procs />}
|
|
||||||
{view === 'services' && <Services />}
|
{view === 'services' && <Services />}
|
||||||
{view === 'cron' && <Cron />}
|
{view === 'cron' && <Cron />}
|
||||||
{view === 'updates' && <Updates />}
|
{view === 'updates' && <Updates />}
|
||||||
@@ -304,7 +301,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>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<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 === '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>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
||||||
export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
|
export interface Me { addr?: string; host: string; user: string; account?: string; update_available?: 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 }
|
||||||
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
||||||
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
||||||
|
|||||||
Reference in New Issue
Block a user