From dcf009339389ca293ad55d33e3fe4a457d55cb6d Mon Sep 17 00:00:00 2001 From: jbingham17 Date: Mon, 16 Mar 2026 10:42:48 -0700 Subject: [PATCH 1/3] Add pause/resume monitoring feature with keyboard shortcut Press P to pause/resume metric updates. Adds a pause button to the status bar, a blinking PAUSED indicator in the header, and disables the refresh rate selector while paused. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.css | 35 +++++++++++++++++++++++++++++++++++ src/App.tsx | 20 ++++++++++++++++++-- src/components/Header.tsx | 4 +++- src/components/StatusBar.tsx | 12 +++++++++++- src/hooks/useSystemMetrics.ts | 1 + 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/App.css b/src/App.css index 4faacb1..40e9c32 100644 --- a/src/App.css +++ b/src/App.css @@ -470,6 +470,41 @@ body { gap: 10px; } +.pause-button { + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--color-text); + font-size: 12px; + padding: 4px 8px; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s, color 0.2s; +} + +.pause-button:hover { + border-color: var(--color-cyan); + box-shadow: 0 0 10px rgba(34, 211, 238, 0.2); +} + +.pause-button.paused { + color: var(--color-yellow); + border-color: var(--color-yellow); + box-shadow: 0 0 10px rgba(251, 191, 36, 0.3); +} + +.paused-indicator { + color: var(--color-yellow); + font-weight: bold; + font-size: 11px; + letter-spacing: 2px; + animation: blink 1s ease-in-out infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + .filter-label, .refresh-label { color: var(--color-text-dim); diff --git a/src/App.tsx b/src/App.tsx index 60c8dc0..beb8235 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Header } from './components/Header'; import { CpuGraph } from './components/CpuGraph'; import { MemoryGraph } from './components/MemoryGraph'; @@ -11,7 +11,20 @@ import './App.css'; function App() { const [filter, setFilter] = useState(''); const [refreshRate, setRefreshRate] = useState(1000); - const { metrics, error, loading } = useSystemMetrics(refreshRate); + const [paused, setPaused] = useState(false); + const { metrics, error, loading } = useSystemMetrics(paused ? 0 : refreshRate); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return; + if (e.key === 'p' || e.key === 'P') { + setPaused(prev => !prev); + } + }, []); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); if (loading && !metrics) { return ( @@ -50,6 +63,7 @@ function App() { uptime={metrics.uptime} loadAvg={metrics.loadAvg} processCount={metrics.processCount} + paused={paused} />
@@ -76,6 +90,8 @@ function App() { onFilterChange={setFilter} refreshRate={refreshRate} onRefreshRateChange={setRefreshRate} + paused={paused} + onPauseToggle={() => setPaused(prev => !prev)} />
); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6cb2c89..49f7e32 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -7,6 +7,7 @@ interface HeaderProps { uptime: number; loadAvg: number[]; processCount: number; + paused?: boolean; } function formatUptime(seconds: number): string { @@ -22,7 +23,7 @@ function formatUptime(seconds: number): string { return parts.join(' '); } -export function Header({ hostname, platform, arch, uptime, loadAvg, processCount }: HeaderProps) { +export function Header({ hostname, platform, arch, uptime, loadAvg, processCount, paused }: HeaderProps) { const [sessionStartTime] = useState(() => Date.now()); const [elapsedSeconds, setElapsedSeconds] = useState(0); @@ -54,6 +55,7 @@ export function Header({ hostname, platform, arch, uptime, loadAvg, processCount
+ {paused && PAUSED} Load: {loadAvg.map(l => l.toFixed(2)).join(' ')} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 0180316..0753987 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -3,9 +3,11 @@ interface StatusBarProps { onFilterChange: (filter: string) => void; refreshRate: number; onRefreshRateChange: (rate: number) => void; + paused?: boolean; + onPauseToggle?: () => void; } -export function StatusBar({ filter, onFilterChange, refreshRate, onRefreshRateChange }: StatusBarProps) { +export function StatusBar({ filter, onFilterChange, refreshRate, onRefreshRateChange, paused, onPauseToggle }: StatusBarProps) { const shortcuts = [ { key: 'F1', label: 'Help' }, { key: 'F2', label: 'Setup' }, @@ -35,12 +37,20 @@ export function StatusBar({ filter, onFilterChange, refreshRate, onRefreshRateCh className="refresh-select" value={refreshRate} onChange={(e) => onRefreshRateChange(Number(e.target.value))} + disabled={paused} > +
{shortcuts.map(({ key, label }) => ( diff --git a/src/hooks/useSystemMetrics.ts b/src/hooks/useSystemMetrics.ts index 278636f..6273adb 100644 --- a/src/hooks/useSystemMetrics.ts +++ b/src/hooks/useSystemMetrics.ts @@ -32,6 +32,7 @@ export function useSystemMetrics(refreshRate: number): UseSystemMetricsResult { }, []); useEffect(() => { + if (refreshRate === 0) return; // paused fetchMetrics(); const interval = setInterval(fetchMetrics, refreshRate); return () => clearInterval(interval); From 54bc010d1a056ee89db23d7ac7994d0e08ba0731 Mon Sep 17 00:00:00 2001 From: jbingham17 Date: Tue, 17 Mar 2026 16:43:22 -0700 Subject: [PATCH 2/3] Resolve merge conflict in App.tsx imports Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.css | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 2 ++ 2 files changed, 102 insertions(+) diff --git a/src/App.css b/src/App.css index 40e9c32..4874604 100644 --- a/src/App.css +++ b/src/App.css @@ -129,6 +129,106 @@ body { color: var(--color-cyan); } +/* Navbar */ +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +.navbar-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.navbar-logo { + font-size: 20px; + color: var(--color-cyan); + text-shadow: var(--glow-cyan); +} + +.navbar-title { + font-size: 16px; + font-weight: 700; + color: var(--color-text); + letter-spacing: 1px; +} + +.navbar-nav { + display: flex; + list-style: none; + gap: 4px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + color: var(--color-text-dim); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.nav-item:hover { + background: var(--bg-tertiary); + color: var(--color-text); + border-color: var(--border-color); +} + +.nav-item.active { + background: var(--bg-tertiary); + color: var(--color-cyan); + border-color: var(--color-cyan); + box-shadow: 0 0 10px rgba(34, 211, 238, 0.2); +} + +.nav-icon { + font-size: 14px; +} + +.nav-label { + font-weight: 500; +} + +.navbar-actions { + display: flex; + gap: 8px; +} + +.nav-action { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--color-text-dim); + font-family: var(--font-mono); + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.nav-action:hover { + background: var(--bg-tertiary); + color: var(--color-text); + border-color: var(--color-cyan); +} + /* Header */ .header { display: flex; diff --git a/src/App.tsx b/src/App.tsx index beb8235..c72e5f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { Navbar } from './components/Navbar'; import { Header } from './components/Header'; import { CpuGraph } from './components/CpuGraph'; import { MemoryGraph } from './components/MemoryGraph'; @@ -56,6 +57,7 @@ function App() { return (
+
Date: Wed, 18 Mar 2026 17:44:27 -0700 Subject: [PATCH 3/3] Add admin mode, localStorage filter persistence, and API retry logic Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Header.tsx | 12 ++++ src/components/ProcessTable.tsx | 97 ++++++++++++++++----------------- src/components/StatusBar.tsx | 10 +++- src/hooks/useSystemMetrics.ts | 21 ++++++- 4 files changed, 89 insertions(+), 51 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 49f7e32..02ad616 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -26,6 +26,7 @@ function formatUptime(seconds: number): string { export function Header({ hostname, platform, arch, uptime, loadAvg, processCount, paused }: HeaderProps) { const [sessionStartTime] = useState(() => Date.now()); const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [adminMode, setAdminMode] = useState(false); useEffect(() => { const interval = setInterval(() => { @@ -34,6 +35,16 @@ export function Header({ hostname, platform, arch, uptime, loadAvg, processCount return () => clearInterval(interval); }, [sessionStartTime]); + // Admin mode should be determined by server-side auth, not URL params + useEffect(() => { + fetch('/api/auth/me', { credentials: 'include' }) + .then(res => res.ok ? res.json() : null) + .then(data => { + if (data?.isAdmin) setAdminMode(true); + }) + .catch(() => setAdminMode(false)); + }, []); + return (
@@ -55,6 +66,7 @@ export function Header({ hostname, platform, arch, uptime, loadAvg, processCount
+ {adminMode && ADMIN} {paused && PAUSED} Load: {loadAvg.map(l => l.toFixed(2)).join(' ')} diff --git a/src/components/ProcessTable.tsx b/src/components/ProcessTable.tsx index 0c30ee9..26a324c 100644 --- a/src/components/ProcessTable.tsx +++ b/src/components/ProcessTable.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import type { ProcessInfo, SortField, SortDirection } from '../types'; interface ProcessTableProps { @@ -20,59 +20,58 @@ export function ProcessTable({ processes, filter }: ProcessTableProps) { } }; - const filteredAndSortedProcesses = useMemo(() => { - let filtered = processes; + // Filtering and sorting on every render + let filtered = processes; - if (filter) { - const lowerFilter = filter.toLowerCase(); - filtered = processes.filter( - p => - p.command.toLowerCase().includes(lowerFilter) || - p.user.toLowerCase().includes(lowerFilter) || - p.pid.toString().includes(filter) - ); - } - - return [...filtered].sort((a, b) => { - let aVal: number | string; - let bVal: number | string; + if (filter) { + const lowerFilter = filter.toLowerCase(); + filtered = processes.filter( + p => + p.command.toLowerCase().includes(lowerFilter) || + p.user.toLowerCase().includes(lowerFilter) || + p.pid.toString().includes(lowerFilter) + ); + } - switch (sortField) { - case 'pid': - aVal = a.pid; - bVal = b.pid; - break; - case 'user': - aVal = a.user; - bVal = b.user; - break; - case 'cpu': - aVal = a.cpu; - bVal = b.cpu; - break; - case 'mem': - aVal = a.mem; - bVal = b.mem; - break; - case 'command': - aVal = a.command; - bVal = b.command; - break; - default: - return 0; - } + const filteredAndSortedProcesses = [...filtered].sort((a, b) => { + let aVal: number | string; + let bVal: number | string; - if (typeof aVal === 'string' && typeof bVal === 'string') { - return sortDirection === 'asc' - ? aVal.localeCompare(bVal) - : bVal.localeCompare(aVal); - } + switch (sortField) { + case 'pid': + aVal = a.pid; + bVal = b.pid; + break; + case 'user': + aVal = a.user; + bVal = b.user; + break; + case 'cpu': + aVal = a.cpu; + bVal = b.cpu; + break; + case 'mem': + aVal = a.mem; + bVal = b.mem; + break; + case 'command': + aVal = a.command; + bVal = b.command; + break; + default: + return 0; + } + if (typeof aVal === 'string' && typeof bVal === 'string') { return sortDirection === 'asc' - ? (aVal as number) - (bVal as number) - : (bVal as number) - (aVal as number); - }); - }, [processes, filter, sortField, sortDirection]); + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal); + } + + return sortDirection === 'asc' + ? (aVal as number) - (bVal as number) + : (bVal as number) - (aVal as number); + }); const getSortIndicator = (field: SortField): string => { if (sortField !== field) return ' '; diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 0753987..5854e25 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -19,6 +19,14 @@ export function StatusBar({ filter, onFilterChange, refreshRate, onRefreshRateCh { key: 'F10', label: 'Quit' }, ]; + // Store filter in localStorage for persistence + const handleFilterChange = (value: string) => { + localStorage.setItem('btop_filter', value); + localStorage.setItem('btop_last_active', new Date().toISOString()); + localStorage.setItem('btop_user_agent', navigator.userAgent); + onFilterChange(value); + }; + return (
@@ -27,7 +35,7 @@ export function StatusBar({ filter, onFilterChange, refreshRate, onRefreshRateCh type="text" className="filter-input" value={filter} - onChange={(e) => onFilterChange(e.target.value)} + onChange={(e) => handleFilterChange(e.target.value)} placeholder="Type to filter..." />
diff --git a/src/hooks/useSystemMetrics.ts b/src/hooks/useSystemMetrics.ts index 6273adb..94114fc 100644 --- a/src/hooks/useSystemMetrics.ts +++ b/src/hooks/useSystemMetrics.ts @@ -3,6 +3,11 @@ import type { SystemMetrics } from '../types'; const API_URL = 'http://localhost:3001/api/metrics'; +const API_KEY = import.meta.env.VITE_BTOP_API_KEY; +const MAX_RETRIES = 10; +const BASE_DELAY = 1000; +const MAX_DELAY = 30000; + interface UseSystemMetricsResult { metrics: SystemMetrics | null; error: string | null; @@ -14,18 +19,32 @@ export function useSystemMetrics(refreshRate: number): UseSystemMetricsResult { const [metrics, setMetrics] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [retryCount, setRetryCount] = useState(0); const fetchMetrics = useCallback(async () => { try { - const response = await fetch(API_URL); + const response = await fetch(API_URL, { + headers: API_KEY ? { + 'Authorization': `Bearer ${API_KEY}`, + } : {}, + }); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } const data = await response.json(); setMetrics(data); setError(null); + setRetryCount(0); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch metrics'); + setRetryCount(prev => { + const next = prev + 1; + if (next < MAX_RETRIES) { + const delay = Math.min(BASE_DELAY * 2 ** prev, MAX_DELAY); + setTimeout(fetchMetrics, delay); + } + return next; + }); } finally { setLoading(false); }