Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** 로 경유 서버 한 단계 |
Expand Down
2 changes: 1 addition & 1 deletion docs/building.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
139 changes: 138 additions & 1 deletion frontend/src/MetricsBar.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -58,17 +58,46 @@ 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,
}: {
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
Expand All @@ -81,6 +110,7 @@ function Stat({
{value}
{unit && <span className="metric-unit">{unit}</span>}
</span>
{note && <span className="metric-note">{note}</span>}
{history && <Sparkline values={history} warn={warn} />}
</div>
</div>
Expand All @@ -91,14 +121,42 @@ export function MetricsBar({ hostID }: { hostID: string }) {
const [m, setM] = useState<MetricsView | null>(null)
const [cpuHist, setCpuHist] = useState<number[]>([])
const [memHist, setMemHist] = useState<number[]>([])
// 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<number[][]>([])
const [gpuMaxHist, setGpuMaxHist] = useState<number[]>([])
const [gpuOpen, setGpuOpen] = useState(false)
const [failed, setFailed] = useState<string | null>(null)
const inFlight = useRef(false)
const gpuPopRef = useRef<HTMLDivElement | null>(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])

Expand All @@ -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.
Expand Down Expand Up @@ -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 (
<div className="metrics-bar">
Expand All @@ -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 && (
<Stat
label="GPU"
value={fmtPct(gpus[0].utilization)}
unit={gpus[0].utilization < 0 ? undefined : '%'}
note={gpus[0].fan < 0 ? fmtTemp(gpus[0].tempC) : t('팬 {f}%', { f: fmtPct(gpus[0].fan) })}
history={gpuHist[0]}
warn={gpuWarn(gpus[0])}
title={gpuTitle(gpus[0])}
/>
)}
{/* 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 && (
<div className="metric-pop" ref={gpuPopRef}>
<button
type="button"
className="metric-btn"
aria-expanded={gpuOpen}
onClick={() => setGpuOpen((o) => !o)}
title={t('GPU {n}개 — 눌러서 카드별로 보기', { n: gpus.length })}
>
<Stat
label={t('GPU ×{n}', { n: gpus.length })}
value={fmtPct(gpuBusy)}
unit={gpuBusy < 0 ? undefined : '%'}
note={gpuFan < 0 ? undefined : t('팬 {f}%', { f: fmtPct(gpuFan) })}
history={gpuMaxHist}
warn={gpus.some(gpuWarn)}
/>
<span className="metric-caret" aria-hidden="true">
{gpuOpen ? '▴' : '▾'}
</span>
</button>
{gpuOpen && (
<div className="gpu-panel">
{gpus.map((g, i) => (
<div className="gpu-row" key={g.index} data-warn={gpuWarn(g) || undefined}>
<span className="gpu-idx">#{g.index}</span>
<span className="gpu-name" title={g.name}>
{g.name}
</span>
<span className="gpu-num metric-value">
{g.utilization < 0 ? '—' : `${fmtPct(g.utilization)}%`}
</span>
<Sparkline values={gpuHist[i] ?? []} warn={gpuWarn(g)} />
<span className="gpu-num muted">
{g.fan < 0 ? t('팬 —') : t('팬 {f}%', { f: fmtPct(g.fan) })}
</span>
<span className="gpu-num muted">{fmtTemp(g.tempC)}</span>
<span className="gpu-num muted">
{g.memTotal > 0 ? `${fmtBytes(g.memUsed)} / ${fmtBytes(g.memTotal)}` : '—'}
</span>
</div>
))}
</div>
)}
</div>
)}
{disk && (
<Stat
label={t('디스크 {mount}', { mount: disk.mountPoint })}
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1522,11 +1522,89 @@ button.danger {
color: var(--fg-faint);
margin-left: 1px;
}
.metric-note {
font-size: var(--text-xs);
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.metric[data-warn] .metric-value {
color: var(--danger);
font-weight: 600;
}

/* Per-card GPU breakdown. One card renders as a plain tile; several collapse
to the busiest and open the rest here. */
.metric-pop {
position: relative;
}
.metric-btn {
display: flex;
align-items: center;
gap: var(--sp-1);
padding: 2px var(--sp-1);
margin: -2px calc(-1 * var(--sp-1));
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.metric-btn:hover,
.metric-btn[aria-expanded='true'] {
background: var(--bg-hover);
border-color: var(--border);
}
.metric-caret {
font-size: var(--text-xs);
color: var(--fg-faint);
}
.gpu-panel {
position: absolute;
top: calc(100% + var(--sp-2));
left: 0;
z-index: 30;
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-raised);
box-shadow: 0 8px 24px #00000030;
}
.gpu-row {
display: flex;
align-items: center;
gap: var(--sp-3);
white-space: nowrap;
}
.gpu-idx {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--fg-faint);
}
.gpu-name {
flex: 1;
min-width: 12ch;
max-width: 28ch;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--text-sm);
}
.gpu-num {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
text-align: right;
min-width: 6ch;
}
.gpu-row[data-warn] .metric-value {
color: var(--danger);
font-weight: 600;
}

.spark {
color: var(--accent);
overflow: visible;
Expand Down
Loading
Loading