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/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/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 && (
+