Skip to content

Commit d30f533

Browse files
davindicodeclaude
andcommitted
System info popup, fix nano version detection
- Header: info button (i) opens system info popup showing hostname, OS, CPU, memory, uptime, shell, node, git, python, tmux, nano, vim versions - New /api/system-info endpoint with full system details - Fix nano version: use -V flag (macOS pico hangs with --version) - 2s timeout on version commands to prevent hangs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 48e459e commit d30f533

4 files changed

Lines changed: 148 additions & 13 deletions

File tree

app/components/Header.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,63 @@
1+
import { useState, useEffect, useRef } from "react";
12
import { useTerminalStore } from "~/stores/terminalStore";
23

4+
function SystemInfoPopup({ onClose }: { onClose: () => void }) {
5+
const [info, setInfo] = useState<Record<string, string | null> | null>(null);
6+
const popupRef = useRef<HTMLDivElement>(null);
7+
8+
useEffect(() => {
9+
fetch("/api/system-info")
10+
.then((r) => r.json())
11+
.then(setInfo)
12+
.catch(() => setInfo({}));
13+
}, []);
14+
15+
useEffect(() => {
16+
const handler = (e: MouseEvent) => {
17+
if (popupRef.current && !popupRef.current.contains(e.target as Node)) onClose();
18+
};
19+
document.addEventListener("mousedown", handler);
20+
return () => document.removeEventListener("mousedown", handler);
21+
}, [onClose]);
22+
23+
return (
24+
<div ref={popupRef} className="absolute right-2 top-10 z-50 bg-[#16162a] border border-gray-700 rounded-lg shadow-xl w-72 max-h-80 overflow-y-auto">
25+
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
26+
<span className="text-xs font-medium text-gray-300">System Info</span>
27+
<button onClick={onClose} className="text-gray-500 hover:text-white">
28+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
29+
</button>
30+
</div>
31+
{!info ? (
32+
<div className="flex items-center justify-center py-4">
33+
<svg className="w-5 h-5 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
34+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
35+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
36+
</svg>
37+
</div>
38+
) : (
39+
<div className="px-3 py-2 space-y-1">
40+
{Object.entries(info).map(([key, val]) => val ? (
41+
<div key={key} className="flex justify-between gap-2 text-[11px]">
42+
<span className="text-gray-500 shrink-0">{key}</span>
43+
<span className="text-gray-300 text-right break-all">{val}</span>
44+
</div>
45+
) : null)}
46+
</div>
47+
)}
48+
</div>
49+
);
50+
}
51+
352
export default function Header() {
453
const socketConnected = useTerminalStore((s) => s.socketConnected);
554
const sessions = useTerminalStore((s) => s.sessions);
55+
const [showInfo, setShowInfo] = useState(false);
656

7-
// Check if any session is actually connected (PTY alive)
857
const hasActiveSessions = Object.values(sessions).some(
958
(s) => s.status === "connected" || s.status === "connecting"
1059
);
1160

12-
// online = socket connected AND at least one session alive
13-
// reconnecting = socket connected but all sessions dead (just reconnected)
14-
// offline = socket disconnected
1561
const status = !socketConnected
1662
? "offline"
1763
: hasActiveSessions
@@ -21,12 +67,21 @@ export default function Header() {
2167
: "online";
2268

2369
return (
24-
<header className="flex items-center justify-between px-3 py-1.5 bg-[#0d0d1a] border-b border-gray-800 shrink-0">
70+
<header className="flex items-center justify-between px-3 py-1.5 bg-[#0d0d1a] border-b border-gray-800 shrink-0 relative">
2571
<div className="flex items-center gap-2">
2672
<img src="/logo-square.png" alt="OTG Code" className="w-6 h-6 rounded" />
2773
<span className="text-white font-bold text-sm">OTG Code</span>
2874
</div>
2975
<div className="flex items-center gap-2">
76+
<button
77+
onClick={() => setShowInfo(!showInfo)}
78+
className="p-1 text-gray-500 hover:text-white transition-colors rounded"
79+
title="System info"
80+
>
81+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
82+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
83+
</svg>
84+
</button>
3085
<span
3186
className={`w-2 h-2 rounded-full ${
3287
status === "online"
@@ -46,6 +101,7 @@ export default function Header() {
46101
{status}
47102
</span>
48103
</div>
104+
{showInfo && <SystemInfoPopup onClose={() => setShowInfo(false)} />}
49105
</header>
50106
);
51107
}

app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export default [
1212
route("api/files/mkdir", "routes/api/files.mkdir.ts"),
1313
route("api/tmux/sessions", "routes/api/tmux.sessions.ts"),
1414
route("api/tool-versions", "routes/api/tool-versions.ts"),
15+
route("api/system-info", "routes/api/system-info.ts"),
1516
] satisfies RouteConfig;

app/routes/api/system-info.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { execSync } from "child_process";
2+
import { platform, release, arch, hostname, totalmem, freemem, cpus, uptime } from "os";
3+
import type { Route } from "./+types/system-info";
4+
5+
function run(cmd: string): string {
6+
try {
7+
return execSync(cmd, { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }).trim();
8+
} catch {
9+
return "";
10+
}
11+
}
12+
13+
function formatBytes(bytes: number): string {
14+
const gb = bytes / (1024 * 1024 * 1024);
15+
return `${gb.toFixed(1)} GB`;
16+
}
17+
18+
function formatUptime(seconds: number): string {
19+
const days = Math.floor(seconds / 86400);
20+
const hours = Math.floor((seconds % 86400) / 3600);
21+
const mins = Math.floor((seconds % 3600) / 60);
22+
return [days > 0 ? `${days}d` : "", hours > 0 ? `${hours}h` : "", `${mins}m`].filter(Boolean).join(" ");
23+
}
24+
25+
export async function loader({ request }: Route.LoaderArgs) {
26+
const os = platform();
27+
const cpu = cpus();
28+
29+
const info: Record<string, string | null> = {
30+
hostname: hostname(),
31+
os: os === "darwin" ? "macOS" : os === "win32" ? "Windows" : os === "linux" ? "Linux" : os,
32+
osVersion: release(),
33+
arch: arch(),
34+
cpu: cpu.length > 0 ? `${cpu[0].model} (${cpu.length} cores)` : null,
35+
memory: `${formatBytes(freemem())} free / ${formatBytes(totalmem())} total`,
36+
uptime: formatUptime(uptime()),
37+
shell: run("echo $SHELL") || run("echo %COMSPEC%") || null,
38+
node: process.version,
39+
tmux: run("tmux -V")?.replace(/^tmux\s+/i, "") || null,
40+
nano: run("nano -V").match(/nano\s+([\d.]+)/i)?.[1] || null,
41+
vim: run("vim --version").match(/Vi IMproved\s+([\d.]+)/)?.[1] || null,
42+
git: run("git --version")?.replace(/^git version\s+/i, "") || null,
43+
python: run("python3 --version")?.replace(/^Python\s+/i, "") || run("python --version")?.replace(/^Python\s+/i, "") || null,
44+
};
45+
46+
// Linux-specific: try to get distro name
47+
if (os === "linux") {
48+
const distro = run("cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d'\"' -f2");
49+
if (distro) info.distro = distro;
50+
}
51+
52+
// macOS-specific: get friendly version
53+
if (os === "darwin") {
54+
const macVer = run("sw_vers -productVersion");
55+
if (macVer) info.osVersion = macVer;
56+
}
57+
58+
return Response.json(info);
59+
}

app/routes/api/tool-versions.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
import { execSync } from "child_process";
22
import type { Route } from "./+types/tool-versions";
33

4-
function getVersion(cmd: string): string | null {
4+
function run(cmd: string): string | null {
55
try {
6-
const output = execSync(cmd, { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }).trim();
7-
return output;
6+
return execSync(cmd, { encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "pipe"] }).trim();
87
} catch {
98
return null;
109
}
1110
}
1211

13-
export async function loader({ request }: Route.LoaderArgs) {
14-
const tmux = getVersion("tmux -V")?.replace(/^tmux\s+/, "") || null;
15-
const nano = getVersion("nano --version")?.split("\n")[0]?.replace(/.*nano\s+/, "")?.trim() || null;
16-
const vim = getVersion("vim --version")?.split("\n")[0]?.match(/Vi IMproved (\S+)/)?.[1] || null;
12+
function getTmuxVersion(): string | null {
13+
return run("tmux -V")?.replace(/^tmux\s+/i, "") || null;
14+
}
15+
16+
function getNanoVersion(): string | null {
17+
// GNU nano supports --version; macOS pico doesn't (hangs), so use -V
18+
// Try to extract from "GNU nano x.y" or similar
19+
const out = run("nano -V");
20+
if (!out) return null;
21+
const match = out.match(/nano\s+([\d.]+)/i);
22+
return match?.[1] || null;
23+
}
1724

18-
return Response.json({ tmux, nano, vim });
25+
function getVimVersion(): string | null {
26+
const out = run("vim --version");
27+
if (!out) return null;
28+
const match = out.match(/Vi IMproved\s+([\d.]+)/);
29+
return match?.[1] || null;
30+
}
31+
32+
export async function loader({ request }: Route.LoaderArgs) {
33+
return Response.json({
34+
tmux: getTmuxVersion(),
35+
nano: getNanoVersion(),
36+
vim: getVimVersion(),
37+
});
1938
}

0 commit comments

Comments
 (0)