From 35147030e0739f944fe205eec7b089e6fbad96b9 Mon Sep 17 00:00:00 2001 From: Junwon Lee Date: Tue, 18 Aug 2026 23:20:24 +0900 Subject: [PATCH 01/39] v1.3.0 --- README.en.md | 4 +- README.md | 4 +- docs/building.en.md | 2 +- docs/building.md | 2 +- docs/support.en.md | 1 + docs/support.md | 1 + frontend/src/EditorPane.tsx | 44 ++++-- frontend/src/FileExplorer.tsx | 24 +++- frontend/src/FilePreviewPane.tsx | 119 ++++++++++++++++ frontend/src/McpPanel.tsx | 43 ++++++ frontend/src/MetricsBar.tsx | 139 ++++++++++++++++++- frontend/src/app.css | 151 ++++++++++++++++++++ frontend/src/ipc.ts | 41 ++++++ frontend/src/locale-en.ts | 14 +- frontend/src/openFiles.ts | 24 +++- internal/adapter/metrics.go | 114 ++++++++++++++- internal/adapter/metrics_test.go | 67 +++++++++ internal/adapter/windows_metrics.go | 14 ++ internal/app/files.go | 167 +++++++++++++++++++++- internal/app/gpu_timeout_test.go | 62 +++++++++ internal/app/mcp.go | 74 ++++++++-- internal/app/mcp_test.go | 128 +++++++++++++++-- internal/app/preview_test.go | 208 ++++++++++++++++++++++++++++ internal/app/version.go | 2 +- internal/config/settings.go | 14 +- internal/i18n/catalog_en.go | 3 +- wails.json | 2 +- 27 files changed, 1400 insertions(+), 68 deletions(-) create mode 100644 frontend/src/FilePreviewPane.tsx create mode 100644 internal/app/gpu_timeout_test.go create mode 100644 internal/app/preview_test.go diff --git a/README.en.md b/README.en.md index a5aa27e..ca8c74c 100644 --- a/README.en.md +++ b/README.en.md @@ -85,7 +85,7 @@ All the server does is **run commands it already had and hand back text**. Which | | | |---|---| -| **Files** | Browse as a tree, **filter by name**, upload, download (with progress, cancel and **resume after an interruption**), whole folders, rename, delete, permissions (checkboxes or `chmod 755` typed directly) | +| **Files** | Browse as a tree, **filter by name**, upload, download (with progress, cancel and **resume after an interruption**), whole folders, rename, delete, permissions (checkboxes or `chmod 755` typed directly). Files the editor will not open come up **read-only** — images are drawn, anything else shows its opening bytes as hex | | **Code editing** | Split view beside the tree, file tabs, syntax highlighting for 24 languages, find/replace, **a diff before every save**, **atomic saves** (temp file + rename) | | **Services** | systemd units / Windows services. List, filter, start/stop/restart, set start-at-boot, **live log tailing** (Linux) | | **Processes** | A task-manager table. Sort, search, tree view, terminate (TERM then KILL), change priority | @@ -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..bca1574 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ RDP·VNC·TeamViewer는 서버의 **화면 픽셀을 영상으로 스트리밍** | | | |---|---| -| **파일** | 트리 탐색·**이름으로 거르기**·업로드·다운로드(진행률·취소·**끊기면 이어받기**)·폴더째 전송·이름 변경·삭제·권한(체크박스 + `chmod 755` 직접 입력) | +| **파일** | 트리 탐색·**이름으로 거르기**·업로드·다운로드(진행률·취소·**끊기면 이어받기**)·폴더째 전송·이름 변경·삭제·권한(체크박스 + `chmod 755` 직접 입력). 편집기가 못 여는 파일은 **읽기 전용으로** — 이미지는 그리고, 나머지는 앞부분을 hex로 | | **코드 편집** | 트리 옆 분할 뷰 · 파일 탭 · 문법 강조 24종 · 찾기/바꾸기 · **저장 전 diff** · **원자적 저장**(임시 파일 + rename). **서버에도 클라이언트에도 편집기를 깔지 않습니다** | | **서비스** | systemd 유닛 / Windows 서비스. 목록·필터·시작/중지/재시작·자동 시작 설정·**실시간 로그 보기**(Linux) | | **프로세스** | 작업 관리자식 테이블. 정렬·검색·트리 보기·종료(TERM→KILL 2단계)·우선순위 변경 | @@ -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 ae9ebe8..12f2eb7 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.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. +v1.3.0 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 ad06ae8..a49fac4 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.2를 만든 환경: Go 1.26.5 · Node 22.13.1 · Wails 2.14.0 · Docker 29.4.0 (macOS 26.5.2 arm64). +v1.3.0을 만든 환경: Go 1.26.5 · Node 22.13.1 · Wails 2.14.0 · Docker 29.4.0 (macOS 26.5.2 arm64). diff --git a/docs/support.en.md b/docs/support.en.md index 91960b9..8d25ecf 100644 --- a/docs/support.en.md +++ b/docs/support.en.md @@ -44,6 +44,7 @@ file chooser have not. | **Debian, RHEL/Rocky 8** | Share the systemd 245 table-parsing path, so they should work, but untested | | **Podman** | Docker-compatible CLI, so the parser is shared, but never run | | **Windows containers** | The test machine had no Docker | +| **NVIDIA GPU monitoring** | The parser is checked against real `nvidia-smi` output, and the rest of the metrics are proven to survive a wedged driver. But **the author has no NVIDIA card**, so it has never run against real hardware here. The contributor ([@INMD1](https://github.com/INMD1)) confirmed it works on theirs | | **Compose v1 and `podman compose`** | Only the Compose v2 plugin was tried. The standalone `docker-compose` (v1) cannot address a project without its file and is not supported. `podman compose` takes the same code path but was never run | | **ProxyJump against a real bastion** | Verified against two containers (bastion + target). Never tried against an actual bastion. Multi-hop is not implemented and is refused | | **macOS and BSD servers** | No adapter. Connecting works and **files and terminal do too**; the other tabs explain themselves | diff --git a/docs/support.md b/docs/support.md index 80efee4..3a56e88 100644 --- a/docs/support.md +++ b/docs/support.md @@ -44,6 +44,7 @@ Linux 클라이언트는 **창이 열리고 접속·조회까지** 확인했습 | **Debian, RHEL·Rocky 8** | systemd 245 표 파싱 경로를 공유하므로 동작이 예상되나 확인 안 됨 | | **Podman** | `docker` 호환 CLI라 파서를 공유하지만 실행 안 해봄 | | **Windows 컨테이너** | 테스트한 서버에 Docker가 없었음 | +| **NVIDIA GPU 모니터링** | 파서는 실제 `nvidia-smi` 출력으로 검증했고, 드라이버가 멈췄을 때 나머지 지표가 살아남는 것도 확인했습니다. 다만 **저자 환경에 NVIDIA 카드가 없어** 카드가 달린 실기에서는 확인하지 못했습니다. 기여자([@INMD1](https://github.com/INMD1)) 환경에서 동작하는 것을 확인했습니다 | | **Compose v1·podman compose** | Compose v2 플러그인만 확인했습니다. 독립 실행형 `docker-compose`(v1)는 파일 없이 프로젝트를 다루지 못해 지원하지 않습니다. `podman compose`는 같은 경로를 타지만 실행 안 해봄 | | **ProxyJump 실서버** | 컨테이너 두 대(경유 + 목적지)로 확인. 실제 bastion으로는 안 해봄. 다단은 미구현이고 거부합니다 | | **macOS·BSD 서버** | 어댑터 없음. 연결하면 **파일과 터미널만** 동작하고, 나머지 탭은 이유를 화면에 적습니다 | diff --git a/frontend/src/EditorPane.tsx b/frontend/src/EditorPane.tsx index e6fdcee..a013a6a 100644 --- a/frontend/src/EditorPane.tsx +++ b/frontend/src/EditorPane.tsx @@ -1,5 +1,6 @@ import { Suspense, lazy, useEffect, useRef, useState, type ReactNode } from 'react' import { CodeEditor } from './CodeEditor' +import { FilePreviewPane } from './FilePreviewPane' import { detectLanguage } from './editorLanguage' import { ReadTextFile, SaveTextFile, type TextFile } from './ipc' import { @@ -42,11 +43,14 @@ export function EditorPane({ hostID, onError, onSaved, + onDownload, }: { hostID: string onError: (msg: string) => void /** Called with the saved path so the tree can reread just that directory. */ onSaved: (path: string) => void + /** A read-only tab's only action: get the file rather than look at it. */ + onDownload: (path: string) => void }) { const { files, active } = useOpenFiles(hostID) const [confirm, setConfirm] = useState(null) @@ -173,27 +177,36 @@ export function EditorPane({ ))} - setDoc(hostID, file.path, doc)} - onSave={() => latest.current && requestSave(latest.current)} - onCursor={(line, col) => setCursor({ line, col })} - /> + {file.preview ? ( + onDownload(file.path)} /> + ) : ( + setDoc(hostID, file.path, doc)} + onSave={() => latest.current && requestSave(latest.current)} + onCursor={(line, col) => setCursor({ line, col })} + /> + )}
{file.path} - {lang?.label ?? t('일반 텍스트')} - - {cursor.line}:{cursor.col} + + {file.preview ? t('읽기 전용') : lang?.label ?? t('일반 텍스트')} + {!file.preview && ( + + {cursor.line}:{cursor.col} + + )} {notice && {notice}} {/* The keyboard already does this; these are here because a shortcut nobody knows about is not a feature. Double-click the number to go back to the default, as everywhere else in the app. */} + {!file.preview && ( + )} + {!file.preview && ( + + )}
{confirm?.kind === 'save' && ( diff --git a/frontend/src/FileExplorer.tsx b/frontend/src/FileExplorer.tsx index 034601c..c8cd7ac 100644 --- a/frontend/src/FileExplorer.tsx +++ b/frontend/src/FileExplorer.tsx @@ -12,6 +12,7 @@ import { PickLocalUploadDir, ReadTextFile, RenamePath, + PreviewFile, StartDownload, StartUpload, type DirListing, @@ -22,6 +23,7 @@ import { clearReveal, forgetPaths, openFile, + openPreview, renameOpen, setTreeWidth, unsavedUnder, @@ -604,11 +606,17 @@ export function FileExplorer({ try { const file = await ReadTextFile(hostID, e.path) if (file.tooLarge) { - onError(t('{name}: {size} — 편집기 한도(2MB)를 넘습니다', { name: e.name, size: fmtSize(file.size, false) })) + onError(t('{name}: {size} — 편집기 한도(2MB)를 넘어 읽기 전용으로 엽니다', { + name: e.name, + size: fmtSize(file.size, false), + })) + openPreview(hostID, await PreviewFile(hostID, e.path)) return } if (file.binary) { - onError(t('{name}: 바이너리 파일이라 편집할 수 없습니다', { name: e.name })) + // Not editable is not the same as not viewable. A refusal here was the + // whole answer until now, which left no way to look at the file at all. + openPreview(hostID, await PreviewFile(hostID, e.path)) return } openFile(hostID, file) @@ -719,6 +727,17 @@ export function FileExplorer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, hostID]) + /** Download exactly one path — what a read-only tab offers. */ + const downloadOne = async (p: string) => { + try { + const dir = await PickLocalDir() + if (!dir) return + await StartDownload(hostID, [p], dir) + } catch (e) { + onError(String(e)) + } + } + const download = async () => { if (selectedEntries.length === 0) { onError(t('다운로드할 항목을 선택하세요')) @@ -1027,6 +1046,7 @@ export function FileExplorer({ hostID={hostID} onError={onError} onSaved={(path) => void refreshDir(parentOf(path))} + onDownload={(path) => void downloadOne(path)} /> diff --git a/frontend/src/FilePreviewPane.tsx b/frontend/src/FilePreviewPane.tsx new file mode 100644 index 0000000..47f70a2 --- /dev/null +++ b/frontend/src/FilePreviewPane.tsx @@ -0,0 +1,119 @@ +import type { FilePreview } from './ipc' +import { t } from './i18n' + +// A read-only look at a file the editor will not open (§4.2). +// +// Until this existed, opening a binary raised a toast and stopped there — the +// file could not be seen at all. Somebody who opens a .png on a server wants to +// look at it; somebody who opens an unknown blob wants to know what it is. +// Neither needs an editor, and neither is served by a refusal. + +/** Bytes per row, chosen so the ASCII column lands where a hex dump puts it. */ +const COLUMNS = 16 + +function fmtSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB'] + let n = bytes / 1024 + let i = 0 + while (n >= 1024 && i < units.length - 1) { + n /= 1024 + i++ + } + return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}` +} + +function decode(b64: string): Uint8Array { + const bin = atob(b64) + const out = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out +} + +/** One `offset hex… ascii` line per 16 bytes, as `xxd` would print it. */ +function hexRows(bytes: Uint8Array) { + const rows = [] + for (let at = 0; at < bytes.length; at += COLUMNS) { + const slice = bytes.subarray(at, at + COLUMNS) + const hex = Array.from(slice, (b) => b.toString(16).padStart(2, '0')) + // Padded so the ASCII column stays put on a short final row. + while (hex.length < COLUMNS) hex.push(' ') + rows.push({ + offset: at.toString(16).padStart(8, '0'), + hex: hex.join(' '), + // Anything outside printable ASCII becomes a dot, which is what makes the + // column readable at all. + ascii: Array.from(slice, (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '·')).join(''), + }) + } + return rows +} + +export function FilePreviewPane({ + preview, + onDownload, +}: { + preview: FilePreview + onDownload: () => void +}) { + if (preview.tooLarge) { + return ( +
+
+ {t('{size} — 그리기에는 너무 큰 이미지입니다. 받아서 여세요.', { + size: fmtSize(preview.size), + })} +
+
+ +
+
+ ) + } + + if (preview.kind === 'image') { + return ( +
+ {/* Checkerboard behind it so a transparent PNG does not look like a + broken load on a dark background. */} +
+ {preview.path} +
+
+ + {preview.mime} · {fmtSize(preview.size)} + + + +
+
+ ) + } + + const rows = hexRows(decode(preview.data)) + return ( +
+
+ {rows.map((r) => ( +
+ {r.offset} + {r.hex} + {r.ascii} +
+ ))} +
+
+ + {preview.mime} · {fmtSize(preview.size)} + + {preview.truncated && ( + + {t('앞부분 {n} 만 표시합니다.', { n: fmtSize(rows.length * COLUMNS) })} + + )} + + +
+
+ ) +} diff --git a/frontend/src/McpPanel.tsx b/frontend/src/McpPanel.tsx index fe5a3c0..3d7c0c1 100644 --- a/frontend/src/McpPanel.tsx +++ b/frontend/src/McpPanel.tsx @@ -3,6 +3,7 @@ import { MCPChanges, MCPState, RestoreMCPChange, + PinMCPPort, RotateMCPToken, SetMCPEnabled, SetMCPHost, @@ -151,6 +152,48 @@ export function McpPanel({ {t('127.0.0.1 에만 열립니다. 외부 인터페이스로 여는 설정은 없습니다.')} + {/* The address moving is invisible from the client side — it + just stops connecting. Saying it here is the whole fix for + the half of #2 that was about not knowing. */} + {state.port && state.wantedPort && state.port !== state.wantedPort && ( + <> + + {t('{wanted} 번 포트가 사용 중이라 {got} 번으로 열렸습니다', { + wanted: state.wantedPort, + got: state.port, + })} + + + {t('다음 실행에서 {wanted} 번이 비어 있으면 그쪽으로 돌아갑니다.', { + wanted: state.wantedPort, + })} + +
+ +
+ + )} + {state.portPinned && ( +
+ + {t('{port} 번 포트로 고정되어 있습니다.', { port: state.port ?? 0 })} + + +
+ )}
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 && ( write: Record delete: Record + /** The port actually bound, and the one asked for. They differ when something + * else held the preferred port. */ + port?: number + wantedPort?: number + portPinned?: boolean /** The exact `claude mcp add` line, assembled by Go so nobody mistypes it. */ snippet?: string /** The same for Codex, which wants the token as an environment variable's @@ -277,6 +282,19 @@ export interface Compose { oneOff?: boolean } +/** A read-only look at a file the editor will not open (§4.2). */ +export interface FilePreview { + path: string + size: number + /** "image" when the webview can draw it, "binary" otherwise. */ + kind: 'image' | 'binary' + mime: string + /** base64. The whole file for an image, a bounded prefix otherwise. */ + data: string + truncated?: boolean + tooLarge?: boolean +} + /** An open terminal tab (§4.6). */ export interface TerminalInfo { id: string @@ -313,6 +331,23 @@ export interface Filesystem { percent: number } +/** One NVIDIA card (§4.7). NVIDIA only: nvidia-smi ships with every driver and + * answers over a plain SSH connection; AMD and Intel need a package that is not + * installed by default. */ +export interface GPU { + index: number + name: string + /** -1 where the card does not report the figure, the same convention cpu uses. + * Passively cooled datacentre cards have no fan reading, and a 0 there would + * read as a stopped fan. */ + utilization: number + fan: number + tempC: number + memTotal: number + memUsed: number + memPercent: number +} + export interface MetricsView { /** -1 until a second sample exists — the counters are totals since boot. */ cpu: number @@ -332,6 +367,8 @@ export interface MetricsView { filesystems: Filesystem[] /** The filtered, sorted subset worth showing. */ disks: Filesystem[] + /** Empty on every host without an NVIDIA card, which is most of them. */ + gpus: GPU[] } /** An open live-log follow (§4.3, §4.5). */ @@ -490,6 +527,7 @@ interface Bindings { SetMCPEnabled(enabled: boolean): Promise SetMCPHost(hostID: string, allowed: boolean): Promise RotateMCPToken(): Promise + PinMCPPort(port: number): Promise SetMCPWritePolicy(hostID: string, mode: string, minutes: number): Promise AnswerMCPWrite(id: string, approved: boolean): Promise MCPChanges(hostID: string): Promise @@ -578,6 +616,7 @@ interface Bindings { typed: string, ): Promise ReadTextFile(id: string, p: string): Promise + PreviewFile(id: string, p: string): Promise WriteTextFile(id: string, p: string, content: string): Promise SaveTextFile(id: string, req: SaveRequest): Promise @@ -707,6 +746,7 @@ export const MCPState = () => api().MCPState() export const SetMCPEnabled = (enabled: boolean) => api().SetMCPEnabled(enabled) export const SetMCPHost = (hostID: string, allowed: boolean) => api().SetMCPHost(hostID, allowed) export const RotateMCPToken = () => api().RotateMCPToken() +export const PinMCPPort = (port: number) => api().PinMCPPort(port) export const SetMCPWritePolicy = (hostID: string, mode: string, minutes: number) => api().SetMCPWritePolicy(hostID, mode, minutes) export const AnswerMCPWrite = (id: string, approved: boolean) => api().AnswerMCPWrite(id, approved) @@ -805,6 +845,7 @@ export const DeletePaths = ( typed: string, ) => api().DeletePaths(id, paths, recursive, typed) export const ReadTextFile = (id: string, p: string) => api().ReadTextFile(id, p) +export const PreviewFile = (id: string, p: string) => api().PreviewFile(id, p) export const WriteTextFile = (id: string, p: string, content: string) => api().WriteTextFile(id, p, content) export const SaveTextFile = (id: string, req: SaveRequest) => diff --git a/frontend/src/locale-en.ts b/frontend/src/locale-en.ts index 7a425d1..7aa9050 100644 --- a/frontend/src/locale-en.ts +++ b/frontend/src/locale-en.ts @@ -20,12 +20,15 @@ export const en: Record = { '서버가 {os} 로 확인됐습니다. 이 OS를 다루는 어댑터가 아직 없습니다.': 'The server identified as {os}. There is no adapter for that OS yet.', '서버가 어떤 OS인지 확인할 수 없었습니다 — 아래 감지 과정을 참고하세요.': 'Could not tell what OS the server runs — see the detection trace below.', '설치되어 있는데도 이렇게 나온다면, 로그인 셸의 PATH에 없을 수 있습니다 — 터미널 탭에서 `command -v docker` 로 확인해보세요.': 'If it is installed and you still see this, it may be missing from the login shell\'s PATH — check with `command -v docker` in the Terminal tab.', + '앞부분 {n} 만 표시합니다.': 'Showing the first {n}.', '알 수 없는 배포판': 'unknown distribution', '이 OS': 'this OS', '일회성': 'one-off', '전체 시작': 'Start all', '전체 재시작': 'Restart all', '전체 중지': 'Stop all', + '이 포트로 고정': 'Pin this port', + '읽기 전용': 'Read-only', '이 서버': 'this server', '이 서버에는 컨테이너 런타임이 없습니다': 'This server has no container runtime', '이 서버의 SSH 접속 목록을 읽을 수 없습니다': 'Cannot read this server\'s SSH sessions', @@ -54,6 +57,7 @@ export const en: Record = { '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).', @@ -88,7 +92,6 @@ export const en: Record = { '{m}분': '{m}m', '{name} 닫기': 'Close {name}', '{name}: {size} — 편집기 한도(2MB)를 넘습니다': '{name}: {size} — over the editor\'s 2MB limit', - '{name}: 바이너리 파일이라 편집할 수 없습니다': '{name}: a binary file cannot be edited', '{n} 부터 이어받습니다': 'Continues from {n}', '{n} 실패': '{n} failed', '{n}개 일치': '{n} {n#match|matches}', @@ -105,6 +108,10 @@ export const en: Record = { '{n}초': '{n}s', '{n}개': '{n}', '{n}개 · {r} 실행 중': '{n} · {r} running', + '{port} 번 포트로 고정되어 있습니다.': 'Pinned to port {port}.', + '{wanted} 번 포트가 사용 중이라 {got} 번으로 열렸습니다': 'Port {wanted} was in use, so this opened on {got}', + '{name}: {size} — 편집기 한도(2MB)를 넘어 읽기 전용으로 엽니다': '{name}: {size} — over the editor\'s 2MB limit, opening read-only', + '{size} — 그리기에는 너무 큰 이미지입니다. 받아서 여세요.': '{size} — too large an image to draw. Download it to open it.', '{path} 에서 가져올 호스트를 찾지 못했습니다.': 'Found no hosts to import in {path}.', '{path}: 바이너리 파일이라 열 수 없습니다': '{path}: a binary file cannot be opened', '{path}: 편집기 한도(2MB)를 넘어 열 수 없습니다': '{path}: over the editor\'s 2MB limit, so it cannot be opened', @@ -132,6 +139,7 @@ export const en: Record = { '고침': 'fixed', '관리자 권한으로 보기': 'View as administrator', '관리자 권한으로 재시도': 'Retry as administrator', + '고정 해제': 'Unpin', '권한': 'Permissions', '권한이 필요합니다': 'Administrator rights are required', '그룹': 'Group', @@ -159,6 +167,8 @@ export const en: Record = { '대기': 'Queued', '다시 연결': 'Reconnect', '대상': 'Affects', + '다음 실행부터 이 포트를 씁니다': 'Use this port from the next start', + '다음 실행에서 {wanted} 번이 비어 있으면 그쪽으로 돌아갑니다.': 'It goes back to {wanted} on the next start if that port is free.', '대기 {n}ms': 'queued {n}ms', '덮어쓰기': 'Overwrite', '덮어씀': 'overwritten', @@ -400,6 +410,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/frontend/src/openFiles.ts b/frontend/src/openFiles.ts index 68cf736..9d9371d 100644 --- a/frontend/src/openFiles.ts +++ b/frontend/src/openFiles.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react' -import type { TextFile } from './ipc' +import type { FilePreview, TextFile } from './ipc' // Which files the editor has open, per host (§4.7-3). // @@ -19,6 +19,9 @@ export type OpenFile = { baseModTime: number baseSize: number perm: number + /** Set on a tab the editor will not open — an image, or an unknown blob. + * Present means read-only: there is no document to save. */ + preview?: FilePreview } export type HostFiles = { @@ -93,6 +96,25 @@ export function openFile(hostID: string, file: TextFile) { }) } +/** Opens a read-only tab for a file the editor refuses (§4.2). */ +export function openPreview(hostID: string, preview: FilePreview) { + update(hostID, (h) => { + if (h.files.some((f) => f.path === preview.path)) return { ...h, active: preview.path } + const opened: OpenFile = { + path: preview.path, + name: preview.path.split('/').pop() || preview.path, + // Empty and equal, so isDirty is false and every save path stays shut. + doc: '', + base: '', + baseModTime: 0, + baseSize: preview.size, + perm: 0, + preview, + } + return { files: [...h.files, opened], active: preview.path } + }) +} + export function closeFile(hostID: string, path: string) { update(hostID, (h) => { const i = h.files.findIndex((f) => f.path === path) diff --git a/internal/adapter/metrics.go b/internal/adapter/metrics.go index a941432..e3d6adb 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,31 @@ 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. +// +// It is also the only line that can hang. A wedged driver — an Xid fault, a card +// that has fallen off the bus — leaves nvidia-smi blocked in the kernel, and +// because this is one script the whole poll blocks with it: CPU, memory and disk +// would go dark for the twenty seconds of pollTimeout, at exactly the moment +// somebody needs them. `timeout` caps that at five seconds and costs an empty +// GPU section instead. It is a shell builtin test, so hosts without coreutils +// `timeout` fall through to running nvidia-smi bare rather than losing the +// feature. +// +// Windows has no cheap equivalent — a PowerShell job per poll is worse than the +// problem — so WindowsMetricsScript keeps only its Get-Command guard. 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'; GPUTO=; command -v timeout >/dev/null 2>&1 && GPUTO='timeout 5' +$GPUTO 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 +82,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 +135,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 +191,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 +306,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 } diff --git a/internal/app/files.go b/internal/app/files.go index e19eebf..b3edeed 100644 --- a/internal/app/files.go +++ b/internal/app/files.go @@ -1,16 +1,20 @@ package app import ( + "bytes" "context" + "encoding/base64" "errors" "fmt" "io" + "net/http" "os" "path" "sort" "strings" "sync/atomic" "time" + "unicode/utf8" "github.com/cpprhtn/LiteDeck/internal/i18n" "github.com/pkg/sftp" @@ -33,6 +37,23 @@ const maxListEntries = 20000 // it would freeze the webview. const maxEditableBytes = 2 << 20 // 2 MiB +// maxPreviewBytes bounds an image, which is the only preview that needs the +// whole file. Higher than the editor's limit because nothing is parsed or kept +// in a document model, but still bounded: this crosses the IPC boundary as +// base64, a third larger again. Anything that is not an image is answered from +// its head, so a blob's size never decides whether it can be looked at. +const maxPreviewBytes = 8 << 20 // 8 MiB + +// hexPreviewBytes is how much of a non-image binary is worth showing. Enough to +// read a magic number and any header strings, which is what somebody asking +// "what is this file" actually wants. +const hexPreviewBytes = 4 << 10 // 4 KiB + +// binarySniffBytes is how much of a file is read before deciding it is not +// text. git uses 8000 for the same judgement; the number is a compromise +// between catching a NUL that appears late and not paying for the whole file. +const binarySniffBytes = 8 << 10 // 8 KiB + // FileEntry is one row of the file explorer. type FileEntry struct { Name string `json:"name"` @@ -364,20 +385,154 @@ func (a *App) ReadTextFile(hostID, p string) (TextFile, error) { } defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, maxEditableBytes+1)) - if err != nil { + // Decided from the head, not from the whole file. Reading two megabytes of + // an ELF binary to find the NUL in its fifth byte is work the server does + // for nothing, and the caller goes straight on to ask for a preview. + head := make([]byte, binarySniffBytes) + n, err := io.ReadFull(f, head) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { return out, fmt.Errorf("%s: %w", cleaned, err) } - // A NUL byte means this is not text; rendering it in a textarea would - // corrupt the file the moment the user pressed save. - if idx := strings.IndexByte(string(data), 0); idx >= 0 { + head = head[:n] + if looksBinary(head, int64(n) >= fi.Size()) { out.Binary = true return out, nil } - out.Content = string(data) + + rest, err := io.ReadAll(io.LimitReader(f, maxEditableBytes+1-int64(n))) + if err != nil { + return out, fmt.Errorf("%s: %w", cleaned, err) + } + out.Content = string(append(head, rest...)) + return out, nil +} + +// looksBinary judges a file by its opening bytes. +// +// A NUL is the classic tell and the one that matters most: rendering it in the +// editor and saving would write it back through a UTF-8 string round trip. +// Invalid UTF-8 is the other, and it is the one that actually corrupts — bytes +// that are not text become replacement characters on the way in and are saved +// as those, silently rewriting the file. +// +// `complete` says the head is the entire file. When it is not, a multi-byte +// character can be cut in half at the boundary, and calling a UTF-8 file binary +// for the sake of its 8192nd byte would be wrong — so up to three trailing +// bytes are given the benefit of the doubt. +func looksBinary(head []byte, complete bool) bool { + if bytes.IndexByte(head, 0) >= 0 { + return true + } + if utf8.Valid(head) { + return false + } + if complete { + return true + } + for drop := 1; drop <= 3 && drop <= len(head); drop++ { + if utf8.Valid(head[:len(head)-drop]) { + return false + } + } + return true +} + +// FilePreview is a read-only look at a file the editor will not open (§4.2). +// +// Refusing to show a binary at all was the whole answer until now: the file +// explorer raised a toast and there was no way to see the thing. Somebody who +// opens a .png on a server wants to look at it, and somebody who opens an +// unknown blob wants to know what it is — neither needs an editor. +type FilePreview struct { + Path string `json:"path"` + Size int64 `json:"size"` + // Kind is "image" when the bytes are one the webview can draw, "binary" + // otherwise. Sniffed from content rather than the extension: a screenshot + // saved as .log is still a screenshot, and a .png that is really a tarball + // must not be handed to an . + Kind string `json:"kind"` + MIME string `json:"mime"` + // Data is base64. For an image it is the whole file, for anything else the + // first hexPreviewBytes. + Data string `json:"data"` + // Truncated says Data is a prefix, so the screen can say so rather than + // implying the file is 4 KiB long. + Truncated bool `json:"truncated"` + // TooLarge means not even the preview would load it. + TooLarge bool `json:"tooLarge"` +} + +// PreviewFile reads a file for the read-only viewer. +func (a *App) PreviewFile(hostID, p string) (FilePreview, error) { + cleaned, err := CleanRemotePath(p) + if err != nil { + return FilePreview{}, err + } + client, err := a.mgr.SFTP(hostID) + if err != nil { + return FilePreview{}, err + } + + fi, err := client.Stat(cleaned) + if err != nil { + return FilePreview{}, fmt.Errorf("%s: %w", cleaned, err) + } + out := FilePreview{Path: cleaned, Size: fi.Size(), Kind: "binary"} + + f, err := client.Open(cleaned) + if err != nil { + return out, fmt.Errorf("%s: %w", cleaned, err) + } + defer f.Close() + + // The head is read first and is often the whole cost. Sniffing needs a few + // hundred bytes and the hex view shows this much, so a blob of any size — + // a core dump, a database, a model checkpoint — is answered by one 4 KiB + // read rather than by pulling megabytes across to throw them away. + head := make([]byte, hexPreviewBytes) + n, err := io.ReadFull(f, head) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return out, fmt.Errorf("%s: %w", cleaned, err) + } + head = head[:n] + + out.MIME = sniffMIME(head) + if !strings.HasPrefix(out.MIME, "image/") { + out.Data = base64.StdEncoding.EncodeToString(head) + out.Truncated = fi.Size() > int64(n) + return out, nil + } + + // Only an image needs the rest, because only an image is shown whole. + out.Kind = "image" + if fi.Size() > maxPreviewBytes { + out.TooLarge = true + return out, nil + } + rest, err := io.ReadAll(io.LimitReader(f, maxPreviewBytes-int64(n))) + if err != nil { + return out, fmt.Errorf("%s: %w", cleaned, err) + } + out.Data = base64.StdEncoding.EncodeToString(append(head, rest...)) return out, nil } +// sniffMIME identifies the bytes without trusting the file name. +// +// SVG is excluded on purpose even though a webview draws it: it is a document +// that can carry script, and this viewer shows whatever is on a server the user +// may not fully control. It falls through to the hex path, where it is inert. +func sniffMIME(data []byte) string { + mime := http.DetectContentType(data) + if i := strings.IndexByte(mime, ';'); i >= 0 { + mime = strings.TrimSpace(mime[:i]) + } + if mime == "image/svg+xml" || mime == "text/xml" { + return "application/octet-stream" + } + return mime +} + // SaveRequest is one save from the editor (§4.7-3). type SaveRequest struct { Path string `json:"path"` diff --git a/internal/app/gpu_timeout_test.go b/internal/app/gpu_timeout_test.go new file mode 100644 index 0000000..194c627 --- /dev/null +++ b/internal/app/gpu_timeout_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "strings" + "testing" + "time" + + "github.com/cpprhtn/LiteDeck/internal/adapter" +) + +// A wedged nvidia-smi must not take the rest of the metrics with it. +// +// The GPU line rides on the same script as CPU, memory and disk, so a driver +// fault that leaves nvidia-smi blocked in the kernel would black out the whole +// summary bar until pollTimeout — twenty seconds, at the moment those numbers +// matter most. The timeout caps it; this proves the cap by putting a hanging +// nvidia-smi on PATH. +func TestMetricsSurviveAHangingNvidiaSmi(t *testing.T) { + a := connectedApp(t) + conn, err := a.mgr.Conn("fixture") + if err != nil { + t.Fatal(err) + } + + // A fake that never returns, earlier on PATH than anything real. + if _, err := conn.Exec(testCtx(t), "sh", "-c", + "mkdir -p /tmp/fakebin && printf '#!/bin/sh\\nsleep 300\\n' > /tmp/fakebin/nvidia-smi "+ + "&& chmod +x /tmp/fakebin/nvidia-smi"); err != nil { + t.Skipf("could not plant the fake: %v", err) + } + if _, err := conn.Exec(testCtx(t), "sh", "-c", "command -v timeout"); err != nil { + t.Skip("this fixture has no coreutils timeout, which is the case the fallback covers") + } + + start := time.Now() + res, err := conn.Exec(testCtx(t), "sh", "-c", + "PATH=/tmp/fakebin:$PATH; "+adapter.MetricsScript) + if err != nil { + t.Fatalf("Exec: %v", err) + } + took := time.Since(start) + + // Five seconds for the cap, plus room for a slow container. + if took > 12*time.Second { + t.Errorf("the poll took %s; the timeout did not cap the hang", took) + } + + // And the numbers that were never in doubt are still there. + m, err := adapter.ParseMetrics(res.Stdout, adapter.CPUTimes{}) + if err != nil { + t.Fatalf("ParseMetrics: %v", err) + } + if m.MemTotal == 0 { + t.Error("memory went dark because the GPU line hung") + } + if len(m.GPUs) != 0 { + t.Errorf("got %d GPUs from a command that never answered", len(m.GPUs)) + } + if !strings.Contains(string(res.Stdout), "#gpu") { + t.Error("the gpu section marker is missing, so the sections may have shifted") + } +} diff --git a/internal/app/mcp.go b/internal/app/mcp.go index e26b757..98a487a 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -39,6 +39,8 @@ type mcpState struct { mu sync.Mutex listener *mcp.Listener url string + // wanted is the port that was asked for, which is not always the one bound. + wanted int } // MCPStatus is what the settings screen draws. @@ -54,6 +56,13 @@ type MCPStatus struct { Write map[string]WritePolicyView `json:"write"` // Delete lists hosts where file deletion is offered at all. Delete map[string]bool `json:"delete"` + // Port is the port actually bound, and WantedPort the one asked for. They + // differ when something else held the preferred port, and the screen has to + // say so — a client configured against the old address just stops + // connecting, with nothing anywhere to explain it (#2). + Port int `json:"port,omitempty"` + WantedPort int `json:"wantedPort,omitempty"` + PortPinned bool `json:"portPinned,omitempty"` // Snippet is the line to paste into Claude Code. Snippet string `json:"snippet,omitempty"` // CodexSnippet is the same thing for Codex CLI, which takes the token as the @@ -105,20 +114,27 @@ func (a *App) startMCP() { a.registerMCPTools(srv) a.registerMCPWriteTools(srv) + // The port the user asked for. A stored port only counts when they pinned + // it: before v1.2.3 whatever got bound was written back here, so a value on + // its own says nothing about what anyone wanted (#2). + wanted := defaultMCPPort + if s.PortPinned && s.Port != 0 { + wanted = s.Port + } + opts := mcp.Options{ Token: token, - Port: s.Port, + Port: wanted, Limiter: mcp.NewLimiter(mcpCallsPerSecond, mcpBurst), OnCall: a.logAICall, } - if opts.Port == 0 { - opts.Port = defaultMCPPort - } listener, url, err := srv.Serve(opts) - if err != nil && opts.Port != 0 { + if err != nil { // Something else has the port. Take any free one rather than leaving the - // integration off, and remember it so this is a one-time change. + // integration off — but only for this run. Writing it down is what made + // one busy moment permanent: the next launch would ask for the port it + // had drifted to, get it, and never try the real one again. opts.Port = 0 listener, url, err = srv.Serve(opts) } @@ -128,14 +144,10 @@ func (a *App) startMCP() { } a.mcp.listener, a.mcp.url = listener, url - // Persist whatever was bound. This is what keeps the client config the user - // pasted once from going stale on the next launch. - if bound := listenerPort(listener.Addr()); bound != 0 && bound != s.Port { - s.Port = bound - if err := a.settings.SetMCP(s); err != nil { - a.emit("log:warning", i18n.T("MCP 설정을 저장하지 못했습니다: %v", err)) - } - } + // Silence here is what turned this into a support question: the client the + // user configured once just stops connecting, and nothing on screen says the + // address moved. MCPStatus carries `wanted` so the panel can say so. + a.mcp.wanted = wanted } // listenerPort pulls the port out of a "127.0.0.1:8779" address. @@ -223,7 +235,12 @@ func (a *App) MCPState() MCPStatus { a.mcp.mu.Lock() out.Running = a.mcp.listener != nil out.URL = a.mcp.url + out.WantedPort = a.mcp.wanted + if a.mcp.listener != nil { + out.Port = listenerPort(a.mcp.listener.Addr()) + } a.mcp.mu.Unlock() + out.PortPinned = s.PortPinned if out.Running { // The exact line to paste. A user who has to assemble this from three @@ -241,6 +258,35 @@ func (a *App) MCPState() MCPStatus { return out } +// PinMCPPort fixes the endpoint on a port, or releases it back to the default. +// +// This is the case the old auto-remember behaviour was trying to serve: someone +// whose preferred port was taken, who copied the address they actually got into +// their client. Doing it on their behalf is what broke everyone else, so it is +// a button now — the app never decides this by itself. +// +// Takes effect on the next start; rebinding underneath a connected client would +// break it at the moment the user is trying to make it stable. +func (a *App) PinMCPPort(port int) MCPStatus { + if a.settings == nil { + return a.MCPState() + } + // 0 means "stop pinning", which is how somebody undoes this. + if port != 0 && (port < 1024 || port > 65535) { + s := a.MCPState() + s.Error = i18n.S("포트는 1024 에서 65535 사이여야 합니다") + return s + } + m := a.settings.Get().MCP + m.Port, m.PortPinned = port, port != 0 + if err := a.settings.SetMCP(m); err != nil { + s := a.MCPState() + s.Error = err.Error() + return s + } + return a.MCPState() +} + // SetMCPEnabled turns the endpoint on or off. func (a *App) SetMCPEnabled(enabled bool) MCPStatus { if a.settings == nil { diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go index b595294..a2e6b62 100644 --- a/internal/app/mcp_test.go +++ b/internal/app/mcp_test.go @@ -724,9 +724,13 @@ func TestJournalTimeExpressionsAreValidated(t *testing.T) { } } -// The port has to survive a restart, or the line the user pasted into their MCP -// client is dead the next morning. Letting the OS pick looks tidier and is the -// bug: it lands in that line. +// The address has to survive a restart, or the line the user pasted into their +// MCP client is dead the next morning. Letting the OS pick looks tidier and is +// the bug: it lands in that line. +// +// Stability comes from asking for the same port every time, not from writing +// down whatever was bound — see TestBusyPortFallsBackForThisRunOnly for why the +// second approach made a temporary problem permanent (#2). func TestPortIsStableAcrossRestart(t *testing.T) { dir := t.TempDir() @@ -738,9 +742,10 @@ func TestPortIsStableAcrossRestart(t *testing.T) { t.Fatalf("did not start: %s", state.Error) } url := state.URL - port := first.mcpSettings().Port - if port == 0 { - t.Fatal("the bound port was not written back to settings") + // Nothing is recorded: the default is where it goes back to on its own. + if got := first.mcpSettings().Port; got != 0 { + first.stopMCP() + t.Fatalf("port %d was written to settings; nobody pinned it", got) } first.stopMCP() @@ -754,28 +759,125 @@ func TestPortIsStableAcrossRestart(t *testing.T) { } } -// A taken port must not leave the integration switched off. -func TestBusyPortFallsBackAndIsRemembered(t *testing.T) { +// A taken port must not leave the integration switched off — and must not +// become the new normal either. +// +// Reported as #2: the port that got bound was written back to settings on every +// launch, so one busy moment moved the endpoint for good. The next launch asked +// for the port it had drifted to, got it, and never tried the real one again. +// The client line the user pasted once was dead with nothing on screen to say +// why. +func TestBusyPortFallsBackForThisRunOnly(t *testing.T) { dir := t.TempDir() - // Hold the preferred port so the app has to move. blocker, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", defaultMCPPort)) if err != nil { t.Skipf("port %d is already in use by something else", defaultMCPPort) } - defer blocker.Close() a := New() a.configDir = dir a.settings = config.OpenSettings(dir) state := a.SetMCPEnabled(true) - defer a.stopMCP() if !state.Running { + blocker.Close() t.Fatalf("a busy preferred port must not stop the endpoint: %s", state.Error) } - if got := a.mcpSettings().Port; got == 0 || got == defaultMCPPort { - t.Errorf("fell back to port %d without remembering a usable one", got) + if state.Port == defaultMCPPort { + blocker.Close() + t.Fatal("bound the port that was supposed to be held") + } + // The screen has to be able to say the address moved. + if state.WantedPort != defaultMCPPort { + t.Errorf("WantedPort = %d, want the default %d", state.WantedPort, defaultMCPPort) + } + // And nothing may be written down, or the move outlives the reason for it. + if got := a.mcpSettings().Port; got != 0 { + t.Errorf("the fallback port was persisted as %d", got) + } + a.stopMCP() + + // The obstruction goes away, and the endpoint has to come home by itself. + blocker.Close() + + second := New() + second.configDir = dir + second.settings = config.OpenSettings(dir) + again := second.SetMCPEnabled(true) + defer second.stopMCP() + if !again.Running { + t.Fatalf("did not start on the second run: %s", again.Error) + } + if again.Port != defaultMCPPort { + t.Errorf("port = %d after the obstruction cleared, want %d — the endpoint "+ + "never returns to the address the docs tell people to paste", + again.Port, defaultMCPPort) + } +} + +// A port written by the pre-v1.2.3 auto-remember is not a preference and must +// not be honoured, or upgrading leaves the user exactly where the bug left them. +func TestUnpinnedStoredPortIsIgnored(t *testing.T) { + dir := t.TempDir() + store := config.OpenSettings(dir) + // What the old code would have left behind. + if err := store.SetMCP(config.MCPSettings{Enabled: true, Port: 56802}); err != nil { + t.Fatal(err) + } + + a := New() + a.configDir = dir + a.settings = config.OpenSettings(dir) + state := a.SetMCPEnabled(true) + defer a.stopMCP() + if !state.Running { + t.Skipf("could not start: %s", state.Error) + } + if state.Port == 56802 { + t.Error("a stored port with no PortPinned was treated as a preference") + } +} + +// Pinning is the deliberate version of what the bug did by accident, so it has +// to survive a restart. +func TestPinnedPortIsHonouredAcrossRestart(t *testing.T) { + dir := t.TempDir() + + // A free port to pin, found by asking for one and letting it go. + probe, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + pinned := listenerPort(probe.Addr().String()) + probe.Close() + + a := New() + a.configDir = dir + a.settings = config.OpenSettings(dir) + if state := a.SetMCPEnabled(true); !state.Running { + t.Fatalf("did not start: %s", state.Error) + } + if state := a.PinMCPPort(pinned); state.Error != "" { + t.Fatalf("PinMCPPort: %s", state.Error) + } + a.stopMCP() + + second := New() + second.configDir = dir + second.settings = config.OpenSettings(dir) + again := second.SetMCPEnabled(true) + defer second.stopMCP() + if again.Port != pinned { + t.Errorf("port = %d, want the pinned %d", again.Port, pinned) + } + if !again.PortPinned { + t.Error("PortPinned = false after pinning") + } + + // And releasing it goes back to the default rather than sticking. + if state := second.PinMCPPort(0); state.PortPinned { + t.Error("PortPinned = true after releasing") } } diff --git a/internal/app/preview_test.go b/internal/app/preview_test.go new file mode 100644 index 0000000..f15d6ab --- /dev/null +++ b/internal/app/preview_test.go @@ -0,0 +1,208 @@ +package app + +import ( + "encoding/base64" + "path" + "strings" + "testing" +) + +// What a file is gets decided by its bytes, not its name. +// +// Both directions matter. A screenshot somebody saved as `.log` should still +// draw, and — the direction that bites — a tarball named `.png` must never be +// handed to an tag on the strength of four characters. + +func TestSniffMIMEIgnoresTheFileName(t *testing.T) { + png := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13} + gzip := []byte{0x1f, 0x8b, 0x08, 0, 0, 0, 0, 0, 0, 3} + + if got := sniffMIME(png); got != "image/png" { + t.Errorf("PNG bytes sniffed as %q", got) + } + if got := sniffMIME(gzip); strings.HasPrefix(got, "image/") { + t.Errorf("gzip bytes sniffed as an image (%q) — an would be fed a tarball", got) + } +} + +// SVG is a document, not a picture: it can carry script, and this viewer shows +// files from a server the user may not control. It has to fall through to the +// inert hex path. +func TestSVGIsNotTreatedAsAnImage(t *testing.T) { + svg := []byte(`` + + ``) + if got := sniffMIME(svg); strings.HasPrefix(got, "image/") { + t.Errorf("SVG sniffed as %q; it would be rendered as markup", got) + } +} + +// Plain text reaching the preview means the editor turned it away for size. It +// must not claim to be an image either. +func TestTextIsNotAnImage(t *testing.T) { + if got := sniffMIME([]byte("worker_processes 1;\n")); strings.HasPrefix(got, "image/") { + t.Errorf("text sniffed as %q", got) + } +} + +// The preview against a real SFTP server (§4.2). +// +// The unit tests above cover the sniffing; this covers the part that reads +// actual bytes off a server and decides what crosses the boundary. +func TestPreviewFileOverSFTP(t *testing.T) { + a := connectedApp(t) + dir := scratchDir(t, a, "litedeck-preview") + + // A one-pixel PNG, byte for byte. Written through the terminal because the + // text write path would mangle it. + const b64png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + png := path.Join(dir, "dot.png") + conn, err := a.mgr.Conn("fixture") + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(testCtx(t), "sh", "-c", + "printf %s '"+b64png+"' | base64 -d > "+png); err != nil { + t.Skipf("could not write the fixture image: %v", err) + } + + got, err := a.PreviewFile("fixture", png) + if err != nil { + t.Fatalf("PreviewFile: %v", err) + } + if got.Kind != "image" || got.MIME != "image/png" { + t.Errorf("kind=%q mime=%q, want an image/png", got.Kind, got.MIME) + } + if got.Truncated { + t.Error("an image small enough to load was reported as truncated") + } + if got.Data != b64png { + t.Errorf("the bytes did not survive the round trip:\n got %s\nwant %s", got.Data, b64png) + } + + // And a file the editor also refuses, but which is not a picture: it comes + // back as a bounded hex-able prefix rather than nothing at all. + blob := path.Join(dir, "blob.bin") + if _, err := conn.Exec(testCtx(t), "sh", "-c", + "head -c 100000 /dev/zero | tr '\\0' 'A' > "+blob+" && printf 'x\\0y' >> "+blob); err != nil { + t.Skipf("could not write the fixture blob: %v", err) + } + bin, err := a.PreviewFile("fixture", blob) + if err != nil { + t.Fatalf("PreviewFile on a blob: %v", err) + } + if bin.Kind != "binary" { + t.Errorf("kind = %q, want binary", bin.Kind) + } + if !bin.Truncated { + t.Error("a 100 KB blob was not reported as truncated") + } + if raw, err := base64.StdEncoding.DecodeString(bin.Data); err != nil { + t.Errorf("Data is not base64: %v", err) + } else if len(raw) != hexPreviewBytes { + t.Errorf("preview carried %d bytes, want %d", len(raw), hexPreviewBytes) + } + // The size on screen must be the file's, not the prefix's. + if bin.Size < 100000 { + t.Errorf("Size = %d, want the whole file's length", bin.Size) + } +} + +func TestLooksBinary(t *testing.T) { + tests := []struct { + name string + head []byte + complete bool + want bool + }{ + {"plain ascii", []byte("worker_processes 1;\n"), true, false}, + {"utf-8 korean", []byte("서버 설정\n"), true, false}, + {"a NUL anywhere", []byte("text\x00more"), true, true}, + {"ELF header", []byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0}, true, true}, + {"latin-1 bytes", []byte{0x48, 0xe9, 0x6c, 0x6c, 0x6f}, true, true}, + // The head boundary can cut a multi-byte character in half. Calling a + // UTF-8 file binary for that is the failure this guards against. + {"rune cut at the boundary", []byte("서버 설정\xed\x95"), false, false}, + // The same bytes with nothing more to come really are broken. + {"truncated rune at EOF", []byte("서버 설정\xed\x95"), true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := looksBinary(tt.head, tt.complete); got != tt.want { + t.Errorf("looksBinary = %v, want %v", got, tt.want) + } + }) + } +} + +// The cost of looking at a blob must not scale with the blob. +// +// The first cut read up to 8 MiB to show 4 KiB of hex, and refused outright +// above that — so a core dump or a model checkpoint could not be identified at +// all, having already been dragged across the wire. Reading the head answers +// the question for a file of any size. +func TestPreviewOfALargeBlobReadsOnlyItsHead(t *testing.T) { + a := connectedApp(t) + dir := scratchDir(t, a, "litedeck-bigblob") + conn, err := a.mgr.Conn("fixture") + if err != nil { + t.Fatal(err) + } + + // 40 MiB — five times the old preview ceiling, so the old code would have + // given up on it. + blob := path.Join(dir, "huge.bin") + if _, err := conn.Exec(testCtx(t), "sh", "-c", + "head -c 41943040 /dev/zero | tr '\\0' 'Z' > "+blob); err != nil { + t.Skipf("could not write the blob: %v", err) + } + + got, err := a.PreviewFile("fixture", blob) + if err != nil { + t.Fatalf("PreviewFile: %v", err) + } + if got.TooLarge { + t.Error("refused a blob whose first 4 KiB is exactly what identifies it") + } + if !got.Truncated { + t.Error("Truncated = false on a 40 MiB file") + } + raw, err := base64.StdEncoding.DecodeString(got.Data) + if err != nil { + t.Fatal(err) + } + if len(raw) != hexPreviewBytes { + t.Errorf("carried %d bytes across, want %d", len(raw), hexPreviewBytes) + } + if got.Size != 41943040 { + t.Errorf("Size = %d, want the real length", got.Size) + } +} + +// And the editor must not drag a binary across before deciding it is one. +func TestReadTextFileGivesUpOnABinaryEarly(t *testing.T) { + a := connectedApp(t) + dir := scratchDir(t, a, "litedeck-earlyout") + conn, err := a.mgr.Conn("fixture") + if err != nil { + t.Fatal(err) + } + + // A NUL in the first bytes, then a megabyte of filler the editor has no + // reason to read. + blob := path.Join(dir, "early.bin") + if _, err := conn.Exec(testCtx(t), "sh", "-c", + "printf 'ELF\\0' > "+blob+" && head -c 1048576 /dev/zero | tr '\\0' 'Q' >> "+blob); err != nil { + t.Skipf("could not write the blob: %v", err) + } + + got, err := a.ReadTextFile("fixture", blob) + if err != nil { + t.Fatalf("ReadTextFile: %v", err) + } + if !got.Binary { + t.Fatal("a file starting with a NUL was not called binary") + } + if got.Content != "" { + t.Errorf("carried %d bytes of a binary across the boundary", len(got.Content)) + } +} diff --git a/internal/app/version.go b/internal/app/version.go index e05495f..703f26f 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.2" +const Version = "1.3.0" diff --git a/internal/config/settings.go b/internal/config/settings.go index 29b8f0b..bf2aca2 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -41,9 +41,17 @@ type MCPSettings struct { // because a token that changes every start breaks the client config the // user pasted in once and expects to keep working. Token string `json:"token,omitempty"` - // Port to bind on loopback. Zero asks the OS, which is fine until the user - // wants a stable line in their client config — then they pin one. - Port int `json:"port,omitempty"` + // Port to bind on loopback, and whether the user chose it. + // + // The two are separate because they answer different questions: Port is + // what the app should try, PortPinned is whether anybody asked for it. + // Until v1.2.3 the port that happened to get bound was written back here on + // every launch, so one busy moment on the default port moved the endpoint + // permanently — the app tried the remembered port next launch, got it, and + // never went home (#2). A Port with no PortPinned is a leftover of that and + // is ignored. + Port int `json:"port,omitempty"` + PortPinned bool `json:"portPinned,omitempty"` // Hosts the AI may read, by host ID. Absent means no: registering a server // in LiteDeck must not hand it to an AI as a side effect. Hosts map[string]bool `json:"hosts,omitempty"` diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 8871b40..de8283f 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -80,7 +80,8 @@ var english = map[string]string{ "%s 를 읽지 못했습니다": "Could not read %s", "이 사용자는 systemd-journal·adm 그룹에 없어 시스템 저널이 비어 보입니다 — 서버에서 그룹에 추가하거나 앱에서 관리자 권한으로 여세요": "This user is in neither systemd-journal nor adm, so the system journal looks empty — add the user to a group on the server, or open it with administrator rights in the app", "ssh-agent 가 가진 키를 차례로 시도하다 서버의 인증 시도 한도를 넘겼습니다. 비밀번호는 시도되지도 않았습니다 — 호스트 편집기에서 인증 방법의 에이전트를 끄거나, 에이전트에 올려둔 키를 줄이세요": "ssh-agent offered its keys one after another and used up the server's limit on authentication attempts, so the password was never tried — turn the agent off for this host in the host editor, or keep fewer keys in the agent", - "이 서버에 Compose 가 없습니다": "This server has no Compose", + "포트는 1024 에서 65535 사이여야 합니다": "The port must be between 1024 and 65535", + "이 서버에 Compose 가 없습니다": "This server has no Compose", "이 서버에서 자기 세션을 식별하지 못했습니다 — 안전을 확인할 수 없어 세션 종료를 막습니다": "Could not identify LiteDeck's own session on this server — ending sessions is blocked because safety cannot be established", "이 터미널은 더 이상 열려 있지 않습니다": "That terminal is no longer open", "이 파일은 연 뒤에 서버에서 바뀌었습니다 — 지금 저장하면 그 변경이 사라집니다": "This file changed on the server after you opened it — saving now discards that change", diff --git a/wails.json b/wails.json index 2be43e0..cb4765e 100644 --- a/wails.json +++ b/wails.json @@ -8,7 +8,7 @@ "frontend:dev:serverUrl": "auto", "info": { "productName": "LiteDeck", - "productVersion": "1.2.2", + "productVersion": "1.3.0", "companyName": "LiteDeck", "copyright": "Copyright © 2026 Junwon Lee. Apache-2.0.", "comments": "SSH 하나로 원격 서버를 내 컴퓨터의 GUI에서 다룹니다." From 480558799f0ee2dbaee5a2f636d24dc491359107 Mon Sep 17 00:00:00 2001 From: Junwon Lee Date: Tue, 18 Aug 2026 23:41:56 +0900 Subject: [PATCH 02/39] v1.3.0 use the latest Go patch in CI --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .github/workflows/release.yml | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fbe1e8..5baaf48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,12 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} + # Without this, setup-go keeps whatever Go the runner image + # already has as long as it satisfies GO_VERSION — which is how + # govulncheck failed on stdlib fixes that had already shipped in + # a newer patch. It also decides which toolchain builds the + # released binaries, so the ones people download carry the fixes. + check-latest: true cache: true - name: go vet @@ -61,6 +67,12 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} + # Without this, setup-go keeps whatever Go the runner image + # already has as long as it satisfies GO_VERSION — which is how + # govulncheck failed on stdlib fixes that had already shipped in + # a newer patch. It also decides which toolchain builds the + # released binaries, so the ones people download carry the fixes. + check-latest: true cache: true - name: govulncheck run: | @@ -97,6 +109,12 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} + # Without this, setup-go keeps whatever Go the runner image + # already has as long as it satisfies GO_VERSION — which is how + # govulncheck failed on stdlib fixes that had already shipped in + # a newer patch. It also decides which toolchain builds the + # released binaries, so the ones people download carry the fixes. + check-latest: true cache: true - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 920c4f0..d7bd51b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,12 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} + # Without this, setup-go keeps whatever Go the runner image + # already has as long as it satisfies GO_VERSION — which is how + # govulncheck failed on stdlib fixes that had already shipped in + # a newer patch. It also decides which toolchain builds the + # released binaries, so the ones people download carry the fixes. + check-latest: true cache: true - uses: actions/setup-node@v4 with: From 4abd76f9946e96e6c3f549070a807acdb0e4e464 Mon Sep 17 00:00:00 2001 From: Junwon Lee Date: Wed, 19 Aug 2026 00:05:20 +0900 Subject: [PATCH 03/39] v1.3.1 --- docs/building.en.md | 2 +- docs/building.md | 2 +- internal/adapter/metrics.go | 10 +++++++- internal/app/gpu_timeout_test.go | 42 ++++++++++++++++++++++++++++++++ internal/app/version.go | 2 +- wails.json | 2 +- 6 files changed, 55 insertions(+), 5 deletions(-) diff --git a/docs/building.en.md b/docs/building.en.md index 12f2eb7..1854899 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.3.0 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.3.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. diff --git a/docs/building.md b/docs/building.md index a49fac4..1a0be91 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.3.0을 만든 환경: Go 1.26.5 · Node 22.13.1 · Wails 2.14.0 · Docker 29.4.0 (macOS 26.5.2 arm64). +v1.3.1을 만든 환경: Go 1.26.5 · Node 22.13.1 · Wails 2.14.0 · Docker 29.4.0 (macOS 26.5.2 arm64). diff --git a/internal/adapter/metrics.go b/internal/adapter/metrics.go index e3d6adb..4883d03 100644 --- a/internal/adapter/metrics.go +++ b/internal/adapter/metrics.go @@ -38,13 +38,21 @@ import ( // // Windows has no cheap equivalent — a PowerShell job per poll is worse than the // problem — so WindowsMetricsScript keeps only its Get-Command guard. +// +// The trailing `:` is what makes the script's exit status its own rather than +// nvidia-smi's. A shell reports the last command's status, so on the ordinary +// server with no card this whole poll exited 127 — every two seconds, as a red +// line in the Command Log with a failure count climbing behind it. The readings +// were fine; the log was lying. That log is the one place this app asks to be +// believed, so a normal condition must not appear there as a failure. 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 '#gpu'; GPUTO=; command -v timeout >/dev/null 2>&1 && GPUTO='timeout 5' -$GPUTO nvidia-smi --query-gpu=index,name,utilization.gpu,fan.speed,temperature.gpu,memory.total,memory.used --format=csv,noheader,nounits 2>/dev/null` +$GPUTO 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. // diff --git a/internal/app/gpu_timeout_test.go b/internal/app/gpu_timeout_test.go index 194c627..a0dc099 100644 --- a/internal/app/gpu_timeout_test.go +++ b/internal/app/gpu_timeout_test.go @@ -60,3 +60,45 @@ func TestMetricsSurviveAHangingNvidiaSmi(t *testing.T) { t.Error("the gpu section marker is missing, so the sections may have shifted") } } + +// A server with no NVIDIA card must not look like a failing command. +// +// The shell reports the last command's status, and the GPU line is last — so on +// the ordinary server this poll exited 127, every two seconds, as a red row in +// the Command Log with a failure count climbing behind it (v1.3.0). The numbers +// on screen were right the whole time; the log was the thing that lied, and the +// log is the one place this app asks to be believed. +func TestMetricsExitZeroWithoutNvidiaSmi(t *testing.T) { + a := connectedApp(t) + conn, err := a.mgr.Conn("fixture") + if err != nil { + t.Fatal(err) + } + + // The fixture has no nvidia-smi, which is the case under test. Exec reports + // a non-zero exit through the result, not through err, so the status is what + // has to be read here. + if probe, err := conn.Exec(testCtx(t), "sh", "-c", "command -v nvidia-smi"); err == nil && probe.OK() { + t.Skip("this fixture has nvidia-smi, so it cannot show the missing case") + } + + res, err := conn.Exec(testCtx(t), "sh", "-c", adapter.MetricsScript) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if !res.OK() { + t.Errorf("exit %d on a host with no GPU — the Command Log shows this as a "+ + "failed command on every poll", res.ExitCode) + } + // And the rest of the snapshot still arrived. + m, err := adapter.ParseMetrics(res.Stdout, adapter.CPUTimes{}) + if err != nil { + t.Fatalf("ParseMetrics: %v", err) + } + if m.MemTotal == 0 { + t.Error("memory is missing") + } + if len(m.GPUs) != 0 { + t.Errorf("got %d GPUs on a host with no nvidia-smi", len(m.GPUs)) + } +} diff --git a/internal/app/version.go b/internal/app/version.go index 703f26f..06bbf6e 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.3.0" +const Version = "1.3.1" diff --git a/wails.json b/wails.json index cb4765e..9e3c0b0 100644 --- a/wails.json +++ b/wails.json @@ -8,7 +8,7 @@ "frontend:dev:serverUrl": "auto", "info": { "productName": "LiteDeck", - "productVersion": "1.3.0", + "productVersion": "1.3.1", "companyName": "LiteDeck", "copyright": "Copyright © 2026 Junwon Lee. Apache-2.0.", "comments": "SSH 하나로 원격 서버를 내 컴퓨터의 GUI에서 다룹니다." From bc23d5d7e639b4b295a8bb1b66c6e9bc5613be60 Mon Sep 17 00:00:00 2001 From: Junwon Lee Date: Wed, 19 Aug 2026 08:40:41 +0900 Subject: [PATCH 04/39] v1.3.2 stream GPU readings, fold background polls in the command log --- docs/building.en.md | 3 +- docs/building.md | 3 +- docs/remote-access.en.md | 9 +- docs/remote-access.md | 35 +++-- docs/support.en.md | 2 +- docs/support.md | 2 +- frontend/src/CommandLogPanel.tsx | 10 ++ frontend/src/app.css | 8 + frontend/src/ipc.ts | 2 + frontend/src/locale-en.ts | 1 + internal/adapter/metrics.go | 149 ++++++++++++------ internal/app/app.go | 2 + internal/app/commandlog.go | 177 +++++++++++++++------ internal/app/commandlog_test.go | 149 ++++++++++++++++++ internal/app/gpu_timeout_test.go | 37 ++++- internal/app/gpustream.go | 261 +++++++++++++++++++++++++++++++ internal/app/gpustream_test.go | 165 +++++++++++++++++++ internal/app/hosts.go | 3 + internal/app/mcp.go | 2 +- internal/app/mcp_test.go | 13 +- internal/app/metrics.go | 32 +++- internal/app/sessions.go | 1 + internal/app/version.go | 2 +- internal/config/settings.go | 2 +- internal/i18n/catalog_en.go | 1 + internal/sshcore/conn.go | 20 ++- internal/sshcore/stream.go | 28 +++- wails.json | 2 +- 28 files changed, 986 insertions(+), 135 deletions(-) create mode 100644 internal/app/commandlog_test.go create mode 100644 internal/app/gpustream.go create mode 100644 internal/app/gpustream_test.go diff --git a/docs/building.en.md b/docs/building.en.md index 1854899..65f0778 100644 --- a/docs/building.en.md +++ b/docs/building.en.md @@ -35,4 +35,5 @@ 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.3.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.3.2 was built with Go 1.26.6, Node 22.13.1, Wails CLI 2.13.0 (library 2.14.0), Docker 29.4.0 on macOS 26.5.2 arm64. +The release workflow pins the same CLI. `@latest` above may hand you a newer one; it still builds. diff --git a/docs/building.md b/docs/building.md index 1a0be91..492df08 100644 --- a/docs/building.md +++ b/docs/building.md @@ -35,4 +35,5 @@ go test ./... -race # 통합 테스트 포함 (Docker 필요) 통합 테스트는 실제 서버를 띄웁니다. `testdata/`에 sshd·systemd·Docker-in-Docker 픽스처가 있습니다. Docker가 없으면 실패가 아니라 건너뛰므로, **`-race`가 몇 초 만에 끝났다면 통합 테스트가 안 돌았다는 뜻입니다** (Docker가 켜져 있으면 1분 남짓 걸립니다). -v1.3.1을 만든 환경: Go 1.26.5 · Node 22.13.1 · Wails 2.14.0 · Docker 29.4.0 (macOS 26.5.2 arm64). +v1.3.2를 만든 환경: Go 1.26.6 · Node 22.13.1 · Wails CLI 2.13.0 (라이브러리 2.14.0) · Docker 29.4.0 (macOS 26.5.2 arm64). +릴리스 워크플로도 같은 CLI 를 씁니다. 위의 `@latest` 로 받으면 더 새 CLI 가 올 수 있는데, 그래도 빌드는 됩니다. diff --git a/docs/remote-access.en.md b/docs/remote-access.en.md index 163f6b8..7680974 100644 --- a/docs/remote-access.en.md +++ b/docs/remote-access.en.md @@ -204,8 +204,9 @@ LiteDeck nor Tailscale solves this for you. **Tailscale ACLs.** With several devices on the tailnet you can restrict which of them may reach the SSH port. The default allows everything. -**This combination has not been verified by the author.** LiteDeck is built to work -against any address SSH can reach, so there is no reason it would not, but by the same -standard used in [what is and is not verified](support.en.md), -**unverified means unverified.** If you try it, please report back in an +**How far this has been verified.** Steps 1–4 have been: a host added at its tailnet +address, connected, with files and metrics read over it. The later sections have not — +**Tailscale SSH, subnet routers, Headscale and hand-rolled WireGuard** are held to the +same standard used in [what is and is not verified](support.en.md), where +**unverified means unverified.** If you try one, please report back in an [issue](https://github.com/cpprhtn/LiteDeck/issues). diff --git a/docs/remote-access.md b/docs/remote-access.md index 0ed1dc5..11ea95a 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -1,9 +1,9 @@ -# 집 PC를 외부에서 다루기 (Tailscale) +# 홈서버를 외부에서 다루기 (Tailscale) > 영문판: [remote-access.en.md](remote-access.en.md) LiteDeck은 SSH가 닿는 곳이면 어디든 붙습니다. 문제는 **SSH가 닿게 만드는 일**입니다. -집 PC나 홈랩은 보통 공유기 뒤에 있어서, 외부에서 접근하려면 포트포워딩을 열거나 DDNS를 +홈서버나 홈랩은 보통 공유기 뒤에 있어서, 외부에서 접근하려면 포트포워딩을 열거나 DDNS를 붙이거나 VPN을 세우게 됩니다. SSH 포트를 인터넷에 그대로 여는 것은 권하지 않습니다. Tailscale을 쓰면 그 셋 다 안 해도 됩니다. 두 도구의 역할이 깔끔하게 나뉩니다. @@ -13,7 +13,7 @@ Tailscale을 쓰면 그 셋 다 안 해도 됩니다. 두 도구의 역할이 | **Tailscale** | 네트워크 설정 (포트포워딩, DDNS, 방화벽 구멍) | | **LiteDeck** | 서버 설치물 (에이전트, 데몬, 패키지) | -합치면 **포트포워딩도 서버 설치물도 없이** 카페에서 집 PC의 파일을 열고, 서비스를 +합치면 **포트포워딩도 서버 설치물도 없이** 카페에서 홈서버의 파일을 열고, 서비스를 재시작하고, 코드를 고쳐 저장할 수 있습니다. --- @@ -32,7 +32,7 @@ WireGuard 암호화로 직접 흐르지만, 어떤 기기가 있는지를 중개 --- -## 1. 집 PC에 SSH 켜기 +## 1. 홈서버에 SSH 켜기 **Linux.** 이미 SSH로 접속하고 계시면 할 일이 없습니다. @@ -48,14 +48,14 @@ Set-Service -Name sshd -StartupType Automatic ## 2. 양쪽에 Tailscale 설치 -[tailscale.com/download](https://tailscale.com/download)에서 받아 집 PC와 노트북 양쪽에 +[tailscale.com/download](https://tailscale.com/download)에서 받아 홈서버와 노트북 양쪽에 설치하고, **같은 계정으로** 로그인합니다. 그러면 두 기기가 같은 tailnet에 들어갑니다. 공유기는 건드리지 않습니다. 포트를 열지도, DDNS를 붙이지도 않습니다. -## 3. 집 PC의 tailnet IP 확인 +## 3. 홈서버의 tailnet IP 확인 -집 PC에서: +홈서버에서: ```bash tailscale ip -4 # 100.x.y.z 형태로 나옵니다 @@ -75,19 +75,19 @@ MagicDNS를 켜두었다면 IP 대신 `mypc` 같은 이름을 그대로 써도 |---|---| | 호스트 | `100.x.y.z` 또는 MagicDNS 이름 | | 포트 | 서버의 sshd 포트. 기본은 `22` | -| 사용자 | 집 PC의 계정 이름 | +| 사용자 | 홈서버의 계정 이름 | | 인증 | ssh-agent를 맨 앞에 두는 것을 권합니다 | -접속을 누르면 처음 한 번 호스트 키 지문을 확인합니다. **이 지문은 집 PC에서 직접 +접속을 누르면 처음 한 번 호스트 키 지문을 확인합니다. **이 지문은 홈서버에서 직접 확인하세요** — Tailscale이 연결 경로를 만들어줄 뿐, 상대가 맞는지 검증하는 것은 여전히 SSH의 일입니다. ```bash -# 집 PC에서 실행해 LiteDeck이 보여주는 지문과 대조합니다 +# 홈서버에서 실행해 LiteDeck이 보여주는 지문과 대조합니다 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub ``` -여기까지가 전부입니다. 이제 노트북 어디서든 집 PC가 사이드바에 뜹니다. +여기까지가 전부입니다. 이제 노트북 어디서든 홈서버가 사이드바에 뜹니다. --- @@ -115,13 +115,13 @@ LiteDeck에게는 이게 **다른 서버에 붙는 것과 같습니다.** 인증 열립니다.** 그래서 구조가 이렇게 됩니다. ``` -Claude Code ──로컬 HTTP──▶ LiteDeck ──Tailscale(WireGuard)──▶ 집 PC +Claude Code ──로컬 HTTP──▶ LiteDeck ──Tailscale(WireGuard)──▶ 홈서버 (노트북) (노트북) (설치물 없음) ``` - **MCP 엔드포인트는 tailnet에 올라가지 않습니다.** 노트북 외부로 나가지 않으므로, tailnet의 다른 기기가 이 엔드포인트를 통해 서버를 조회하거나 바꿀 수 없습니다 -- **서버에는 여전히 아무것도 설치하지 않습니다.** Claude Code를 집 PC에 깔아 tailnet으로 +- **서버에는 여전히 아무것도 설치하지 않습니다.** Claude Code를 홈서버에 깔아 tailnet으로 붙는 방식과 다릅니다. 그쪽은 서버에 런타임과 상주 프로세스가 생깁니다 - 승인창은 노트북에 뜹니다. 자리를 비울 거라면 [MCP 문서](mcp.md)의 통과 모드와 되돌리기를 먼저 읽어보세요 @@ -188,7 +188,7 @@ Tailscale 쪽이 맞습니다. 어느 쪽인지 알려줍니다. LiteDeck은 화면을 스트리밍하지 않으므로 릴레이를 타도 쓸 만하지만, 파일 전송 속도는 눈에 띄게 차이납니다. -**집 PC가 잠들면 접속되지 않습니다.** 절전을 끄는 것이 가장 확실합니다. Wake-on-LAN은 +**홈서버가 잠들면 접속되지 않습니다.** 절전을 끄는 것이 가장 확실합니다. Wake-on-LAN은 매직 패킷을 **같은 LAN 안에서** 쏴야 해서, 밖에서 tailnet을 타고 직접 깨울 수는 없습니다. 그 LAN에 상시 켜져 있는 다른 기기가 있고 거기에도 Tailscale이 깔려 있다면, 그 기기로 들어가서 쏘는 방법은 됩니다. 이건 LiteDeck이나 Tailscale이 해결해 주는 문제가 아닙니다. @@ -196,7 +196,8 @@ Tailscale 쪽이 맞습니다. **Tailscale ACL.** tailnet에 기기가 여럿이면 ACL로 SSH 포트에 닿을 수 있는 기기를 제한할 수 있습니다. 기본값은 전부 허용입니다. -**이 문서의 조합은 저자가 실제로 확인하지 않았습니다.** LiteDeck은 SSH가 닿는 IP면 -동작하도록 만들어져 있어서 안 될 이유가 없지만, [지원 범위](support.md)의 -기준대로 **검증되지 않은 것은 검증되지 않았다고** 적어 둡니다. 해보셨다면 결과를 +**어디까지 해봤는지.** 위 1~4번 — tailnet 주소로 호스트를 등록해 붙고, 파일과 지표를 +읽는 것까지는 실제로 확인했습니다. 나머지 절은 아직입니다: **Tailscale SSH, 서브넷 +라우터, Headscale, WireGuard 직접 구성**은 [지원 범위](support.md)의 기준대로 +**검증되지 않은 것은 검증되지 않았다고** 적어 둡니다. 해보셨다면 결과를 [이슈](https://github.com/cpprhtn/LiteDeck/issues)로 알려주세요. diff --git a/docs/support.en.md b/docs/support.en.md index 8d25ecf..9a3d79d 100644 --- a/docs/support.en.md +++ b/docs/support.en.md @@ -44,7 +44,7 @@ file chooser have not. | **Debian, RHEL/Rocky 8** | Share the systemd 245 table-parsing path, so they should work, but untested | | **Podman** | Docker-compatible CLI, so the parser is shared, but never run | | **Windows containers** | The test machine had no Docker | -| **NVIDIA GPU monitoring** | The parser is checked against real `nvidia-smi` output, and the rest of the metrics are proven to survive a wedged driver. But **the author has no NVIDIA card**, so it has never run against real hardware here. The contributor ([@INMD1](https://github.com/INMD1)) confirmed it works on theirs | +| **More than one NVIDIA card** | Confirmed on real hardware with a single card. The multi-card path is covered by tests for the parser and the sample boundary, but has not run against a machine with several | | **Compose v1 and `podman compose`** | Only the Compose v2 plugin was tried. The standalone `docker-compose` (v1) cannot address a project without its file and is not supported. `podman compose` takes the same code path but was never run | | **ProxyJump against a real bastion** | Verified against two containers (bastion + target). Never tried against an actual bastion. Multi-hop is not implemented and is refused | | **macOS and BSD servers** | No adapter. Connecting works and **files and terminal do too**; the other tabs explain themselves | diff --git a/docs/support.md b/docs/support.md index 3a56e88..a0b0816 100644 --- a/docs/support.md +++ b/docs/support.md @@ -44,7 +44,7 @@ Linux 클라이언트는 **창이 열리고 접속·조회까지** 확인했습 | **Debian, RHEL·Rocky 8** | systemd 245 표 파싱 경로를 공유하므로 동작이 예상되나 확인 안 됨 | | **Podman** | `docker` 호환 CLI라 파서를 공유하지만 실행 안 해봄 | | **Windows 컨테이너** | 테스트한 서버에 Docker가 없었음 | -| **NVIDIA GPU 모니터링** | 파서는 실제 `nvidia-smi` 출력으로 검증했고, 드라이버가 멈췄을 때 나머지 지표가 살아남는 것도 확인했습니다. 다만 **저자 환경에 NVIDIA 카드가 없어** 카드가 달린 실기에서는 확인하지 못했습니다. 기여자([@INMD1](https://github.com/INMD1)) 환경에서 동작하는 것을 확인했습니다 | +| **NVIDIA 카드 여러 장** | 한 장 꽂힌 실기에서 확인했습니다. 여러 장을 읽는 경로는 파서와 샘플 경계까지만 테스트로 확인했고 실기에서는 안 해봄 | | **Compose v1·podman compose** | Compose v2 플러그인만 확인했습니다. 독립 실행형 `docker-compose`(v1)는 파일 없이 프로젝트를 다루지 못해 지원하지 않습니다. `podman compose`는 같은 경로를 타지만 실행 안 해봄 | | **ProxyJump 실서버** | 컨테이너 두 대(경유 + 목적지)로 확인. 실제 bastion으로는 안 해봄. 다단은 미구현이고 거부합니다 | | **macOS·BSD 서버** | 어댑터 없음. 연결하면 **파일과 터미널만** 동작하고, 나머지 탭은 이유를 화면에 적습니다 | diff --git a/frontend/src/CommandLogPanel.tsx b/frontend/src/CommandLogPanel.tsx index d5f46e1..73e2162 100644 --- a/frontend/src/CommandLogPanel.tsx +++ b/frontend/src/CommandLogPanel.tsx @@ -148,6 +148,16 @@ export function CommandLogPanel({ ))} + {/* A background read runs the same line forever, so the row + carries a count instead of a hundred copies of itself. It + doubles as the answer to "why does this keep happening" — + a feed that should have opened once and shows ×40 is a + feed that is restarting. */} + {e.repeat && e.repeat > 1 ? ( + + ×{e.repeat} + + ) : null} {e.status === 'running' && t('실행 중')} {e.status === 'ok' && `${e.durationMs}ms`} {e.status === 'probe' && t('없음 (exit {code})', { code: e.exitCode })} diff --git a/frontend/src/app.css b/frontend/src/app.css index 625aa14..6ff8f2a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1938,6 +1938,14 @@ button.danger { opacity: 0.7; } +/* 배경 조회의 반복 횟수. 같은 줄이 수백 개 쌓이는 대신 한 줄이 세어 준다. + 숫자 자체가 정보라서 소요 시간보다 앞에 오고 조금 더 진하다. */ +.cmdlog-repeat { + margin-right: 6px; + font-variant-numeric: tabular-nums; + opacity: 0.85; +} + /* 호스트별 AI 변경 권한. 켜져 있는 동안 헤더에서 보여야 한다 — 아무도 못 보는 스위치는 아무도 끄지 않는다. */ .mcp-badge-wrap { diff --git a/frontend/src/ipc.ts b/frontend/src/ipc.ts index fc06ea4..33027f7 100644 --- a/frontend/src/ipc.ts +++ b/frontend/src/ipc.ts @@ -456,6 +456,8 @@ export interface CommandEntry { origin?: string /** Time spent waiting for a session slot, when long enough to matter. */ queuedMs?: number + /** How many times this background read has run. Absent for user actions. */ + repeat?: number } export interface ConnectionState { diff --git a/frontend/src/locale-en.ts b/frontend/src/locale-en.ts index 7aa9050..fd9e784 100644 --- a/frontend/src/locale-en.ts +++ b/frontend/src/locale-en.ts @@ -255,6 +255,7 @@ export const en: Record = { '세션': 'Sessions', '세션 {n}개': '{n} {n#session|sessions}', '세션 슬롯을 기다린 시간': 'Time spent waiting for a session slot', + '이 조회가 실행된 횟수': 'How many times this read has run', '세션 종료': 'session ended', '세션을 읽는 중…': 'Reading sessions…', '세션을 종료하지 못했습니다': 'Could not end the session', diff --git a/internal/adapter/metrics.go b/internal/adapter/metrics.go index 4883d03..4fafa5f 100644 --- a/internal/adapter/metrics.go +++ b/internal/adapter/metrics.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "time" ) // MetricsScript collects everything the summary bar needs in one round trip. @@ -23,12 +24,33 @@ import ( // 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. +// Every line reads a file the kernel already keeps, so the whole snapshot costs +// one round trip and no process on the server worth measuring. The GPU is the +// exception and does not live here — see GPUStreamScript. // -// It is also the only line that can hang. A wedged driver — an Xid fault, a card -// that has fallen off the bus — leaves nvidia-smi blocked in the kernel, and +// The trailing `:` makes the script's exit status its own rather than the last +// command's. `cat /proc/loadavg` fails on a kernel without it, `df` fails on a +// container with a broken mount, and a shell reports whatever ran last — so a +// normal condition would show up in the Command Log as a red row with a failure +// count climbing behind it. That log is the one place this app asks to be +// believed, so a normal condition must not appear there as a failure. +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 +:` + +// MetricsScriptWithGPU is MetricsScript with the card read inline, one +// nvidia-smi per poll. +// +// This was the only way the GPU was read until the feed arrived, and it is +// still the fallback: GPUStreamScript needs a channel and a server that lets +// nvidia-smi write a line at a time, and when either is missing the summary bar +// falls back here rather than dropping the tiles. Slower, never wrong. +// +// nvidia-smi is the one line that can hang. A wedged driver — an Xid fault, a +// card that has fallen off the bus — leaves it blocked in the kernel, and // because this is one script the whole poll blocks with it: CPU, memory and disk // would go dark for the twenty seconds of pollTimeout, at exactly the moment // somebody needs them. `timeout` caps that at five seconds and costs an empty @@ -38,22 +60,50 @@ import ( // // Windows has no cheap equivalent — a PowerShell job per poll is worse than the // problem — so WindowsMetricsScript keeps only its Get-Command guard. -// -// The trailing `:` is what makes the script's exit status its own rather than -// nvidia-smi's. A shell reports the last command's status, so on the ordinary -// server with no card this whole poll exited 127 — every two seconds, as a red -// line in the Command Log with a failure count climbing behind it. The readings -// were fine; the log was lying. That log is the one place this app asks to be -// believed, so a normal condition must not appear there as a failure. -const MetricsScript = `echo '#stat'; cat /proc/stat 2>/dev/null | head -1 +const MetricsScriptWithGPU = `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 '#gpu'; GPUTO=; command -v timeout >/dev/null 2>&1 && GPUTO='timeout 5' -$GPUTO nvidia-smi --query-gpu=index,name,utilization.gpu,fan.speed,temperature.gpu,memory.total,memory.used --format=csv,noheader,nounits 2>/dev/null +$GPUTO nvidia-smi --query-gpu=` + gpuQueryFields + ` --format=csv,noheader,nounits 2>/dev/null :` +// gpuQueryFields is the one list both readings ask for, so the inline poll and +// the feed can never drift into parsing different columns. +const gpuQueryFields = "index,name,utilization.gpu,fan.speed,temperature.gpu,memory.total,memory.used" + +// GPUStreamScript keeps one nvidia-smi alive and lets it report on a loop. +// +// nvidia-smi initialises NVML on startup and tears it down on exit, and that +// setup — not the reading — is nearly all of what an invocation costs. Called +// once per poll it was paying that price every two seconds, forever, on a +// machine whose whole job is to be busy with something else. `-l` keeps one +// process and one NVML session, so each further sample is a library call. +// +// `dmon` is the tool NVIDIA documents for this and it is the wrong one here: it +// reports a fixed column set with no fan speed, no card name and no VRAM in +// bytes. Streaming the same --query-gpu list keeps every figure the tiles +// already show. +// +// The `command -v` guard is what keeps a card-less server quiet. Without it the +// stream would end on nvidia-smi's exit 127 and the app would have to tell a +// missing program apart from a broken one; with it, no card is a clean exit 0 +// and an empty feed, which is exactly what it means. +// +// stdbuf is a hedge, not a requirement. A program writing to a pipe usually +// switches to block buffering, which would hold samples back until 4KB of them +// had piled up; `-oL` asks for line buffering instead. Where coreutils stdbuf +// is missing the command still runs, and a feed that goes quiet is caught by +// the staleness check on the other end rather than by trusting this line. +const GPUStreamScript = `command -v nvidia-smi >/dev/null 2>&1 || exit 0 +SB=; command -v stdbuf >/dev/null 2>&1 && SB='stdbuf -oL' +exec $SB nvidia-smi --query-gpu=` + gpuQueryFields + ` --format=csv,noheader,nounits -l 2` + +// GPUStreamInterval is the `-l` period above. The reader needs it to tell a +// feed that is merely between samples from one that has stopped answering. +const GPUStreamInterval = 2 * time.Second + // CPUTimes is one sample of the aggregate CPU counters. // // The counters are monotonic totals since boot, so a single reading says @@ -325,41 +375,52 @@ func parseDF(lines []string) []Filesystem { func parseGPUs(lines []string) []GPU { out := []GPU{} for i, line := range lines { - f := strings.Split(line, ",") - if len(f) < 7 { - continue + if g, ok := ParseGPULine(line, i); ok { + out = append(out, g) } - 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 } +// ParseGPULine reads one CSV row. fallbackIndex names the card when the index +// column is unreadable — the row still describes a real card. +// +// Exported because the feed sees rows one at a time as they arrive and has no +// section to hand over whole. +func ParseGPULine(line string, fallbackIndex int) (GPU, bool) { + f := strings.Split(line, ",") + if len(f) < 7 { + return GPU{}, false + } + 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 = fallbackIndex + } + 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) + } + return g, true +} + // 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. diff --git a/internal/app/app.go b/internal/app/app.go index 01d9df3..42f5ae7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -37,6 +37,7 @@ type App struct { transfers *transferQueue terminals *terminalRegistry cpu *cpuHistory + gpus *gpuWatcher logs *logRegistry mcp mcpState approvals *approvalBridge @@ -69,6 +70,7 @@ func New() *App { a.transfers = newTransferQueue(a) a.terminals = newTerminalRegistry(a) a.cpu = newCPUHistory() + a.gpus = newGPUWatcher() a.logs = newLogRegistry(a) a.approvals = newApprovalBridge(a) a.secrets = secret.Ephemeral{} diff --git a/internal/app/commandlog.go b/internal/app/commandlog.go index 64bbd8d..678feb0 100644 --- a/internal/app/commandlog.go +++ b/internal/app/commandlog.go @@ -27,7 +27,11 @@ type CommandEntry struct { ExitCode int `json:"exitCode"` Duration int64 `json:"durationMs"` // Kind is "", "poll" or "probe" — see sshcore.CommandKind. - Kind string `json:"kind,omitempty"` + Kind string `json:"kind,omitempty"` + // Repeat counts how many times this background read has run. A view that + // refreshes every two seconds produces the same line forever, and a + // hundred identical rows say nothing a count does not say better. + Repeat int `json:"repeat,omitempty"` Stderr string `json:"stderr,omitempty"` // QueuedMs is time spent waiting for one of the Exec channels. Shown only // when it is long enough to matter, because a command that sat in a queue @@ -54,14 +58,33 @@ type commandLog struct { mu sync.Mutex seq int entries []CommandEntry - running map[string]int // line+host → index, to match a finish to its start + running map[string]int // line+host → seq, to match a finish to its start + // folded is host+kind+outcome+line → seq of the row that absorbs repeats + // of one background read. The outcome is part of the key so that a poll + // which starts failing gets a row of its own rather than changing the + // colour of the row that had been counting its successes. + folded map[string]int } func newCommandLog(a *App) *commandLog { - return &commandLog{app: a, running: make(map[string]int)} + return &commandLog{ + app: a, + running: make(map[string]int), + folded: make(map[string]int), + } } func (l *commandLog) CommandStarted(info sshcore.CommandInfo) { + // A background read gets no row while it runs. It finishes in under a + // second, nobody watches it happen, and one row per run is what filled this + // panel with hundreds of identical lines — the panel holds 500 entries, and + // at two pollers on a two second cadence the one restart the user actually + // performed was evicted within about eight minutes. They fold into a single + // counted row on completion instead. + if info.Background() { + return + } + l.mu.Lock() l.seq++ e := CommandEntry{ @@ -73,9 +96,7 @@ func (l *commandLog) CommandStarted(info sshcore.CommandInfo) { Kind: string(info.Kind), } l.entries = append(l.entries, e) - if len(l.entries) > commandLogLimit { - l.entries = l.entries[len(l.entries)-commandLogLimit:] - } + l.trim() l.running[info.HostID+"\x00"+info.Line] = e.Seq l.mu.Unlock() @@ -83,43 +104,27 @@ func (l *commandLog) CommandStarted(info sshcore.CommandInfo) { } func (l *commandLog) CommandFinished(info sshcore.CommandInfo, res *sshcore.Result, err error) { - l.mu.Lock() - key := info.HostID + "\x00" + info.Line - seq, ok := l.running[key] - delete(l.running, key) + status, exit, stderr := classify(info, res, err) + l.mu.Lock() var updated CommandEntry - for i := range l.entries { - if !ok || l.entries[i].Seq != seq { - continue - } - e := &l.entries[i] - switch { - case err != nil: - e.Status = "error" - e.Stderr = err.Error() - case res != nil && !res.OK() && info.Probe(): - // A probe answering "no" is information, not a fault. Counting it - // as a failure would put two red rows in the log on every single - // connection, and a failure count that is always wrong is one the - // user stops reading. - e.Status = "probe" - e.ExitCode = res.ExitCode - case res != nil && !res.OK(): - e.Status = "failed" - e.ExitCode = res.ExitCode - e.Stderr = truncate(string(res.Stderr), 4000) - default: - e.Status = "ok" - } - if res != nil { - e.Duration = res.Duration.Milliseconds() - if res.Queued > queueNoteFloor { - e.QueuedMs = res.Queued.Milliseconds() + if info.Background() { + updated = l.fold(info, status, exit, stderr, res) + } else { + key := info.HostID + "\x00" + info.Line + seq, ok := l.running[key] + delete(l.running, key) + if i := l.indexOf(seq); ok && i >= 0 { + e := &l.entries[i] + e.Status, e.ExitCode, e.Stderr = status, exit, stderr + if res != nil { + e.Duration = res.Duration.Milliseconds() + if res.Queued > queueNoteFloor { + e.QueuedMs = res.Queued.Milliseconds() + } } + updated = *e } - updated = *e - break } l.mu.Unlock() @@ -128,6 +133,89 @@ func (l *commandLog) CommandFinished(info sshcore.CommandInfo, res *sshcore.Resu } } +// classify turns an outcome into the row's status. +func classify(info sshcore.CommandInfo, res *sshcore.Result, err error) (status string, exit int, stderr string) { + switch { + case err != nil: + return "error", 0, err.Error() + case res != nil && !res.OK() && info.Probe(): + // A probe answering "no" is information, not a fault. Counting it as a + // failure would put two red rows in the log on every single connection, + // and a failure count that is always wrong is one the user stops + // reading. + return "probe", res.ExitCode, "" + case res != nil && !res.OK(): + return "failed", res.ExitCode, truncate(string(res.Stderr), 4000) + } + return "ok", 0, "" +} + +// fold merges one background read into the single row that counts it. +// +// The outcome is part of the key, so a poll that starts failing gets a row of +// its own instead of recolouring the row that had been counting its successes. +// A failure hidden behind a repeat count is a failure nobody sees, and the +// count would stop meaning what it says. +func (l *commandLog) fold( + info sshcore.CommandInfo, status string, exit int, stderr string, res *sshcore.Result, +) CommandEntry { + outcome := "ok" + if status == "failed" || status == "error" { + outcome = "bad" + } + key := info.HostID + "\x00" + string(info.Kind) + "\x00" + outcome + "\x00" + info.Line + + i := -1 + if seq, ok := l.folded[key]; ok { + if i = l.indexOf(seq); i < 0 { + // Trimmed out from under us. Start counting again rather than + // leave the map pointing at a row that no longer exists. + delete(l.folded, key) + } + } + if i < 0 { + l.seq++ + l.entries = append(l.entries, CommandEntry{ + Seq: l.seq, + HostID: info.HostID, + Line: info.Line, + Kind: string(info.Kind), + }) + l.trim() + i = len(l.entries) - 1 + l.folded[key] = l.entries[i].Seq + } + + e := &l.entries[i] + e.At = time.Now().Format(time.RFC3339) + e.Status, e.ExitCode, e.Stderr = status, exit, stderr + e.Repeat++ + e.QueuedMs = 0 + if res != nil { + e.Duration = res.Duration.Milliseconds() + if res.Queued > queueNoteFloor { + e.QueuedMs = res.Queued.Milliseconds() + } + } + return *e +} + +// indexOf finds an entry by sequence number, or -1 once it has been trimmed. +func (l *commandLog) indexOf(seq int) int { + for i := range l.entries { + if l.entries[i].Seq == seq { + return i + } + } + return -1 +} + +func (l *commandLog) trim() { + if len(l.entries) > commandLogLimit { + l.entries = l.entries[len(l.entries)-commandLogLimit:] + } +} + func (l *commandLog) emit(event string, e CommandEntry) { l.app.emit(event, e) } @@ -145,6 +233,9 @@ func (a *App) CommandLog() []CommandEntry { func (a *App) ClearCommandLog() { a.log.mu.Lock() a.log.entries = nil + // The fold map points at rows that no longer exist; leaving it would make + // the next background read update nothing. + a.log.folded = make(map[string]int) a.log.mu.Unlock() } @@ -173,9 +264,7 @@ func (l *commandLog) AICall(hostID, line string) { Origin: "ai", } l.entries = append(l.entries, e) - if len(l.entries) > commandLogLimit { - l.entries = l.entries[len(l.entries)-commandLogLimit:] - } + l.trim() l.mu.Unlock() l.emit("cmd:started", e) @@ -203,9 +292,7 @@ func (l *commandLog) AIWrite(hostID, summary, outcome string) { Origin: "ai", } l.entries = append(l.entries, e) - if len(l.entries) > commandLogLimit { - l.entries = l.entries[len(l.entries)-commandLogLimit:] - } + l.trim() l.mu.Unlock() l.emit("cmd:started", e) diff --git a/internal/app/commandlog_test.go b/internal/app/commandlog_test.go new file mode 100644 index 0000000..76c4901 --- /dev/null +++ b/internal/app/commandlog_test.go @@ -0,0 +1,149 @@ +package app + +import ( + "fmt" + "testing" + + "github.com/cpprhtn/LiteDeck/internal/sshcore" +) + +func poll(line string) sshcore.CommandInfo { + return sshcore.CommandInfo{HostID: "h", Line: line, Kind: sshcore.CommandPoll} +} + +func action(line string) sshcore.CommandInfo { + return sshcore.CommandInfo{HostID: "h", Line: line} +} + +func run(l *commandLog, info sshcore.CommandInfo, res *sshcore.Result) { + l.CommandStarted(info) + l.CommandFinished(info, res, nil) +} + +const metricsPoll = "sh -c 'echo #stat; cat /proc/stat'" + +// A view that refreshes every two seconds must not produce a row every two +// seconds. +func TestBackgroundPollsFoldIntoOneRow(t *testing.T) { + l := newCommandLog(New()) + for range 300 { + run(l, poll(metricsPoll), &sshcore.Result{}) + } + if got := len(l.entries); got != 1 { + t.Fatalf("%d rows for one repeated poll, want 1", got) + } + if got := l.entries[0].Repeat; got != 300 { + t.Errorf("count %d, want 300 — the row is not saying how often it ran", got) + } + if l.entries[0].Status != "ok" { + t.Errorf("status %q", l.entries[0].Status) + } +} + +// The bug this was written for: the log holds 500 entries, and at two pollers +// on a two second cadence the restart the user actually performed was gone +// within about eight minutes. The panel exists to answer "what did the GUI do +// on my behalf" and it was answering "it polled". +func TestBackgroundPollsDoNotEvictUserCommands(t *testing.T) { + l := newCommandLog(New()) + run(l, action("docker compose --project-name web restart"), &sshcore.Result{}) + + // Eight minutes of two pollers, several times over. + for range 5000 { + run(l, poll(metricsPoll), &sshcore.Result{}) + run(l, poll("docker ps --format '{{json .}}'"), &sshcore.Result{}) + } + + var found bool + for _, e := range l.entries { + if e.Line == "docker compose --project-name web restart" { + found = true + } + } + if !found { + t.Fatalf("the user's own command was evicted by polling; %d rows remain", + len(l.entries)) + } + // And the whole log stayed small, which is the point. + if len(l.entries) != 3 { + t.Errorf("%d rows, want 3 (one action + two folded polls)", len(l.entries)) + } +} + +// A poll that starts failing has to be visible, not absorbed into the row that +// had been counting its successes. +// +// This is the v1.3.0 shape: nvidia-smi exiting 127 on every poll. Folding it in +// silently would recolour one row and leave the count meaning nothing. +func TestFailingBackgroundPollGetsItsOwnRow(t *testing.T) { + l := newCommandLog(New()) + for range 10 { + run(l, poll(metricsPoll), &sshcore.Result{}) + } + for range 7 { + run(l, poll(metricsPoll), &sshcore.Result{ExitCode: 127}) + } + + if got := len(l.entries); got != 2 { + t.Fatalf("%d rows, want 2 — success and failure must not share one", got) + } + ok, bad := l.entries[0], l.entries[1] + if ok.Status != "ok" || ok.Repeat != 10 { + t.Errorf("success row: status %q count %d, want ok/10", ok.Status, ok.Repeat) + } + if bad.Status != "failed" || bad.Repeat != 7 { + t.Errorf("failure row: status %q count %d, want failed/7", bad.Status, bad.Repeat) + } + if bad.ExitCode != 127 { + t.Errorf("exit %d, want 127", bad.ExitCode) + } +} + +// Folding is for background reads only. What the user did stays one row per +// doing, in order, even when they do the same thing twice. +func TestUserCommandsAreNeverFolded(t *testing.T) { + l := newCommandLog(New()) + for range 3 { + run(l, action("systemctl restart nginx"), &sshcore.Result{}) + } + if got := len(l.entries); got != 3 { + t.Fatalf("%d rows for three restarts, want 3 — the user did it three times", + got) + } + for _, e := range l.entries { + if e.Repeat != 0 { + t.Errorf("a user command carries a repeat count of %d", e.Repeat) + } + } +} + +// Clearing must not leave the fold map pointing at rows that are gone, or the +// next poll updates nothing and the panel stops moving. +func TestClearResetsFolding(t *testing.T) { + a := New() + l := newCommandLog(a) + a.log = l + run(l, poll(metricsPoll), &sshcore.Result{}) + a.ClearCommandLog() + run(l, poll(metricsPoll), &sshcore.Result{}) + + if got := len(l.entries); got != 1 { + t.Fatalf("%d rows after clearing and polling once, want 1", got) + } + if got := l.entries[0].Repeat; got != 1 { + t.Errorf("count %d after a clear, want 1 — the count survived the clear", got) + } +} + +// Distinct commands keep distinct rows; folding is per command, not per kind. +func TestFoldingIsPerCommand(t *testing.T) { + l := newCommandLog(New()) + for i := range 4 { + for range 50 { + run(l, poll(fmt.Sprintf("read-%d", i)), &sshcore.Result{}) + } + } + if got := len(l.entries); got != 4 { + t.Fatalf("%d rows for four distinct polls, want 4", got) + } +} diff --git a/internal/app/gpu_timeout_test.go b/internal/app/gpu_timeout_test.go index a0dc099..34fdcf4 100644 --- a/internal/app/gpu_timeout_test.go +++ b/internal/app/gpu_timeout_test.go @@ -10,6 +10,9 @@ import ( // A wedged nvidia-smi must not take the rest of the metrics with it. // +// This covers the inline reading, which is what runs whenever the GPU feed is +// not answering — so it is the path a card can still hang on. +// // The GPU line rides on the same script as CPU, memory and disk, so a driver // fault that leaves nvidia-smi blocked in the kernel would black out the whole // summary bar until pollTimeout — twenty seconds, at the moment those numbers @@ -34,7 +37,7 @@ func TestMetricsSurviveAHangingNvidiaSmi(t *testing.T) { start := time.Now() res, err := conn.Exec(testCtx(t), "sh", "-c", - "PATH=/tmp/fakebin:$PATH; "+adapter.MetricsScript) + "PATH=/tmp/fakebin:$PATH; "+adapter.MetricsScriptWithGPU) if err != nil { t.Fatalf("Exec: %v", err) } @@ -82,7 +85,7 @@ func TestMetricsExitZeroWithoutNvidiaSmi(t *testing.T) { t.Skip("this fixture has nvidia-smi, so it cannot show the missing case") } - res, err := conn.Exec(testCtx(t), "sh", "-c", adapter.MetricsScript) + res, err := conn.Exec(testCtx(t), "sh", "-c", adapter.MetricsScriptWithGPU) if err != nil { t.Fatalf("Exec: %v", err) } @@ -102,3 +105,33 @@ func TestMetricsExitZeroWithoutNvidiaSmi(t *testing.T) { t.Errorf("got %d GPUs on a host with no nvidia-smi", len(m.GPUs)) } } + +// The feed's own script has to end quietly on a server with no card. +// +// It runs as a stream, and a stream that dies on nvidia-smi's exit 127 would +// put the v1.3.0 Command Log flood back — a red row per host per reconnect — +// and leave the app unable to tell "no such program" from "the driver broke". +// The `command -v` guard turns both of those into an exit 0 with no output, +// which the feed reads as the plain fact that this machine has no card. +func TestGPUStreamScriptIsSilentWithoutNvidiaSmi(t *testing.T) { + a := connectedApp(t) + conn, err := a.mgr.Conn("fixture") + if err != nil { + t.Fatal(err) + } + if probe, err := conn.Exec(testCtx(t), "sh", "-c", "command -v nvidia-smi"); err == nil && probe.OK() { + t.Skip("this fixture has nvidia-smi, so it cannot show the missing case") + } + + res, err := conn.Exec(testCtx(t), "sh", "-c", adapter.GPUStreamScript) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if !res.OK() { + t.Errorf("exit %d with no nvidia-smi — every connection to an ordinary "+ + "server would log a failed command", res.ExitCode) + } + if len(res.Stdout) != 0 { + t.Errorf("stdout %q, want nothing", res.Stdout) + } +} diff --git a/internal/app/gpustream.go b/internal/app/gpustream.go new file mode 100644 index 0000000..aa68e84 --- /dev/null +++ b/internal/app/gpustream.go @@ -0,0 +1,261 @@ +package app + +import ( + "context" + "sync" + "time" + + "github.com/cpprhtn/LiteDeck/internal/adapter" + "github.com/cpprhtn/LiteDeck/internal/sshcore" +) + +// The GPU feed (§4.7). +// +// The summary bar reads CPU, memory and disk out of files the kernel already +// keeps, so a poll costs nothing worth measuring. The GPU was the exception: it +// needs nvidia-smi, and nvidia-smi initialises NVML on startup and tears it +// down on exit. Called once per poll, on a two second cadence, it paid that +// setup cost forever on a machine whose entire job is to be busy with something +// else. +// +// So the card is read by one process that stays alive and reports on a loop. +// One NVML session, one channel, and each further sample is a library call. +// +// Everything here is written so that failure means *slower*, never *wrong*. A +// server that will not hold the channel, a nvidia-smi that buffers its output, +// a driver that dies and comes back — each of those drops the reading back to +// the inline query the app used before, which is unchanged and still correct. +// The tiles do not go blank; they go back to costing what they used to cost. + +// gpuFeedGrace is how long a feed gets to produce its first row before it +// counts as not answering. Generous on purpose: it covers the `command -v` +// guard, nvidia-smi's own startup, and a slow link, and the only cost of +// waiting is that the inline query keeps running meanwhile. +const gpuFeedGrace = 10 * time.Second + +// gpuFeedRetry is the wait before reopening a feed that had been working and +// then ended — a driver reload, a card reset. Long enough that a card stuck in +// a crash loop cannot turn this into a reconnect storm. +const gpuFeedRetry = 30 * time.Second + +// gpuWatcher owns one feed per connected host. +type gpuWatcher struct { + mu sync.Mutex + byID map[string]*gpuFeed +} + +func newGPUWatcher() *gpuWatcher { + return &gpuWatcher{byID: make(map[string]*gpuFeed)} +} + +type gpuFeed struct { + cancel context.CancelFunc + + mu sync.Mutex + stream *sshcore.Stream + starting bool // an ensure() is opening the channel right now + + // latest is the last complete sample; batch is the one still arriving. + // Rows come in one at a time and nvidia-smi does not separate samples, so + // the boundary is the index column going back down. + latest []adapter.GPU + batch []adapter.GPU + settled bool // a boundary has been seen, so latest is a whole sample + + started time.Time + lastRow time.Time + rows int + + // The three ways a feed stops being the thing that answers. + // + // noCard is the ordinary server: the stream ran, said nothing, exited. Not + // an error and not worth retrying, because a card does not appear inside a + // connection. + // + // stalled is nvidia-smi block-buffering into the pipe. The process is alive + // and will go on being alive; it is the samples that are stuck. Nothing + // about that resolves on a retry, so the host stays on the inline query. + // + // retryAt is everything else — it worked, then it stopped. + noCard bool + stalled bool + retryAt time.Time +} + +// sample reports the current GPU rows and whether the feed is answering. +// +// A false second return is the caller's instruction to read the card inline +// this time round. It is deliberately also the answer before the first feed +// exists, so the very first poll after connecting shows the tiles immediately +// rather than a blank space that fills in two seconds later. +func (w *gpuWatcher) sample(hostID string) ([]adapter.GPU, bool) { + w.mu.Lock() + f := w.byID[hostID] + w.mu.Unlock() + if f == nil { + return nil, false + } + + f.mu.Lock() + defer f.mu.Unlock() + + // A host with no card is a settled answer, not a missing one: the feed + // already asked and nothing was there. Reporting it as live is what keeps + // the ordinary server from running an nvidia-smi it does not have. + if f.noCard { + return nil, true + } + if f.stalled || !f.retryAt.IsZero() { + return nil, false + } + + since := f.lastRow + grace := adapter.GPUStreamInterval * 3 + if f.rows == 0 { + since, grace = f.started, gpuFeedGrace + } + if time.Since(since) > grace { + // Alive but not talking. Close it — a buffered nvidia-smi would hold + // the channel open for as long as the app runs otherwise — and leave + // this host on the inline query until it reconnects. + f.stalled = true + if f.stream != nil { + _ = f.stream.Close() + } + if f.cancel != nil { + f.cancel() + } + return nil, false + } + if f.latest == nil { + return nil, false + } + return append([]adapter.GPU(nil), f.latest...), true +} + +// ensure starts a feed for the host if one is wanted and none is running. +// +// Called after each poll rather than on connect, because it is the poll that +// proves somebody is looking at this host. A host nobody has opened costs +// nothing. +func (w *gpuWatcher) ensure(hostID string, conn *sshcore.Conn) { + w.mu.Lock() + f := w.byID[hostID] + switch { + case f == nil: + f = &gpuFeed{started: time.Now(), starting: true} + w.byID[hostID] = f + default: + f.mu.Lock() + skip := f.stream != nil || f.starting || f.noCard || f.stalled || + (!f.retryAt.IsZero() && time.Now().Before(f.retryAt)) + if !skip { + f.starting = true + f.retryAt = time.Time{} + f.started = time.Now() + f.rows = 0 + } + f.mu.Unlock() + if skip { + w.mu.Unlock() + return + } + } + w.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + stream, err := conn.OpenStreamOpts(ctx, + sshcore.StreamOptions{Background: true}, + func(line string, isStderr bool) { + if !isStderr { + f.row(line) + } + }, + func(error) { f.closed() }, + // A compile-time constant with nothing interpolated, which is why `sh -c` + // here does not violate the argv-only rule (§3.2b). + "sh", "-c", adapter.GPUStreamScript, + ) + if err != nil { + // No channel to spare, or the server refused. Neither is worth an error + // on screen — the inline query covers it — so this is only a note to try + // again later. + cancel() + f.mu.Lock() + f.starting = false + f.retryAt = time.Now().Add(gpuFeedRetry) + f.mu.Unlock() + return + } + + f.mu.Lock() + f.cancel, f.stream, f.starting = cancel, stream, false + f.mu.Unlock() +} + +// row folds one CSV line into the sample being assembled. +func (f *gpuFeed) row(line string) { + g, ok := adapter.ParseGPULine(line, len(f.batch)) + if !ok { + return + } + f.mu.Lock() + defer f.mu.Unlock() + + f.rows++ + f.lastRow = time.Now() + + // nvidia-smi prints one row per card per interval with nothing between + // samples, so a card index that did not go up is where the next sample + // began. + if n := len(f.batch); n > 0 && g.Index <= f.batch[n-1].Index { + f.latest, f.batch, f.settled = f.batch, nil, true + } + f.batch = append(f.batch, g) + + // Until the first boundary the app has never seen a whole sample, and + // waiting for one would leave the tiles empty for an interval. Show the + // rows as they arrive; the first boundary takes over from there. + if !f.settled { + f.latest = append([]adapter.GPU(nil), f.batch...) + } +} + +// closed records why the stream ended. +func (f *gpuFeed) closed() { + f.mu.Lock() + defer f.mu.Unlock() + f.stream = nil + if f.rows == 0 { + // It ran and named no card. Whether that was a missing nvidia-smi, a + // driver with no card under it, or a card that could not be read, the + // useful conclusion is the same and retrying it every thirty seconds + // for the life of the connection would only be noise. + f.noCard = true + return + } + f.retryAt = time.Now().Add(gpuFeedRetry) +} + +// forget closes the feed for a host. Called when the connection goes away, so +// that the next one starts by asking again rather than trusting an answer from +// a server that may have had a card added since. +func (w *gpuWatcher) forget(hostID string) { + w.mu.Lock() + f := w.byID[hostID] + delete(w.byID, hostID) + w.mu.Unlock() + if f == nil { + return + } + f.mu.Lock() + stream, cancel := f.stream, f.cancel + f.stream = nil + f.mu.Unlock() + if stream != nil { + _ = stream.Close() + } + if cancel != nil { + cancel() + } +} diff --git a/internal/app/gpustream_test.go b/internal/app/gpustream_test.go new file mode 100644 index 0000000..44deede --- /dev/null +++ b/internal/app/gpustream_test.go @@ -0,0 +1,165 @@ +package app + +import ( + "strings" + "testing" + "time" + + "github.com/cpprhtn/LiteDeck/internal/adapter" +) + +// One card, sampled twice, is one tile — not two. +// +// nvidia-smi prints a row per card per interval and nothing in between, so the +// only thing marking where one sample ends is the index column going back down. +// Get that wrong and a single-GPU box grows a second card every two seconds. +func TestGPUFeedSampleBoundary(t *testing.T) { + f := &gpuFeed{started: time.Now()} + const row0 = "0, NVIDIA GeForce RTX 4090, 31, 42, 55, 24564, 1024" + const row0b = "0, NVIDIA GeForce RTX 4090, 77, 61, 68, 24564, 8192" + + f.row(row0) + if got := len(f.latest); got != 1 { + t.Fatalf("after one row: %d cards, want 1", got) + } + f.row(row0b) + if got := len(f.latest); got != 1 { + t.Fatalf("after a second sample of the same card: %d cards, want 1", got) + } + if f.latest[0].Utilization != 31 { + t.Errorf("utilization %v — the completed sample should be the first one, "+ + "the second is still arriving", f.latest[0].Utilization) + } + f.row(row0) + if got := f.latest[0].Utilization; got != 77 { + t.Errorf("utilization %v after a third sample, want 77 — the feed is not "+ + "advancing", got) + } +} + +// Two cards must both appear, and must not be split across samples. +func TestGPUFeedTwoCards(t *testing.T) { + f := &gpuFeed{started: time.Now()} + rows := []string{ + "0, NVIDIA A100, 10, [N/A], 40, 40960, 1024", + "1, NVIDIA A100, 20, [N/A], 45, 40960, 2048", + } + for range 2 { // two full samples + for _, r := range rows { + f.row(r) + } + } + if got := len(f.latest); got != 2 { + t.Fatalf("%d cards, want 2", got) + } + if f.latest[0].Index != 0 || f.latest[1].Index != 1 { + t.Errorf("indices %d,%d — the sample was assembled out of order", + f.latest[0].Index, f.latest[1].Index) + } + // Passively cooled cards answer [N/A] for the fan, which must stay distinct + // from a genuine zero — a 0 there reads as a stopped fan. + if f.latest[0].Fan != -1 { + t.Errorf("fan %v for an [N/A] card, want -1", f.latest[0].Fan) + } +} + +// The first sample has to show before the boundary that completes it. +// +// Otherwise a single-GPU box shows nothing for one whole interval after +// connecting, which looks exactly like a card that is not detected. +func TestGPUFeedShowsTheFirstSampleImmediately(t *testing.T) { + f := &gpuFeed{started: time.Now()} + f.row("0, NVIDIA L4, 5, 30, 38, 23034, 512") + if len(f.latest) != 1 { + t.Fatalf("nothing to show after the first row ever received") + } + if f.latest[0].Name != "NVIDIA L4" { + t.Errorf("name %q", f.latest[0].Name) + } +} + +// A server with no card is an answer, not a failure — and not one to keep +// asking about. +func TestGPUFeedNoCardIsLiveAndEmpty(t *testing.T) { + w := newGPUWatcher() + f := &gpuFeed{started: time.Now()} + w.byID["h"] = f + f.closed() // the stream ran, said nothing, exited + + rows, live := w.sample("h") + if !live { + t.Error("a host with no card should not fall back to running an " + + "nvidia-smi it does not have") + } + if len(rows) != 0 { + t.Errorf("%d cards on a host with none", len(rows)) + } + if !f.noCard { + t.Error("the feed did not record that this host has no card") + } +} + +// A feed that stops producing must hand the reading back, not freeze the tiles. +// +// This is the nvidia-smi that block-buffers into the pipe: the process is alive +// and stays alive, so nothing about it times out on its own. Without this check +// the summary bar would show a sample from minutes ago and go on showing it. +func TestGPUFeedStaleFallsBackToInline(t *testing.T) { + w := newGPUWatcher() + f := &gpuFeed{started: time.Now().Add(-time.Hour)} + w.byID["h"] = f + f.row("0, NVIDIA A100, 10, 50, 40, 40960, 1024") + f.lastRow = time.Now().Add(-time.Hour) + + if _, live := w.sample("h"); live { + t.Fatal("a feed an hour behind is still reported as answering") + } + if !f.stalled { + t.Error("the stalled feed was left open; it holds a channel for the " + + "life of the app") + } + // And it does not come back on its own — a buffering nvidia-smi will not + // start line-buffering because we asked twice. + if _, live := w.sample("h"); live { + t.Error("the feed reported itself answering again after stalling") + } +} + +// Before any feed exists the poll has to read the card inline, so the first +// screen after connecting is not an empty space. +func TestGPUNoFeedMeansInline(t *testing.T) { + w := newGPUWatcher() + if _, live := w.sample("never-seen"); live { + t.Error("a host with no feed reported as answering") + } +} + +// forget has to actually forget: a card can be added to a machine between +// connections, and "no card" must not outlive the connection that observed it. +func TestGPUForgetClearsNoCard(t *testing.T) { + w := newGPUWatcher() + f := &gpuFeed{started: time.Now()} + w.byID["h"] = f + f.closed() + if _, live := w.sample("h"); !live { + t.Fatal("setup: expected the no-card answer") + } + w.forget("h") + if _, live := w.sample("h"); live { + t.Error("the no-card verdict survived the connection that produced it") + } +} + +// The two readings must ask for the same columns in the same order. They are +// parsed by the same code, so a drift here is a silently mislabelled tile — +// temperature shown as fan speed, say. +func TestGPUScriptsAgreeOnColumns(t *testing.T) { + for _, s := range []string{adapter.MetricsScriptWithGPU, adapter.GPUStreamScript} { + if !strings.Contains(s, "--query-gpu=index,name,utilization.gpu,fan.speed,temperature.gpu,memory.total,memory.used") { + t.Errorf("column list drifted in:\n%s", s) + } + if !strings.Contains(s, "--format=csv,noheader,nounits") { + t.Errorf("format drifted in:\n%s", s) + } + } +} diff --git a/internal/app/hosts.go b/internal/app/hosts.go index 23d56e7..97e3906 100644 --- a/internal/app/hosts.go +++ b/internal/app/hosts.go @@ -149,6 +149,9 @@ func (a *App) DisconnectHost(hostID string) error { // The CPU baseline is meaningless across a reconnect: /proc/stat counters // reset on reboot, and a stale sample would draw a wild first reading. a.cpu.forget(hostID) + // The GPU feed rides a channel on the connection being dropped, and its + // "this host has no card" is only true of the machine that just answered. + a.gpus.forget(hostID) // Dropping the connection kills the sessions anyway; this is so the registry // does not go on offering them back as tabs that cannot be typed into. a.terminals.closeHost(hostID) diff --git a/internal/app/mcp.go b/internal/app/mcp.go index 98a487a..d1a0fe4 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -115,7 +115,7 @@ func (a *App) startMCP() { a.registerMCPWriteTools(srv) // The port the user asked for. A stored port only counts when they pinned - // it: before v1.2.3 whatever got bound was written back here, so a value on + // it: before v1.3.0 whatever got bound was written back here, so a value on // its own says nothing about what anyone wanted (#2). wanted := defaultMCPPort if s.PortPinned && s.Port != 0 { diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go index a2e6b62..38f81a7 100644 --- a/internal/app/mcp_test.go +++ b/internal/app/mcp_test.go @@ -732,6 +732,17 @@ func TestJournalTimeExpressionsAreValidated(t *testing.T) { // down whatever was bound — see TestBusyPortFallsBackForThisRunOnly for why the // second approach made a temporary problem permanent (#2). func TestPortIsStableAcrossRestart(t *testing.T) { + // The premise is that the default port is free. It is not when the + // developer has LiteDeck itself open, and then both starts below fall back + // to different ephemeral ports and this reads as a stability bug that is + // not there. TestBusyPortFallsBackForThisRunOnly is the test for that case. + if l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", defaultMCPPort)); err != nil { + t.Skipf("port %d is already taken, so stability cannot be observed: %v", + defaultMCPPort, err) + } else { + l.Close() + } + dir := t.TempDir() first := New() @@ -816,7 +827,7 @@ func TestBusyPortFallsBackForThisRunOnly(t *testing.T) { } } -// A port written by the pre-v1.2.3 auto-remember is not a preference and must +// A port written by the pre-v1.3.0 auto-remember is not a preference and must // not be honoured, or upgrading leaves the user exactly where the bug left them. func TestUnpinnedStoredPortIsIgnored(t *testing.T) { dir := t.TempDir() diff --git a/internal/app/metrics.go b/internal/app/metrics.go index 7fa6560..7d1b0b0 100644 --- a/internal/app/metrics.go +++ b/internal/app/metrics.go @@ -90,10 +90,22 @@ func (a *App) HostMetrics(hostID string) (MetricsView, error) { }, nil } - // One round trip for everything. The script is a compile-time constant with - // nothing interpolated, which is why passing it to `sh -c` does not violate - // the argv-only rule (§3.2b) — there is no caller-supplied text in it. - res, err := conn.Poll(ctx, "sh", "-c", adapter.MetricsScript) + // The card is read by a feed that outlives this poll, so that nvidia-smi is + // started once rather than every two seconds. Whether it is answering + // decides which script runs: when it is, the poll leaves the GPU out + // entirely; when it is not — no feed yet, no channel to spare, output that + // arrives in blocks instead of lines — the script still carries the inline + // nvidia-smi it always did. The tiles never go blank on account of this. + gpuRows, gpuLive := a.gpus.sample(hostID) + script := adapter.MetricsScriptWithGPU + if gpuLive { + script = adapter.MetricsScript + } + + // One round trip for everything. Both scripts are compile-time constants + // with nothing interpolated, which is why passing one to `sh -c` does not + // violate the argv-only rule (§3.2b) — there is no caller-supplied text. + res, err := conn.Poll(ctx, "sh", "-c", script) if err != nil { return MetricsView{}, err } @@ -106,6 +118,18 @@ func (a *App) HostMetrics(hostID string) (MetricsView, error) { return MetricsView{}, err } a.cpu.record(hostID, m.CPUTimes) + if gpuLive { + // Never nil: the bar maps over this, and a host with no card has to + // arrive as an empty list rather than as a missing one. + m.GPUs = gpuRows + if m.GPUs == nil { + m.GPUs = []adapter.GPU{} + } + } + + // After the poll, not on connect: a poll is the evidence that somebody is + // looking at this host. Cheap and idempotent once the feed is running. + a.gpus.ensure(hostID, conn) return MetricsView{ Metrics: m, diff --git a/internal/app/sessions.go b/internal/app/sessions.go index 205bbec..7240814 100644 --- a/internal/app/sessions.go +++ b/internal/app/sessions.go @@ -127,6 +127,7 @@ func (a *App) freshen(hostID string) { a.sshPorts.forget(hostID) a.detected.forget(hostID) a.cpu.forget(hostID) + a.gpus.forget(hostID) } // selfPIDs returns the sshd processes this connection is running underneath. diff --git a/internal/app/version.go b/internal/app/version.go index 06bbf6e..782a910 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.3.1" +const Version = "1.3.2" diff --git a/internal/config/settings.go b/internal/config/settings.go index bf2aca2..8ce991d 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -45,7 +45,7 @@ type MCPSettings struct { // // The two are separate because they answer different questions: Port is // what the app should try, PortPinned is whether anybody asked for it. - // Until v1.2.3 the port that happened to get bound was written back here on + // Until v1.3.0 the port that happened to get bound was written back here on // every launch, so one busy moment on the default port moved the endpoint // permanently — the app tried the remembered port next launch, got it, and // never went home (#2). A Port with no PortPinned is a leftover of that and diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index de8283f..7988b7e 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -45,6 +45,7 @@ var english = map[string]string{ "app: 임시 파일을 만들 수 없습니다": "app: could not create a temp file", "enabled (지연 시작)": "enabled (delayed start)", "sshcore: 동시에 열 수 있는 스트림 수를 초과했습니다 (%d개) — 터미널 탭이나 로그 창을 닫아보세요": "sshcore: too many streams open at once (%d) — try closing a terminal tab or log window", + "sshcore: 배경 스트림 자리가 없습니다 (%d개)": "sshcore: no background stream slot free (%d)", "sshcore: 이 호스트에 열 수 있는 터미널·로그 창 수를 초과했습니다 (동시 %d개) — ": "sshcore: too many terminals and log windows open on this host (%d at once) — ", "sshcore: 터미널이 응답하지 않습니다 — 실행 중인 프로그램이 있는지 확인하세요": "sshcore: the terminal did not answer — check whether a program is running in it", "sshcore: 터미널이 현재 디렉터리를 알려주지 않았습니다": "sshcore: the terminal did not report its directory", diff --git a/internal/sshcore/conn.go b/internal/sshcore/conn.go index 17a4be2..f4ce670 100644 --- a/internal/sshcore/conn.go +++ b/internal/sshcore/conn.go @@ -74,10 +74,19 @@ type HostConfig struct { // below instead. Charging it to the long-lived pool is what made the terminal // budget quietly 3 rather than the 4 the error message claimed. // -// 1 (SFTP) + 5 (terminals, logs) + 3 (Exec) = 9, one under the default. +// Background streams get their own slot rather than a share of the long-lived +// pool. The pools answer to different owners: long-lived channels are opened +// because the user asked, and taking one for a reading the app decided to +// collect would mean a GPU host silently offers one terminal tab fewer than +// every other host. It is also the pool that can be refused most cheaply — the +// summary bar drops the GPU tiles and nothing else changes — so it sits last in +// the budget where a server with a lowered MaxSessions will hit it first. +// +// 1 (SFTP) + 5 (terminals, logs) + 3 (Exec) + 1 (background) = 10, the default. const ( - DefaultMaxSessions = 3 // transient: Exec. Queues when full. - DefaultMaxLongLived = 5 // long-lived: terminal tabs + log follows. + DefaultMaxSessions = 3 // transient: Exec. Queues when full. + DefaultMaxLongLived = 5 // long-lived: terminal tabs + log follows. + DefaultMaxBackground = 1 // app-internal: the GPU feed. ) // Result is the outcome of one remote command. @@ -202,6 +211,10 @@ type Conn struct { // longLived bounds the SFTP subsystem and terminal tabs, which hold their // channel until closed. longLived chan struct{} + // bg bounds streams the app opens for itself rather than for the user — + // today only the GPU feed. Separate from longLived so a background reading + // never costs somebody a terminal tab. + bg chan struct{} mu sync.Mutex sftp *sftp.Client // created on first use, shared by all file operations @@ -311,6 +324,7 @@ func Dial(ctx context.Context, cfg HostConfig) (*Conn, error) { jump: jump, sem: make(chan struct{}, maxSessions), longLived: make(chan struct{}, maxLongLived), + bg: make(chan struct{}, DefaultMaxBackground), }, nil } diff --git a/internal/sshcore/stream.go b/internal/sshcore/stream.go index d563173..ecf0896 100644 --- a/internal/sshcore/stream.go +++ b/internal/sshcore/stream.go @@ -47,6 +47,12 @@ type StreamOptions struct { // here and never in argv — this is how an elevated follow passes a sudo // password (§7.2). Stdin io.Reader + + // Background marks a stream the app opened for itself rather than one the + // user asked for. It draws from the background pool instead of the + // long-lived one, and it is logged as a poll — a reading nobody requested + // should not sit in the Command Log looking like a command somebody ran. + Background bool } // OpenStream starts a command and delivers its output line by line. onClose @@ -79,17 +85,25 @@ func (c *Conn) OpenStreamOpts( } // Follows hold their channel for as long as the panel is open, so they draw - // from the long-lived budget alongside SFTP and terminals. + // from the long-lived budget alongside SFTP and terminals. Background feeds + // draw from their own, so that a full pool of terminal tabs cannot turn the + // GPU tiles off and a GPU feed cannot cost somebody a tab. + pool := c.longLived + full := i18n.S("sshcore: 동시에 열 수 있는 스트림 수를 초과했습니다 (%d개) — 터미널 탭이나 로그 창을 닫아보세요") + kind := CommandAction + if opts.Background { + pool = c.bg + full = i18n.S("sshcore: 배경 스트림 자리가 없습니다 (%d개)") + kind = CommandPoll + } select { - case c.longLived <- struct{}{}: + case pool <- struct{}{}: case <-ctx.Done(): return nil, fmt.Errorf("sshcore: waiting for a stream slot: %w", ctx.Err()) default: - return nil, fmt.Errorf( - i18n.S("sshcore: 동시에 열 수 있는 스트림 수를 초과했습니다 (%d개) — 터미널 탭이나 로그 창을 닫아보세요"), - cap(c.longLived)) + return nil, fmt.Errorf(full, cap(pool)) } - release := func() { <-c.longLived } + release := func() { <-pool } sess, err := c.client.NewSession() if err != nil { @@ -116,7 +130,7 @@ func (c *Conn) OpenStreamOpts( s := &Stream{sess: sess, release: release} if c.obs != nil { - info := CommandInfo{HostID: c.cfg.ID, Line: line, Kind: CommandAction} + info := CommandInfo{HostID: c.cfg.ID, Line: line, Kind: kind} c.obs.CommandStarted(info) // A follow has no exit code worth waiting for — it ends when the user // closes it — so it is reported as finished immediately. Leaving it diff --git a/wails.json b/wails.json index 9e3c0b0..cce1672 100644 --- a/wails.json +++ b/wails.json @@ -8,7 +8,7 @@ "frontend:dev:serverUrl": "auto", "info": { "productName": "LiteDeck", - "productVersion": "1.3.1", + "productVersion": "1.3.2", "companyName": "LiteDeck", "copyright": "Copyright © 2026 Junwon Lee. Apache-2.0.", "comments": "SSH 하나로 원격 서버를 내 컴퓨터의 GUI에서 다룹니다." From 4be62e0147643f1280db01f05669b1e587982b78 Mon Sep 17 00:00:00 2001 From: webbrain-one <295484252+webbrain-one@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:25:37 +0300 Subject: [PATCH 05/39] docs: improve README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bca1574..ad62972 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ + +

@@ -96,7 +98,7 @@ RDP·VNC·TeamViewer는 서버의 **화면 픽셀을 영상으로 스트리밍** | **Command Log** | GUI가 실행한 **모든 명령을 실시간으로 표시**. 클릭하면 복사됩니다 | | **MCP** | Claude Code·Claude Desktop이 이 앱을 통해 서버를 조회하고 바꿉니다. 서버별 opt-in, 변경은 승인, **되돌리기 가능** | | **접속** | 비밀번호·키·에이전트·2FA. `~/.ssh/config` 가져오기. **ProxyJump** 로 경유 서버 한 단계 | -| **언어** | 한국어·영어. 처음 열 때 OS 언어를 따르고, 사이드바 아래 `KO`/`EN` 로 바꿉니다 | +| **언어** | 한국어·영어. 설정된 언어가 있으면 그 언어를, 없으면 OS 언어를 따르고, 사이드바 아래 `KO`/`EN` 로 바꿉니다 | ### 무엇을 실행했는지 숨기지 않습니다 @@ -151,7 +153,7 @@ claude mcp add --transport http litedeck http://127.0.0.1:<포트>/mcp \ > 그래서 첫 실행에 macOS Gatekeeper와 Windows SmartScreen 경고가 뜹니다. 넘어가는 방법은 > [설치와 서버 준비](docs/install.md)에 적어두었습니다. -**서버 쪽 준비.** Linux는 이미 SSH로 접속하고 계시면 끝입니다. Windows는 OpenSSH 서버만 켜면 됩니다. +**서버 쪽 준비.** Linux은 이미 SSH로 접속하고 계시면 끝입니다. Windows는 OpenSSH 서버만 켜면 됩니다. 집에 있는 PC를 외부에서 다루려면 포트포워딩 대신 메시 VPN을 권합니다 — [서버 준비](docs/install.md#서버-준비) · [Tailscale로 외부에서 쓰기](docs/remote-access.md) From 88256d40bc9086dc00d206e7e3648b46b378dd33 Mon Sep 17 00:00:00 2001 From: Junwon Lee Date: Sun, 23 Aug 2026 14:58:25 +0900 Subject: [PATCH 06/39] v1.4.0 monitoring tab: CPU breakdown, per-core, trends over time, event timeline --- README.en.md | 4 +- README.md | 6 +- docs/building.en.md | 2 +- docs/building.md | 2 +- docs/support.en.md | 2 + docs/support.md | 2 + frontend/src/App.tsx | 33 +- frontend/src/EventTimeline.tsx | 220 +++++++++++ frontend/src/MetricsBar.tsx | 122 +++--- frontend/src/MonitorView.tsx | 66 ++++ frontend/src/ResourceView.tsx | 334 ++++++++++++++++ frontend/src/TimeChart.tsx | 126 ++++++ frontend/src/app.css | 361 ++++++++++++++++++ frontend/src/ipc.ts | 61 +++ frontend/src/locale-en.ts | 67 ++++ frontend/src/metricsStore.ts | 120 ++++++ internal/adapter/detect.go | 11 + internal/adapter/journal.go | 273 +++++++++++++ internal/adapter/journal_test.go | 166 ++++++++ internal/adapter/metrics.go | 133 ++++++- internal/adapter/metrics_test.go | 130 +++++++ internal/adapter/windows_metrics.go | 6 + internal/app/events.go | 108 ++++++ internal/app/events_test.go | 111 ++++++ internal/app/gpustream.go | 23 +- internal/app/gpustream_test.go | 56 +++ internal/app/metrics.go | 37 +- internal/app/version.go | 2 +- internal/i18n/catalog_en.go | 1 + testdata/golden/journal/provenance.txt | 5 + .../golden/journal/warning-ubuntu-22.04.jsonl | 7 + wails.json | 2 +- 32 files changed, 2533 insertions(+), 66 deletions(-) create mode 100644 frontend/src/EventTimeline.tsx create mode 100644 frontend/src/MonitorView.tsx create mode 100644 frontend/src/ResourceView.tsx create mode 100644 frontend/src/TimeChart.tsx create mode 100644 frontend/src/metricsStore.ts create mode 100644 internal/adapter/journal.go create mode 100644 internal/adapter/journal_test.go create mode 100644 internal/app/events.go create mode 100644 internal/app/events_test.go create mode 100644 testdata/golden/journal/provenance.txt create mode 100644 testdata/golden/journal/warning-ubuntu-22.04.jsonl diff --git a/README.en.md b/README.en.md index ca8c74c..8fc29d4 100644 --- a/README.en.md +++ b/README.en.md @@ -94,7 +94,9 @@ 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. NVIDIA cards add **utilisation, fan, temperature and VRAM** (nvidia-smi) | +| **Monitoring** | The bar is the glance, the monitoring tab is the detail. CPU is **split into user, kernel, IO wait and steal** — 90% that is all IO wait means the disk, and all steal means the hypervisor. **Per-core bars**, memory with cache and buffers separated out, every filesystem, and NVIDIA cards add **utilisation, fan, temperature and VRAM** (nvidia-smi) | +| **Trends** | CPU, memory and GPU over time for as long as the app was watching. **Stretches it did not see are drawn as breaks**, never joined up | +| **Events** | What happened, and when. **OOM kills**, failed units, core dumps, scheduled restarts, reboot boundaries. Read from the systemd journal and classified by `MESSAGE_ID` rather than by wording, so the server's language does not matter | | **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 bca1574..ebee84a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@

- 접속 한 번으로 파일 · 편집기 · 서비스 · 프로세스 · 컨테이너 · 네트워크 · 세션 · 터미널까지.
+ 접속 한 번으로 파일 · 편집기 · 서비스 · 프로세스 · 컨테이너 · 네트워크 · 세션 · 모니터링 · 터미널까지.
서버에는 아무것도 설치하지 않았습니다.

@@ -92,7 +92,9 @@ RDP·VNC·TeamViewer는 서버의 **화면 픽셀을 영상으로 스트리밍** | **세션** | 이 서버에 누가 붙어 있는지. 접속별로 끊기 | | **스케줄** | systemd 타이머. 다음·마지막 실행 시각 | | **터미널** | xterm.js PTY, 탭 여러 개. `code .` · `vi foo.conf` 를 **앱이 가로채** 파일 탭에서 엽니다. 서버로 보내지 않으므로 VS Code나 vi가 없어도 됩니다 | -| **모니터링** | CPU·메모리·디스크 요약 바 + 그래프. NVIDIA 카드가 있으면 **GPU 사용률·팬·온도·VRAM** 도 함께 (nvidia-smi) | +| **모니터링** | 요약 바는 한눈에, 모니터링 탭은 자세히. **CPU 를 사용자·커널·IO 대기·뺏김으로 갈라** 보여줍니다 — 90%가 전부 IO 대기면 디스크가, 전부 뺏김이면 하이퍼바이저가 문제입니다. **코어별 막대**, 메모리의 캐시·버퍼 분리, 파일시스템 전부, NVIDIA 카드가 있으면 **GPU 사용률·팬·온도·VRAM** (nvidia-smi) | +| **추세** | 보고 있는 동안의 CPU·메모리·GPU 를 시간축으로. **앱이 안 보던 구간은 선을 끊어** 표시합니다 — 없는 기록을 이어 그리지 않습니다 | +| **이벤트** | 무슨 일이 언제 있었나. **OOM killer**, 유닛 실패, 코어 덤프, 재시작 예약, 재부팅 경계. systemd 저널을 읽되 문구가 아니라 `MESSAGE_ID` 로 갈라내므로 서버 언어와 무관합니다 | | **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 65f0778..bf93a9f 100644 --- a/docs/building.en.md +++ b/docs/building.en.md @@ -35,5 +35,5 @@ 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.3.2 was built with Go 1.26.6, Node 22.13.1, Wails CLI 2.13.0 (library 2.14.0), Docker 29.4.0 on macOS 26.5.2 arm64. +v1.4.0 was built with Go 1.26.6, Node 22.13.1, Wails CLI 2.13.0 (library 2.14.0), Docker 29.4.0 on macOS 26.5.2 arm64. The release workflow pins the same CLI. `@latest` above may hand you a newer one; it still builds. diff --git a/docs/building.md b/docs/building.md index 492df08..344cb45 100644 --- a/docs/building.md +++ b/docs/building.md @@ -35,5 +35,5 @@ go test ./... -race # 통합 테스트 포함 (Docker 필요) 통합 테스트는 실제 서버를 띄웁니다. `testdata/`에 sshd·systemd·Docker-in-Docker 픽스처가 있습니다. Docker가 없으면 실패가 아니라 건너뛰므로, **`-race`가 몇 초 만에 끝났다면 통합 테스트가 안 돌았다는 뜻입니다** (Docker가 켜져 있으면 1분 남짓 걸립니다). -v1.3.2를 만든 환경: Go 1.26.6 · Node 22.13.1 · Wails CLI 2.13.0 (라이브러리 2.14.0) · Docker 29.4.0 (macOS 26.5.2 arm64). +v1.4.0을 만든 환경: Go 1.26.6 · Node 22.13.1 · Wails CLI 2.13.0 (라이브러리 2.14.0) · Docker 29.4.0 (macOS 26.5.2 arm64). 릴리스 워크플로도 같은 CLI 를 씁니다. 위의 `@latest` 로 받으면 더 새 CLI 가 올 수 있는데, 그래도 빌드는 됩니다. diff --git a/docs/support.en.md b/docs/support.en.md index 9a3d79d..fd3af47 100644 --- a/docs/support.en.md +++ b/docs/support.en.md @@ -44,6 +44,8 @@ file chooser have not. | **Debian, RHEL/Rocky 8** | Share the systemd 245 table-parsing path, so they should work, but untested | | **Podman** | Docker-compatible CLI, so the parser is shared, but never run | | **Windows containers** | The test machine had no Docker | +| **OOM and core-dump events** | Classification goes by systemd's `MESSAGE_ID` and those IDs were checked against the catalogue, but **no real OOM or core dump has ever been seen through this.** A container cannot produce one — the kernel's OOM message goes to the host's journal | +| **The monitoring tab on Windows** | Resources appear, but there is **no CPU split, no per-core view and no memory breakdown** — those read `/proc`, which Windows does not have. The events pane does not appear at all; the Windows event log sits where journald does, but nothing reads it yet | | **More than one NVIDIA card** | Confirmed on real hardware with a single card. The multi-card path is covered by tests for the parser and the sample boundary, but has not run against a machine with several | | **Compose v1 and `podman compose`** | Only the Compose v2 plugin was tried. The standalone `docker-compose` (v1) cannot address a project without its file and is not supported. `podman compose` takes the same code path but was never run | | **ProxyJump against a real bastion** | Verified against two containers (bastion + target). Never tried against an actual bastion. Multi-hop is not implemented and is refused | diff --git a/docs/support.md b/docs/support.md index a0b0816..28a11ee 100644 --- a/docs/support.md +++ b/docs/support.md @@ -44,6 +44,8 @@ Linux 클라이언트는 **창이 열리고 접속·조회까지** 확인했습 | **Debian, RHEL·Rocky 8** | systemd 245 표 파싱 경로를 공유하므로 동작이 예상되나 확인 안 됨 | | **Podman** | `docker` 호환 CLI라 파서를 공유하지만 실행 안 해봄 | | **Windows 컨테이너** | 테스트한 서버에 Docker가 없었음 | +| **OOM·코어 덤프 이벤트** | 분류는 systemd 의 `MESSAGE_ID` 로 하고 그 ID 는 카탈로그에서 확인했지만, **실제 OOM 이나 코어 덤프가 일어난 줄은 본 적이 없습니다.** 컨테이너에서는 커널 OOM 메시지가 호스트 저널로 가서 만들 수가 없었습니다 | +| **Windows 의 모니터링 탭** | 리소스는 나오지만 **CPU 분해·코어별·메모리 분해가 없습니다** — `/proc` 을 읽는 값들이라 Windows 에는 원본이 없습니다. 이벤트 탭은 아예 뜨지 않습니다. 이벤트 로그가 journald 자리에 대응되지만 아직 읽지 않습니다 | | **NVIDIA 카드 여러 장** | 한 장 꽂힌 실기에서 확인했습니다. 여러 장을 읽는 경로는 파서와 샘플 경계까지만 테스트로 확인했고 실기에서는 안 해봄 | | **Compose v1·podman compose** | Compose v2 플러그인만 확인했습니다. 독립 실행형 `docker-compose`(v1)는 파일 없이 프로젝트를 다루지 못해 지원하지 않습니다. `podman compose`는 같은 경로를 타지만 실행 안 해봄 | | **ProxyJump 실서버** | 컨테이너 두 대(경유 + 목적지)로 확인. 실제 bastion으로는 안 해봄. 다단은 미구현이고 거부합니다 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98bc838..c9c88da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { MetricsBar } from './MetricsBar' import { NetworkView } from './NetworkView' import { ProcessView } from './ProcessView' import { SessionView } from './SessionView' +import { MonitorView } from './MonitorView' import { ServiceView } from './ServiceView' // xterm.js is ~340KB — more than the rest of the app combined. The terminal is @@ -50,7 +51,15 @@ import { initPlatform } from './platform' // Scoped to what §1.6 fixed — a handful of servers, opened when something needs // doing, GUI first. No tray, no alerting, no fleet view. -type Tab = 'services' | 'files' | 'processes' | 'containers' | 'network' | 'sessions' | 'terminal' +type Tab = + | 'services' + | 'files' + | 'processes' + | 'containers' + | 'network' + | 'sessions' + | 'monitor' + | 'terminal' // files and terminal have no capability because they need nothing but SSH // itself — SFTP and a PTY. That is what keeps them working on a host no adapter @@ -62,6 +71,7 @@ const TABS: { id: Tab; label: string; capability?: string }[] = [ { id: 'containers', label: k('컨테이너'), capability: 'containers' }, { id: 'network', label: k('네트워크'), capability: 'network' }, { id: 'sessions', label: k('세션'), capability: 'sessions' }, + { id: 'monitor', label: k('모니터링'), capability: 'metrics' }, { id: 'terminal', label: k('터미널') }, ] @@ -518,6 +528,27 @@ function renderTab( } return + case 'monitor': + if (!info.capabilities?.metrics) { + return ( + + ) + } + // The event pane needs a journal; the resource pane does not. So the tab + // is gated on metrics and the pane hides itself, rather than the whole + // tab disappearing on a host that simply has no systemd. + return ( + + ) + case 'terminal': return ( {t('터미널을 불러오는 중…')}
}> diff --git a/frontend/src/EventTimeline.tsx b/frontend/src/EventTimeline.tsx new file mode 100644 index 0000000..ec8fa2e --- /dev/null +++ b/frontend/src/EventTimeline.tsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useState } from 'react' +import { HostEvents, type EventsView, type ServerEvent } from './ipc' +import { k, t } from './i18n' + +// The event timeline (§4.7) — see arch/07. +// +// Not a log viewer. There is already one, and pouring `journalctl -p warning` +// onto the screen would be a second one with a worse filter. What earns a row +// here is the handful of things that explain "the service died overnight": +// the OOM killer, a unit that failed, a process that dumped core, a restart +// that keeps being scheduled. +// +// One read per open, not a poll. The past does not change. + +/** Oldest first is wrong here: the question is what happened, and the answer is + * usually the most recent thing. The backend already sorts newest-first. */ +const RANGES: { id: '1h' | '24h' | '7d'; label: string }[] = [ + { id: '1h', label: k('1시간') }, + { id: '24h', label: k('24시간') }, + { id: '7d', label: k('7일') }, +] + +/** Severity of the row, which decides its colour. Kind first, because an OOM + * kill logged at notice is still an OOM kill. */ +function tone(e: ServerEvent): 'bad' | 'warn' | 'plain' { + switch (e.kind) { + case 'oom': + case 'unit-failed': + case 'start-failed': + case 'coredump': + return 'bad' + case 'restart': + return 'warn' + case 'boot': + case 'shutdown': + return 'plain' + } + return e.severity <= 3 ? 'bad' : e.severity <= 4 ? 'warn' : 'plain' +} + +const KIND_LABEL: Record = { + oom: 'OOM', + 'unit-failed': k('유닛 실패'), + 'start-failed': k('시작 실패'), + coredump: k('코어 덤프'), + restart: k('재시작 예약'), + boot: k('부팅'), + shutdown: k('종료'), + session: k('세션 시작'), + other: '', +} + +export function EventTimeline({ + hostID, + onError, + onOpenService, +}: { + hostID: string + onError: (msg: string) => void + /** Jump to this unit's log. The timeline says what happened; the log says why. */ + onOpenService?: (unit: string) => void +}) { + const [range, setRange] = useState<'1h' | '24h' | '7d'>('24h') + const [view, setView] = useState(null) + const [busy, setBusy] = useState(false) + + const load = useCallback( + async (elevate: boolean) => { + setBusy(true) + try { + setView(await HostEvents(hostID, range, elevate)) + } catch (e) { + onError(String(e)) + } finally { + setBusy(false) + } + }, + [hostID, range, onError], + ) + + useEffect(() => { + void load(false) + }, [load]) + + return ( +
+
+
+ {RANGES.map((r) => ( + + ))} +
+ + +
+ + {view && void load(true)} />} + + {view?.truncated && ( +
+ {t('가장 오래된 줄이 이 기간의 시작이 아닙니다 — 한 번에 읽는 최대 줄 수에 걸렸습니다.')} +
+ )} + +
+ {view?.access === 'ok' && view.events.length === 0 && ( +
+ {t('이 기간에는 기록할 만한 사건이 없었습니다.')} +
+ )} + {view?.events.map((e, i) => { + // A reboot between two rows is the one piece of context that makes + // "it stopped answering" and "it was restarted" tell themselves apart. + const rebooted = i > 0 && e.bootId !== view.events[i - 1].bootId + return ( +
+ {rebooted && ( +
+ {t('재부팅')} +
+ )} +
+ {when(e.at)} + {KIND_LABEL[e.kind] && ( + {t(KIND_LABEL[e.kind])} + )} + {e.unit && + (onOpenService ? ( + + ) : ( + {e.unit} + ))} + {e.message} +
+
+ ) + })} +
+
+ ) +} + +/** Emptiness always arrives labelled. An unreadable journal and a quiet server + * look identical on screen, and they need opposite things said about them. */ +function AccessNotice({ + view, + busy, + onElevate, +}: { + view: EventsView + busy: boolean + onElevate: () => void +}) { + if (view.access === 'ok') return null + + if (view.access === 'needs-sudo') { + return ( +
+

+ {t('이 사용자는 systemd-journal·adm 그룹에 없어 시스템 저널이 비어 보입니다. 사건이 없는 것이 아니라 보이지 않는 것입니다.')} +

+
+ + + {t('서버에서 그룹에 추가하면 매번 묻지 않아도 됩니다.')} + +
+
+ ) + } + + if (view.access === 'denied') { + return ( +
+

+ {t('이 사용자는 시스템 저널을 읽을 수 없고 sudo 도 없습니다. 목록이 비어 있는 것은 사건이 없어서가 아닙니다.')} +

+

+ {t('서버에서 이 계정을 systemd-journal 또는 adm 그룹에 추가하세요.')} +

+
+ ) + } + + return ( +
+

{t('이 서버에는 systemd 저널이 없습니다.')}

+
+ ) +} + +/** Local time, seconds included — "at 03:14" is the whole point of the row. */ +function when(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} diff --git a/frontend/src/MetricsBar.tsx b/frontend/src/MetricsBar.tsx index c340347..050000f 100644 --- a/frontend/src/MetricsBar.tsx +++ b/frontend/src/MetricsBar.tsx @@ -1,6 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { HostMetrics, type GPU, type MetricsView } from './ipc' import { usePoll } from './usePoll' +import { + GAP_MS, + forgetMetrics, + publishMetrics, + useMetricsHistory, + type Sample, +} from './metricsStore' import { t } from './i18n' // The summary bar (§4.7). Shown above every tab for the connected host, so @@ -10,7 +17,6 @@ import { t } from './i18n' // monitoring stack does the rest, and §1.5 keeps LiteDeck out of that business. const POLL_MS = 2000 -const HISTORY = 60 // two minutes at the poll rate function fmtBytes(n: number): string { if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(1)}G` @@ -35,25 +41,52 @@ function fmtUptime(sec: number): string { * it scales with the container for free, and it inherits `currentColor` so the * theme applies without a redraw. */ -function Sparkline({ values, warn }: { values: number[]; warn?: boolean }) { +function Sparkline({ + samples, + pick, + warn, +}: { + samples: Sample[] + pick: (s: Sample) => number + warn?: boolean +}) { const w = 56 const h = 16 - if (values.length < 2) return + const usable = samples.filter((s) => pick(s) >= 0) + if (usable.length < 2) return + + // x is time, not index. Spacing the points evenly drew half an hour of idle + // polling as though it were two minutes of active polling — the shape stayed + // while the axis under it quietly changed scale (A-45). + const t0 = usable[0].t + const span = Math.max(1, usable[usable.length - 1].t - t0) // Fixed 0–100 scale. Auto-scaling would make 2% CPU look identical to 90%, // which is exactly the distinction the bar exists to show. - const step = w / (HISTORY - 1) - const points = values - .map((v, i) => { - const x = (i + (HISTORY - values.length)) * step - const y = h - (Math.max(0, Math.min(100, v)) / 100) * h - return `${x.toFixed(1)},${y.toFixed(1)}` - }) - .join(' ') + const at = (s: Sample) => + `${(((s.t - t0) / span) * w).toFixed(1)},${(h - (Math.max(0, Math.min(100, pick(s))) / 100) * h).toFixed(1)}` + + // One run per stretch that was actually recorded. A gap is the app having + // been away, and drawing through it invents a reading nobody took. + const runs: string[][] = [[]] + usable.forEach((s, i) => { + if (i > 0 && s.t - usable[i - 1].t > GAP_MS) runs.push([]) + runs[runs.length - 1].push(at(s)) + }) return ( - + {runs + .filter((r) => r.length >= 2) + .map((r, i) => ( + + ))} ) } @@ -87,7 +120,8 @@ function Stat({ value, unit, note, - history, + samples, + pick, warn, title, }: { @@ -98,7 +132,8 @@ function Stat({ // 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[] + samples?: Sample[] + pick?: (s: Sample) => number warn?: boolean title?: string }) { @@ -111,7 +146,7 @@ function Stat({ {unit && {unit}} {note && {note}} - {history && } + {samples && pick && } ) @@ -119,14 +154,8 @@ function Stat({ 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 samples = useMetricsHistory(hostID) const [failed, setFailed] = useState(null) const inFlight = useRef(false) const gpuPopRef = useRef(null) @@ -151,11 +180,10 @@ export function MetricsBar({ hostID }: { hostID: string }) { useEffect(() => { // Host changed: the previous host's history says nothing about this one. + // History lives in the store, which forgetMetrics clears — the bar keeps + // no second copy to fall out of step with it. + forgetMetrics(hostID) setM(null) - setCpuHist([]) - setMemHist([]) - setGpuHist([]) - setGpuMaxHist([]) setGpuOpen(false) setFailed(null) }, [hostID]) @@ -169,26 +197,10 @@ export function MetricsBar({ hostID }: { hostID: string }) { try { const next = await HostMetrics(hostID) setM(next) + // Anything else that wants these numbers reads them from here rather + // than polling for its own copy — see metricsStore. + publishMetrics(hostID, next) setFailed(null) - // CPU is -1 until a second sample exists; plotting that would draw a - // spike down to zero on every connect. - if (next.cpu >= 0) { - 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. @@ -228,7 +240,8 @@ export function MetricsBar({ hostID }: { hostID: string }) { label="CPU" value={m.cpu < 0 ? '—' : m.cpu.toFixed(0)} unit={m.cpu < 0 ? undefined : '%'} - history={cpuHist} + samples={samples} + pick={(s) => s.cpu} warn={m.cpu >= 85} title={m.cpu < 0 ? t('두 번째 샘플을 기다리는 중 — 누적 카운터라 한 번만으로는 알 수 없습니다') : undefined} /> @@ -236,7 +249,8 @@ export function MetricsBar({ hostID }: { hostID: string }) { label={t('메모리')} value={m.memPercent.toFixed(0)} unit="%" - history={memHist} + samples={samples} + pick={(s) => s.mem} warn={m.memPercent >= 90} title={`${fmtBytes(m.memUsed)} / ${fmtBytes(m.memTotal)}`} /> @@ -248,7 +262,8 @@ export function MetricsBar({ hostID }: { hostID: string }) { 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]} + samples={samples} + pick={(s) => s.gpu[0] ?? -1} warn={gpuWarn(gpus[0])} title={gpuTitle(gpus[0])} /> @@ -269,7 +284,10 @@ export function MetricsBar({ hostID }: { hostID: string }) { value={fmtPct(gpuBusy)} unit={gpuBusy < 0 ? undefined : '%'} note={gpuFan < 0 ? undefined : t('팬 {f}%', { f: fmtPct(gpuFan) })} - history={gpuMaxHist} + samples={samples} + // The busiest card per sample, not one chosen card: following a + // single line would make it jump as the lead changes hands. + pick={(s) => (s.gpu.length ? Math.max(...s.gpu) : -1)} warn={gpus.some(gpuWarn)} />