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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -470,6 +570,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);
Expand Down
22 changes: 20 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react';
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';
Expand All @@ -11,7 +12,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 (
Expand Down Expand Up @@ -43,13 +57,15 @@ function App() {

return (
<div className="app">
<Navbar />
<Header
hostname={metrics.hostname}
platform={metrics.platform}
arch={metrics.arch}
uptime={metrics.uptime}
loadAvg={metrics.loadAvg}
processCount={metrics.processCount}
paused={paused}
/>

<div className="metrics-panel">
Expand All @@ -76,6 +92,8 @@ function App() {
onFilterChange={setFilter}
refreshRate={refreshRate}
onRefreshRateChange={setRefreshRate}
paused={paused}
onPauseToggle={() => setPaused(prev => !prev)}
/>
</div>
);
Expand Down
16 changes: 15 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface HeaderProps {
uptime: number;
loadAvg: number[];
processCount: number;
paused?: boolean;
}

function formatUptime(seconds: number): string {
Expand All @@ -22,9 +23,10 @@ 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);
const [adminMode, setAdminMode] = useState(false);

useEffect(() => {
const interval = setInterval(() => {
Expand All @@ -33,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));
Comment on lines +40 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing AbortController and error handling for non-JSON response.

The fetch lacks:

  1. An AbortController to cancel on unmount
  2. Proper handling if res.json() fails on non-JSON responses (404 returns plain text "Not Found")
Proposed fix with proper cleanup and error handling
   useEffect(() => {
+    const controller = new AbortController();
-    fetch('/api/auth/me', { credentials: 'include' })
+    fetch('/api/auth/me', { credentials: 'include', signal: controller.signal })
       .then(res => res.ok ? res.json() : null)
       .then(data => {
         if (data?.isAdmin) setAdminMode(true);
       })
-      .catch(() => setAdminMode(false));
+      .catch(() => {});
+    return () => controller.abort();
   }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Header.tsx` around lines 40 - 45, The fetch to '/api/auth/me'
in the Header component should use an AbortController for cleanup and robustly
handle non-JSON responses: create an AbortController, pass its signal to fetch,
and on cleanup call controller.abort(); after fetch, if !res.ok throw or set
admin false, and before calling res.json() verify
res.headers.get('content-type') includes 'application/json' (or wrap res.json()
in try/catch) so a plain-text 404 doesn't throw; catch all errors (including
AbortError) and call setAdminMode(false) only for non-abort failures or simply
ensure aborts don't trigger state updates.

}, []);
Comment on lines +38 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that /api/auth/me is not implemented in the server
echo "=== Searching for auth/me route in server code ==="
rg -n "auth/me" --type ts

echo ""
echo "=== All route definitions in server ==="
rg -n "url\.pathname ===" --type ts

Repository: coderabbit-demo/btop

Length of output: 430


The /api/auth/me endpoint does not exist on the server.

The server only implements routes for /api/metrics, /api/health, and /api/environment (server/index.ts:271-290). This fetch will always receive a 404 response, so res.ok will be false and adminMode will never be set to true.

Either implement the /api/auth/me endpoint on the server, or remove this dead code until the auth feature is ready.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Header.tsx` around lines 38 - 46, The useEffect in Header.tsx
calls fetch('/api/auth/me') which will always 404 because the server only
exposes /api/metrics, /api/health and /api/environment; either implement the
missing auth endpoint on the server (add a handler that returns { isAdmin:
boolean } for GET /api/auth/me) or remove the client-side code that references
fetch('/api/auth/me')/useEffect and the setAdminMode/adminMode logic until auth
exists; locate the useEffect block in Header.tsx (the fetch('/api/auth/me')
call, useEffect, setAdminMode and adminMode) and either add a corresponding
server route that returns the expected JSON or delete/guard the client code to
avoid the dead fetch.


return (
<div className="header">
<div className="header-left">
Expand All @@ -54,6 +66,8 @@ export function Header({ hostname, platform, arch, uptime, loadAvg, processCount
</span>
</div>
<div className="header-right">
{adminMode && <span style={{ color: 'var(--color-red)' }}>ADMIN</span>}
{paused && <span className="paused-indicator">PAUSED</span>}
<span className="load-avg">
Load: <span className="value">{loadAvg.map(l => l.toFixed(2)).join(' ')}</span>
</span>
Expand Down
97 changes: 48 additions & 49 deletions src/components/ProcessTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState } from 'react';
import type { ProcessInfo, SortField, SortDirection } from '../types';

interface ProcessTableProps {
Expand All @@ -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 ' ';
Expand Down
Loading
Loading