From bccff788bdd2715e1f782d7f391557aa701e72f9 Mon Sep 17 00:00:00 2001 From: Junwon Lee Date: Thu, 13 Aug 2026 21:43:25 +0900 Subject: [PATCH 1/2] v1.2.2 --- docs/building.en.md | 2 +- docs/building.md | 2 +- frontend/src/idle.ts | 77 +++++++++++++++++++++++++++++++++++++++++ frontend/src/usePoll.ts | 34 ++++++++++++++---- internal/app/version.go | 2 +- wails.json | 2 +- 6 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 frontend/src/idle.ts diff --git a/docs/building.en.md b/docs/building.en.md index fd2f007..ae9ebe8 100644 --- a/docs/building.en.md +++ b/docs/building.en.md @@ -35,4 +35,4 @@ go test ./... -race # includes integration tests (needs Docker) Integration tests bring up real servers. `testdata/` holds sshd, systemd and Docker-in-Docker fixtures. Without Docker they skip rather than fail, so **if `-race` finishes in a few seconds the integration tests did not run** (with Docker up it takes about a minute). -v1.2.1 was built with Go 1.26.5, Node 22.13.1, Wails 2.14.0, Docker 29.4.0 on macOS 26.5.2 arm64. +v1.2.2 was built with Go 1.26.5, Node 22.13.1, Wails 2.14.0, Docker 29.4.0 on macOS 26.5.2 arm64. diff --git a/docs/building.md b/docs/building.md index e4db6a2..ad06ae8 100644 --- a/docs/building.md +++ b/docs/building.md @@ -35,4 +35,4 @@ go test ./... -race # 통합 테스트 포함 (Docker 필요) 통합 테스트는 실제 서버를 띄웁니다. `testdata/`에 sshd·systemd·Docker-in-Docker 픽스처가 있습니다. Docker가 없으면 실패가 아니라 건너뛰므로, **`-race`가 몇 초 만에 끝났다면 통합 테스트가 안 돌았다는 뜻입니다** (Docker가 켜져 있으면 1분 남짓 걸립니다). -v1.2.1을 만든 환경: Go 1.26.5 · Node 22.13.1 · Wails 2.14.0 · Docker 29.4.0 (macOS 26.5.2 arm64). +v1.2.2를 만든 환경: Go 1.26.5 · Node 22.13.1 · Wails 2.14.0 · Docker 29.4.0 (macOS 26.5.2 arm64). diff --git a/frontend/src/idle.ts b/frontend/src/idle.ts new file mode 100644 index 0000000..e84d391 --- /dev/null +++ b/frontend/src/idle.ts @@ -0,0 +1,77 @@ +// How long the window has gone untouched, and how much that should slow polling. +// +// `document.hidden` already stops the timers when the window is minimised — +// measured on macOS 2026-08-13, minimising does fire `visibilitychange`. What it +// does not cover is the more common state: the window sitting open on a second +// monitor while the user works elsewhere. Switching apps leaves the page +// `visible`, so without this the app keeps asking the server every two seconds +// for a screen nobody has looked at since this morning. +// +// The server pays for each tick — an Exec channel, a forked sshd, a shell — and +// servers are usually specced for the job they run and not much more (§3.2d). +// +// Slowing down rather than stopping: a glance at a window that has been idle for +// an hour should still show something recent, and the wake path below refreshes +// it the instant the mouse moves. + +/** Idle thresholds, longest first. */ +const STEPS: { after: number; factor: number }[] = [ + { after: 10 * 60_000, factor: 15 }, + { after: 2 * 60_000, factor: 4 }, +] + +/** Overridden by tests and probes; production never changes it. */ +let steps = STEPS + +let lastActivity = Date.now() +const listeners = new Set<() => void>() + +/** Multiplier to apply to a poll interval right now. 1 while in use. */ +export function idleFactor(): number { + const quiet = Date.now() - lastActivity + for (const s of steps) { + if (quiet >= s.after) return s.factor + } + return 1 +} + +/** + * Called when the user touches the window after having been idle. + * + * Pollers use it to refresh immediately rather than serving whatever was on + * screen when they slowed down — coming back to a stale table is the failure + * this whole mechanism has to avoid being blamed for. + */ +export function onWake(fn: () => void): () => void { + listeners.add(fn) + return () => { + listeners.delete(fn) + } +} + +function touched() { + const wasIdle = idleFactor() > 1 + lastActivity = Date.now() + if (wasIdle) { + for (const l of listeners) l() + } +} + +// Capture, and passive where it matters: these must not interfere with any +// handler in the app, and pointermove fires constantly. +const EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'focus'] as const +for (const e of EVENTS) { + window.addEventListener(e, touched, { capture: true, passive: true }) +} +// Returning from a minimised window counts as activity: visibilitychange fires +// before any pointer event, and the poller restarting should not immediately +// inherit an hour-old idle factor. +document.addEventListener('visibilitychange', () => { + if (!document.hidden) touched() +}) + +/** Test seam. Pass nothing to restore the shipped thresholds. */ +export function setIdleSteps(next?: { after: number; factor: number }[]) { + steps = next ?? STEPS + lastActivity = Date.now() +} diff --git a/frontend/src/usePoll.ts b/frontend/src/usePoll.ts index a45fdc6..7adc438 100644 --- a/frontend/src/usePoll.ts +++ b/frontend/src/usePoll.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { idleFactor, onWake } from './idle' /** * Polls while somebody is actually looking (§3.2d). @@ -14,6 +15,10 @@ import { useEffect, useRef } from 'react' * * A tick fires immediately on becoming visible, so coming back shows fresh data * rather than however stale the last frame was. + * + * Between those two states is the one `document.hidden` cannot see — a window + * left open and untouched — so the interval is stretched by idle.ts and snaps + * back the moment the mouse moves. */ export function usePoll(tick: () => unknown, everyMs: number, active = true) { // Held in a ref so a caller that rebuilds its closure every render does not @@ -25,24 +30,41 @@ export function usePoll(tick: () => unknown, everyMs: number, active = true) { if (!active) return let timer = 0 - const stop = () => { + // setTimeout rather than setInterval: the gap is re-read before every tick, + // so going idle takes effect at the next tick instead of needing the timer + // to be torn down and rebuilt. + const clear = () => { if (timer) { - window.clearInterval(timer) + window.clearTimeout(timer) timer = 0 } } + const schedule = () => { + clear() + timer = window.setTimeout(fire, everyMs * idleFactor()) + } + const fire = () => { + timer = 0 + if (document.hidden) return // start() will resume it + void latest.current() + schedule() + } + /** Tick now and restart the clock. */ const start = () => { - if (timer || document.hidden) return + if (document.hidden) return void latest.current() - timer = window.setInterval(() => void latest.current(), everyMs) + schedule() } - const onVisibility = () => (document.hidden ? stop() : start()) + + const onVisibility = () => (document.hidden ? clear() : start()) + const offWake = onWake(start) document.addEventListener('visibilitychange', onVisibility) start() return () => { document.removeEventListener('visibilitychange', onVisibility) - stop() + offWake() + clear() } }, [everyMs, active]) } diff --git a/internal/app/version.go b/internal/app/version.go index 6e8a7c4..e05495f 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -9,4 +9,4 @@ package app // // Bumping a release means editing this line and wails.json's info.productVersion // together, then tagging v. -const Version = "1.2.1" +const Version = "1.2.2" diff --git a/wails.json b/wails.json index c385741..2be43e0 100644 --- a/wails.json +++ b/wails.json @@ -8,7 +8,7 @@ "frontend:dev:serverUrl": "auto", "info": { "productName": "LiteDeck", - "productVersion": "1.2.1", + "productVersion": "1.2.2", "companyName": "LiteDeck", "copyright": "Copyright © 2026 Junwon Lee. Apache-2.0.", "comments": "SSH 하나로 원격 서버를 내 컴퓨터의 GUI에서 다룹니다." From 530d35d56fd1eccc27214a13b954dbd9eeb61ef0 Mon Sep 17 00:00:00 2001 From: INMD1 Date: Thu, 13 Aug 2026 23:19:44 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20NVIDIA=20GPU=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 2 +- README.md | 2 +- frontend/src/MetricsBar.tsx | 139 +++++++++++++++++++++++++++- frontend/src/app.css | 78 ++++++++++++++++ frontend/src/ipc.ts | 19 ++++ frontend/src/locale-en.ts | 3 + internal/adapter/metrics.go | 101 +++++++++++++++++++- internal/adapter/metrics_test.go | 67 ++++++++++++++ internal/adapter/windows_metrics.go | 14 +++ 9 files changed, 418 insertions(+), 7 deletions(-) diff --git a/README.en.md b/README.en.md index a5aa27e..0038651 100644 --- a/README.en.md +++ b/README.en.md @@ -94,7 +94,7 @@ All the server does is **run commands it already had and hand back text**. Which | **Sessions** | Who is logged in to this server, and cutting any of them off | | **Scheduled jobs** | systemd timers. Next and last run | | **Terminal** | xterm.js PTY, multiple tabs. `code .` and `vi foo.conf` are **caught by the app** and open in the file tab. They are never sent to the server, so neither VS Code nor vi needs to exist there | -| **Monitoring** | CPU, memory, disk summary bar with sparklines | +| **Monitoring** | CPU, memory, disk summary bar with sparklines. NVIDIA cards add **utilisation, fan, temperature and VRAM** (nvidia-smi) | | **Command Log** | **Every command the GUI runs, live.** Click to copy | | **MCP** | Claude Code and Claude Desktop read and change your servers through this app. Per-server opt-in, changes are approved, **and can be undone** | | **Connecting** | Password, key, agent, 2FA. Import from `~/.ssh/config`. One **ProxyJump** hop | diff --git a/README.md b/README.md index 2a13cfb..45e5a3e 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ RDP·VNC·TeamViewer는 서버의 **화면 픽셀을 영상으로 스트리밍** | **세션** | 이 서버에 누가 붙어 있는지. 접속별로 끊기 | | **스케줄** | systemd 타이머. 다음·마지막 실행 시각 | | **터미널** | xterm.js PTY, 탭 여러 개. `code .` · `vi foo.conf` 를 **앱이 가로채** 파일 탭에서 엽니다. 서버로 보내지 않으므로 VS Code나 vi가 없어도 됩니다 | -| **모니터링** | CPU·메모리·디스크 요약 바 + 그래프 | +| **모니터링** | CPU·메모리·디스크 요약 바 + 그래프. NVIDIA 카드가 있으면 **GPU 사용률·팬·온도·VRAM** 도 함께 (nvidia-smi) | | **Command Log** | GUI가 실행한 **모든 명령을 실시간으로 표시**. 클릭하면 복사됩니다 | | **MCP** | Claude Code·Claude Desktop이 이 앱을 통해 서버를 조회하고 바꿉니다. 서버별 opt-in, 변경은 승인, **되돌리기 가능** | | **접속** | 비밀번호·키·에이전트·2FA. `~/.ssh/config` 가져오기. **ProxyJump** 로 경유 서버 한 단계 | diff --git a/frontend/src/MetricsBar.tsx b/frontend/src/MetricsBar.tsx index b9a8fda..c340347 100644 --- a/frontend/src/MetricsBar.tsx +++ b/frontend/src/MetricsBar.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { HostMetrics, type MetricsView } from './ipc' +import { HostMetrics, type GPU, type MetricsView } from './ipc' import { usePoll } from './usePoll' import { t } from './i18n' @@ -58,10 +58,35 @@ function Sparkline({ values, warn }: { values: number[]; warn?: boolean }) { ) } +// -1 is "the card did not answer", which is not the same as zero: a passively +// cooled datacentre card really does read 0 RPM. +function fmtPct(v: number): string { + return v < 0 ? '—' : v.toFixed(0) +} + +function fmtTemp(v: number): string { + return v < 0 ? '—' : `${v.toFixed(0)}°C` +} + +function gpuTitle(g: GPU): string { + const parts = [g.name] + if (g.tempC >= 0) parts.push(fmtTemp(g.tempC)) + if (g.fan >= 0) parts.push(t('팬 {f}%', { f: g.fan.toFixed(0) })) + if (g.memTotal > 0) parts.push(`${fmtBytes(g.memUsed)} / ${fmtBytes(g.memTotal)}`) + return parts.join(' · ') +} + +// A card is worth flagging when it is pinned or hot; either one is a reason to +// look before starting more work on it. +function gpuWarn(g: GPU): boolean { + return g.utilization >= 90 || g.tempC >= 85 +} + function Stat({ label, value, unit, + note, history, warn, title, @@ -69,6 +94,10 @@ function Stat({ label: string value: string unit?: string + // A second, smaller figure on the same line. Fan speed rides along with GPU + // load here rather than taking a tile of its own: it is the number you check + // after the load, not instead of it. + note?: string history?: number[] warn?: boolean title?: string @@ -81,6 +110,7 @@ function Stat({ {value} {unit && {unit}} + {note && {note}} {history && } @@ -91,14 +121,42 @@ export function MetricsBar({ hostID }: { hostID: string }) { const [m, setM] = useState(null) const [cpuHist, setCpuHist] = useState([]) const [memHist, setMemHist] = useState([]) + // One row per card, in the order nvidia-smi listed them, plus the busiest + // card per sample for the collapsed tile — following one card's line there + // would make the line jump between cards as the lead changes. + const [gpuHist, setGpuHist] = useState([]) + const [gpuMaxHist, setGpuMaxHist] = useState([]) + const [gpuOpen, setGpuOpen] = useState(false) const [failed, setFailed] = useState(null) const inFlight = useRef(false) + const gpuPopRef = useRef(null) + + // The panel is an overlay over the tab below it, so it closes the way every + // other overlay does: click away, or Escape. + useEffect(() => { + if (!gpuOpen) return + const onDown = (e: MouseEvent) => { + if (!gpuPopRef.current?.contains(e.target as Node)) setGpuOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setGpuOpen(false) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [gpuOpen]) useEffect(() => { // Host changed: the previous host's history says nothing about this one. setM(null) setCpuHist([]) setMemHist([]) + setGpuHist([]) + setGpuMaxHist([]) + setGpuOpen(false) setFailed(null) }, [hostID]) @@ -118,6 +176,19 @@ export function MetricsBar({ hostID }: { hostID: string }) { setCpuHist((h) => [...h, next.cpu].slice(-HISTORY)) } setMemHist((h) => [...h, next.memPercent].slice(-HISTORY)) + // Same rule as the CPU: a card that did not report utilisation keeps the + // history it has rather than plotting a -1 as a dive to the floor. Rows + // are rebuilt from the current list so a card appearing or disappearing + // cannot leave a stale line behind. + setGpuHist((h) => + (next.gpus ?? []).map((g, i) => + g.utilization < 0 ? h[i] ?? [] : [...(h[i] ?? []), g.utilization].slice(-HISTORY), + ), + ) + const busiest = (next.gpus ?? []).reduce((a, g) => Math.max(a, g.utilization), -1) + if (busiest >= 0) { + setGpuMaxHist((h) => [...h, busiest].slice(-HISTORY)) + } } catch (e) { // The bar must never interrupt what the user is doing: a failure here is // shown in place, not raised as an app-level error. @@ -145,6 +216,11 @@ export function MetricsBar({ hostID }: { hostID: string }) { } const disk = m.disks?.[0] + const gpus = m.gpus ?? [] + // The busiest card is what answers "is this box working right now". An + // average across an idle second card would hide a pinned first one. + const gpuBusy = gpus.reduce((a, g) => Math.max(a, g.utilization), -1) + const gpuFan = gpus.reduce((a, g) => Math.max(a, g.fan), -1) return (
@@ -164,6 +240,67 @@ export function MetricsBar({ hostID }: { hostID: string }) { warn={m.memPercent >= 90} title={`${fmtBytes(m.memUsed)} / ${fmtBytes(m.memTotal)}`} /> + {/* Sits with CPU and memory rather than at the end: on a box that has a + card at all, it is the figure being watched. */} + {gpus.length === 1 && ( + + )} + {/* Eight cards would eat the whole bar, so the many-card case collapses + to the busiest one and opens the rest on click. */} + {gpus.length > 1 && ( +
+ + {gpuOpen && ( +
+ {gpus.map((g, i) => ( +
+ #{g.index} + + {g.name} + + + {g.utilization < 0 ? '—' : `${fmtPct(g.utilization)}%`} + + + + {g.fan < 0 ? t('팬 —') : t('팬 {f}%', { f: fmtPct(g.fan) })} + + {fmtTemp(g.tempC)} + + {g.memTotal > 0 ? `${fmtBytes(g.memUsed)} / ${fmtBytes(g.memTotal)}` : '—'} + +
+ ))} +
+ )} +
+ )} {disk && ( = { 'AI 파일만 승인': 'AI: ask on files', 'Claude Code 나 Claude Desktop 같은 MCP 클라이언트가 이 앱을 통해 서버를 조회할 수 있게 합니다. AI 는 GUI 와 똑같은 어댑터·SSH 연결·Command Log 를 씁니다.': 'Lets an MCP client such as Claude Code or Claude Desktop query your servers through this app. The AI uses the same adapters, the same SSH connection and the same Command Log as the GUI.', 'Claude Code·Claude Desktop 같은 MCP 클라이언트가 이 앱을 통해 서버를 다룹니다. 같은 어댑터·SSH 연결·Command Log 를 씁니다.': 'An MCP client such as Claude Code or Claude Desktop works your servers through this app, using the same adapters, the same SSH connection and the same Command Log.', + 'GPU {n}개 — 눌러서 카드별로 보기': '{n} {n#card|cards} — click for a per-card breakdown', 'GUI가 실행하는 모든 명령이 여기에 실시간으로 표시됩니다. 클릭하면 복사됩니다.': 'Every command the GUI runs appears here as it happens. Click one to copy it.', 'GUI가 표현하지 못하는 일을 위한 탭입니다 — 실행한 명령은 아래 Command Log에 남습니다': 'For the things a GUI cannot express. What you run shows up in the Command Log below', 'Go 바인딩을 찾을 수 없습니다 — `wails dev`로 실행해야 합니다 (순수 Vite 서버로는 동작하지 않습니다).': 'Go bindings not found — run with `wails dev` (a plain Vite server will not work).', @@ -400,6 +401,8 @@ export const en: Record = { '파일 변경만 물어보기': 'Ask about file changes only', '파일 삭제 허용': 'Allow deleting files', '파일이 서버에서 바뀌었습니다': 'The file changed on the server', + '팬 {f}%': 'Fan {f}%', + '팬 —': 'Fan —', '편집': 'Edit', '편집기를 불러오는 중…': 'Loading the editor…', '포트': 'Port', diff --git a/internal/adapter/metrics.go b/internal/adapter/metrics.go index a941432..1368505 100644 --- a/internal/adapter/metrics.go +++ b/internal/adapter/metrics.go @@ -1,6 +1,6 @@ package adapter -// The monitoring summary bar (§4.7): CPU, memory, disk and load. +// The monitoring summary bar (§4.7): CPU, memory, disk, load and GPU. // // Positioned as a supporting feature, not a monitoring product. It answers "is // this box healthy right now" at a glance; anything more belongs to a real @@ -20,13 +20,18 @@ import ( // A shell script rather than argv, which is the one exception to the // argv-only rule (§3.2b) — and it is safe for the reason the rule exists: this // is a compile-time constant with nothing interpolated into it. Splitting it -// into five separate commands would cost five round trips every two seconds, +// into six separate commands would cost six round trips every two seconds, // and CPU has to be sampled twice anyway. +// +// The nvidia-smi line is the only one that can be absent. Its stderr is dropped +// rather than probed for first, because `command -v nvidia-smi` would cost a +// second lookup on every poll to learn something the empty output already says. const MetricsScript = `echo '#stat'; cat /proc/stat 2>/dev/null | head -1 echo '#mem'; cat /proc/meminfo 2>/dev/null echo '#load'; cat /proc/loadavg 2>/dev/null echo '#up'; cat /proc/uptime 2>/dev/null -echo '#df'; df -P -B1 2>/dev/null` +echo '#df'; df -P -B1 2>/dev/null +echo '#gpu'; nvidia-smi --query-gpu=index,name,utilization.gpu,fan.speed,temperature.gpu,memory.total,memory.used --format=csv,noheader,nounits 2>/dev/null` // CPUTimes is one sample of the aggregate CPU counters. // @@ -64,6 +69,32 @@ type Filesystem struct { Percent float64 `json:"percent"` } +// GPU is one NVIDIA card. +// +// NVIDIA only, deliberately: nvidia-smi ships with every driver install and +// answers one line per card over a plain SSH connection. AMD and Intel expose +// nothing comparable without a package that is not there by default, and §1.5 +// keeps LiteDeck from carrying an agent to fill the gap. A host with no cards +// reports none and the summary bar drops the tiles, the same way it drops load +// average on Windows. +type GPU struct { + Index int `json:"index"` + Name string `json:"name"` + + // Utilization, Fan and TempC are -1 where the card does not report the + // figure — the same convention CPU uses before its second sample. Fan speed + // is the common one: passively cooled datacentre cards (Tesla, A100) and + // laptop hybrids answer "[N/A]", and a 0 there reads as a stopped fan on a + // card that is about to cook. + Utilization float64 `json:"utilization"` // percent + Fan float64 `json:"fan"` // percent of maximum speed + TempC float64 `json:"tempC"` + + MemTotal int64 `json:"memTotal"` // bytes + MemUsed int64 `json:"memUsed"` + MemPercent float64 `json:"memPercent"` +} + // Metrics is one snapshot of a server's health. type Metrics struct { // CPU is -1 until a second sample exists; the UI shows a dash rather than @@ -91,13 +122,17 @@ type Metrics struct { UptimeSeconds int64 `json:"uptimeSeconds"` Filesystems []Filesystem `json:"filesystems"` + + // GPUs is empty on the overwhelming majority of servers, which is why the + // summary bar treats it as an optional section rather than a fixed tile. + GPUs []GPU `json:"gpus"` } // ParseMetrics reads the output of MetricsScript. // // prev is the previous CPU sample; pass a zero value on the first call. func ParseMetrics(data []byte, prev CPUTimes) (Metrics, error) { - m := Metrics{CPU: -1, Filesystems: []Filesystem{}} + m := Metrics{CPU: -1, Filesystems: []Filesystem{}, GPUs: []GPU{}} sections := splitSections(data) @@ -143,6 +178,7 @@ func ParseMetrics(data []byte, prev CPUTimes) (Metrics, error) { } m.Filesystems = parseDF(sections["df"]) + m.GPUs = parseGPUs(sections["gpu"]) if m.MemTotal == 0 && len(m.Filesystems) == 0 { return m, fmt.Errorf("adapter: metrics output had nothing usable") @@ -257,6 +293,63 @@ func parseDF(lines []string) []Filesystem { return out } +// parseGPUs reads `nvidia-smi --format=csv,noheader,nounits` rows. +// +// The section is empty on the hosts without a card, which is the common case +// and not an error: the driver is absent, nvidia-smi is not on PATH, and the +// shell wrote its complaint to the dropped stderr. +// +// nounits gives bare numbers, and memory is in MiB — the one unit the flag +// cannot spell out, so it is converted here rather than left for the UI. +func parseGPUs(lines []string) []GPU { + out := []GPU{} + for i, line := range lines { + f := strings.Split(line, ",") + if len(f) < 7 { + continue + } + for j := range f { + f[j] = strings.TrimSpace(f[j]) + } + + // The index column is authoritative but a driver that answered a row + // without one still names a real card; fall back to position. + idx, err := strconv.Atoi(f[0]) + if err != nil { + idx = i + } + g := GPU{ + Index: idx, + Name: f[1], + Utilization: parseGPUFloat(f[2]), + Fan: parseGPUFloat(f[3]), + TempC: parseGPUFloat(f[4]), + } + if total := parseGPUFloat(f[5]); total >= 0 { + g.MemTotal = int64(total) * 1024 * 1024 + } + if used := parseGPUFloat(f[6]); used >= 0 { + g.MemUsed = int64(used) * 1024 * 1024 + } + if g.MemTotal > 0 { + g.MemPercent = clampPercent(float64(g.MemUsed) / float64(g.MemTotal) * 100) + } + out = append(out, g) + } + return out +} + +// parseGPUFloat returns -1 for the "[N/A]" and "[Not Supported]" placeholders +// nvidia-smi prints where a card cannot answer, keeping them distinct from a +// genuine zero. +func parseGPUFloat(s string) float64 { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return -1 + } + return v +} + // InterestingFilesystems drops the ones nobody wants in a summary bar. // // A container or a modern desktop mounts dozens of tmpfs, overlay and cgroup diff --git a/internal/adapter/metrics_test.go b/internal/adapter/metrics_test.go index 1068959..c995ed7 100644 --- a/internal/adapter/metrics_test.go +++ b/internal/adapter/metrics_test.go @@ -173,3 +173,70 @@ func TestMetricsMarshalsAsArrays(t *testing.T) { assertArray(t, "Metrics.Filesystems with no df output", m.Filesystems) assertArray(t, "InterestingFilesystems(empty)", InterestingFilesystems(nil)) } + +// A host without a card is the common case: nvidia-smi is missing, the shell +// complains to the dropped stderr, and the section arrives empty. That is not +// an error, and it must not become a phantom card in the bar. +func TestParseGPUsAbsent(t *testing.T) { + for _, in := range [][]string{nil, {""}, {"bash: nvidia-smi: command not found"}} { + if got := parseGPUs(in); len(got) != 0 { + t.Errorf("parseGPUs(%q) = %v, want none", in, got) + } + } +} + +func TestParseGPUs(t *testing.T) { + got := parseGPUs([]string{ + "0, NVIDIA GeForce RTX 4090, 37, 41, 55, 24564, 1228", + "1, Tesla A100-SXM4-40GB, 100, [N/A], 71, 40960, 40960", + }) + if len(got) != 2 { + t.Fatalf("got %d cards, want 2", len(got)) + } + + // nounits gives bare numbers and memory in MiB; the conversion happens here + // so the UI never has to know which column carries which unit. + if got[0].Name != "NVIDIA GeForce RTX 4090" { + t.Errorf("name = %q", got[0].Name) + } + if got[0].Utilization != 37 || got[0].Fan != 41 || got[0].TempC != 55 { + t.Errorf("card 0 figures = %+v", got[0]) + } + if got[0].MemTotal != 24564*1024*1024 || got[0].MemUsed != 1228*1024*1024 { + t.Errorf("memory = %d/%d bytes", got[0].MemUsed, got[0].MemTotal) + } + if got[0].MemPercent < 4.9 || got[0].MemPercent > 5.1 { + t.Errorf("memPercent = %v, want ~5", got[0].MemPercent) + } + + // A passively cooled datacentre card reports no fan. Zero would read as a + // stopped fan on a card that is about to cook, so it stays -1. + if got[1].Fan != -1 { + t.Errorf("missing fan = %v, want -1", got[1].Fan) + } + if got[1].Index != 1 || got[1].MemPercent != 100 { + t.Errorf("card 1 = %+v", got[1]) + } +} + +// The GPU section is optional, so its absence must leave the rest of the +// snapshot intact rather than failing the whole poll. +func TestParseMetricsGPUSection(t *testing.T) { + m, err := ParseMetrics([]byte("#mem\nMemTotal: 100 kB\n#gpu\n0, NVIDIA A2, 0, [N/A], 33, 15356, 0\n"), CPUTimes{}) + if err != nil { + t.Fatal(err) + } + if len(m.GPUs) != 1 || m.GPUs[0].Name != "NVIDIA A2" { + t.Fatalf("GPUs = %+v", m.GPUs) + } + assertArray(t, "Metrics.GPUs with no gpu output", mustParse(t, "#mem\nMemTotal: 100 kB\n").GPUs) +} + +func mustParse(t *testing.T, s string) Metrics { + t.Helper() + m, err := ParseMetrics([]byte(s), CPUTimes{}) + if err != nil { + t.Fatal(err) + } + return m +} diff --git a/internal/adapter/windows_metrics.go b/internal/adapter/windows_metrics.go index b389a53..09eb548 100644 --- a/internal/adapter/windows_metrics.go +++ b/internal/adapter/windows_metrics.go @@ -33,6 +33,14 @@ func WindowsMetricsScript() string { `Write-Output '#disk'`, windowspowershell.JSON(`Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | `+ `Select-Object DeviceID,Size,FreeSpace`, 2), + `Write-Output '#gpu'`, + // Guarded rather than silenced: an unrecognised command is a PowerShell + // error record, and those arrive as CLIXML on the same stream the rest of + // this output has to survive on. Get-Command is the same test detect.go + // uses for docker. + `if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { ` + + `nvidia-smi --query-gpu=index,name,utilization.gpu,fan.speed,temperature.gpu,memory.total,memory.used ` + + `--format=csv,noheader,nounits }`, }, "; ") } @@ -69,6 +77,7 @@ func ParseWindowsMetrics(data []byte, nowMillis int64) (Metrics, error) { m := Metrics{ CPU: -1, Filesystems: []Filesystem{}, + GPUs: []GPU{}, // Windows has no load average. Nothing here approximates it: the // processor queue length counter measures something else, and reporting // zero would read as an idle machine rather than as an absent figure. The @@ -127,5 +136,10 @@ func ParseWindowsMetrics(data []byte, nowMillis int64) (Metrics, error) { }) } } + + // nvidia-smi is the same binary with the same CSV on both platforms, so the + // rows go through the Linux parser rather than a second copy of it. + m.GPUs = parseGPUs(strings.Split(blocks["gpu"], "\n")) + return m, nil }