Skip to content
Open
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
66 changes: 66 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,62 @@ async function getMemoryInfo(): Promise<{ total: number; free: number; used: num
};
}

interface DiskInfo {
filesystem: string;
size: string;
used: string;
available: string;
use_percent: number;
mounted: string;
}

async function getDiskInfo(): Promise<DiskInfo[]> {
const os = platform();
let cmd: string;

if (os === "darwin") {
cmd = "df -h";
} else {
cmd = "df -h";
}

try {
const { stdout } = await execAsync(cmd);
const lines = stdout.trim().split("\n");
const diskList: DiskInfo[] = [];

// skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.trim().split(/\s+/);

if (parts.length >= 6) {
const filesystem = parts[0];
// filter out virtual filesystems
if (filesystem.startsWith("/dev") || filesystem.includes("disk")) {
const percentStr = parts[4].replace("%", "");
const percent = parseInt(percentStr);

diskList.push({
filesystem: filesystem,
size: parts[1],
used: parts[2],
available: parts[3],
use_percent: percent,
mounted: parts[5],
});
Comment on lines +257 to +274
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Preserve mount paths that contain spaces.

Splitting on whitespace and using parts[5] truncates mountpoints with spaces (e.g., “/Volumes/My Drive”). Consider joining the tail for the mount path.

🛠️ Suggested parsing fix
-        if (parts.length >= 6) {
-          const filesystem = parts[0];
+        if (parts.length >= 6) {
+          const filesystem = parts[0];
+          const mounted = parts.slice(5).join(" ");
           const percentStr = parts[4].replace("%", "");
           const percent = parseInt(percentStr);
 
           diskList.push({
             filesystem: filesystem,
             size: parts[1],
             used: parts[2],
             available: parts[3],
             use_percent: percent,
-            mounted: parts[5],
+            mounted: mounted,
           });
         }
🤖 Prompt for AI Agents
In `@server/index.ts` around lines 257 - 274, The current parsing splits df output
on whitespace and uses parts[5] for the mount path, which truncates mountpoints
containing spaces; update the parsing in the block that builds diskList
(variables: parts, filesystem, percentStr, percent, mounted) to construct the
mount path from the tail of the parts array (e.g., mounted =
parts.slice(5).join(' ')), keep percent parsing from parts[4] as-is but validate
parseInt result, and then push the disk object with the joined mounted path so
full mount paths like "/Volumes/My Drive" are preserved.

}
Comment on lines +262 to +275
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Return all mounted filesystems; current filter drops valid entries.

The PR objective says “all mounted filesystems,” but filtering to /dev or names containing "disk" will skip mounts like NFS/SMB, tmpfs, overlay, etc. Consider removing the filter (or replacing with a clear allow/deny list based on filesystem type) so the panel reflects all mounts.

💡 Suggested adjustment
-        // filter out virtual filesystems
-        if (filesystem.startsWith("/dev") || filesystem.includes("disk")) {
-          const percentStr = parts[4].replace("%", "");
-          const percent = parseInt(percentStr);
-
-          diskList.push({
-            filesystem: filesystem,
-            size: parts[1],
-            used: parts[2],
-            available: parts[3],
-            use_percent: percent,
-            mounted: parts[5],
-          });
-        }
+        const percentStr = parts[4].replace("%", "");
+        const percent = parseInt(percentStr);
+
+        diskList.push({
+          filesystem: filesystem,
+          size: parts[1],
+          used: parts[2],
+          available: parts[3],
+          use_percent: percent,
+          mounted: parts[5],
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// filter out virtual filesystems
if (filesystem.startsWith("/dev") || filesystem.includes("disk")) {
const percentStr = parts[4].replace("%", "");
const percent = parseInt(percentStr);
diskList.push({
filesystem: filesystem,
size: parts[1],
used: parts[2],
available: parts[3],
use_percent: percent,
mounted: parts[5],
});
}
const percentStr = parts[4].replace("%", "");
const percent = parseInt(percentStr);
diskList.push({
filesystem: filesystem,
size: parts[1],
used: parts[2],
available: parts[3],
use_percent: percent,
mounted: parts[5],
});
🤖 Prompt for AI Agents
In `@server/index.ts` around lines 262 - 275, The current conditional that filters
mounted filesystems (the if guarding the diskList.push block that checks
filesystem.startsWith("/dev") || filesystem.includes("disk")) is excluding valid
mounts; remove that filter so every parsed mount entry is pushed into diskList
(or replace it with an explicit allow/deny based on filesystem type if you
prefer). Locate the block that builds diskList using variables filesystem,
parts, percentStr/percent, and mounted, and either delete the surrounding if
(...) { ... } or change it to check an explicit whitelist/blacklist of
filesystem types before calling diskList.push so all intended mounts are
returned.

}
}

console.log("Disk info fetched:", diskList.length, "disks found");
return diskList;
} catch (error) {
console.error("Error getting disk info:", error);
return [];
}
}

async function getSystemMetrics(): Promise<SystemMetrics> {
const cpuInfo = cpus();
const memInfo = await getMemoryInfo();
Expand Down Expand Up @@ -287,6 +343,16 @@ const server = Bun.serve({
});
}

if (url.pathname === "/api/disks") {
const diskInfo = await getDiskInfo();
return new Response(JSON.stringify({ disks: diskInfo }), {
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
});
}

if (url.pathname === "/api/environment") {
// Return filtered environment variables for system diagnostics
// Only expose safe, non-sensitive variables
Expand Down
107 changes: 107 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -704,3 +704,110 @@ body {
max-height: 200px;
}
}

/* Disk Usage Panel */
.disk-usage {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
grid-column: span 2;
}
Comment on lines +708 to +716
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/App.css | sed -n '700,720p'

Repository: coderabbit-demo/btop

Length of output: 561


🏁 Script executed:

# Search for metrics-panel and related grid definitions
rg -A 5 -B 5 "metrics-panel|disk-usage" src/App.css | head -100

Repository: coderabbit-demo/btop

Length of output: 725


🏁 Script executed:

# Look for media queries that might affect the grid layout
rg "max-width.*1200px|@media" src/App.css | head -20

Repository: coderabbit-demo/btop

Length of output: 120


🏁 Script executed:

# Check the full context and structure around disk-usage
rg -B 15 "\.disk-usage" src/App.css | head -50

Repository: coderabbit-demo/btop

Length of output: 450


🏁 Script executed:

# Look for any additional disk-usage rules or media query overrides
rg "\.disk-usage" src/App.css

Repository: coderabbit-demo/btop

Length of output: 97


🏁 Script executed:

# Check the 800px media query context to see if disk-usage needs adjustment there too
rg -A 20 "max-width.*800px" src/App.css

Repository: coderabbit-demo/btop

Length of output: 340


Use grid-column: 1 / -1 for responsive full-width spanning.

When .metrics-panel switches to a single column at max-width 1200px, the current grid-column: span 2 creates an implicit second column, causing unintended layout behavior. Using grid-column: 1 / -1 spans all available columns without creating implicit ones, providing proper responsive behavior across all breakpoints.

🎛️ Suggested fix
 .disk-usage {
   background: var(--bg-card);
   border: 1px solid var(--border-color);
   border-radius: 12px;
   padding: 16px;
   box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
-  grid-column: span 2;
+  grid-column: 1 / -1;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/* Disk Usage Panel */
.disk-usage {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
grid-column: span 2;
}
/* Disk Usage Panel */
.disk-usage {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
grid-column: 1 / -1;
}
🤖 Prompt for AI Agents
In `@src/App.css` around lines 708 - 716, The .disk-usage rule uses grid-column:
span 2 which can create an implicit column and break layouts when .metrics-panel
collapses to one column; update the .disk-usage CSS to use grid-column: 1 / -1
so it spans the full row across all breakpoints (replace grid-column: span 2
with grid-column: 1 / -1 in the .disk-usage selector) to avoid implicit columns
and ensure correct responsive behavior.


.disk-usage.error {
color: var(--color-red);
}

.disk-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}

.disk-title {
color: var(--color-purple);
font-weight: 700;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 2px;
}

.disk-total {
font-size: 14px;
font-weight: 600;
}

.disk-list {
display: flex;
flex-direction: column;
gap: 8px;
}

.disk-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}

.disk-info {
display: flex;
flex-direction: column;
min-width: 140px;
}

.disk-mount {
color: var(--color-cyan);
font-weight: 600;
font-size: 11px;
}

.disk-size {
color: var(--color-text-dim);
font-size: 10px;
}

.disk-bar-container {
flex: 1;
height: 6px;
background: rgba(0, 0, 0, 0.4);
border-radius: 3px;
overflow: hidden;
}

.disk-bar {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}

.disk-percent {
font-weight: 600;
font-size: 12px;
min-width: 40px;
text-align: right;
}

.disk-expand-btn {
display: block;
width: 100%;
margin-top: 10px;
padding: 8px;
background: rgba(167, 139, 250, 0.1);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--color-purple);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}

.disk-expand-btn:hover {
background: rgba(167, 139, 250, 0.2);
border-color: var(--color-purple);
}
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MemoryGraph } from './components/MemoryGraph';
import { ProcessTable } from './components/ProcessTable';
import { StatusBar } from './components/StatusBar';
import { EnvironmentPanel } from './components/EnvironmentPanel';
import { DiskUsage } from './components/DiskUsage';
import { useSystemMetrics } from './hooks/useSystemMetrics';
import './App.css';

Expand Down Expand Up @@ -59,6 +60,7 @@ function App() {
total={metrics.totalMem}
percent={metrics.memPercent}
/>
<DiskUsage refreshRate={refreshRate} />
</div>

<div className="process-section compact">
Expand Down
157 changes: 157 additions & 0 deletions src/components/DiskUsage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';

interface DiskInfo {
filesystem: string;
size: string;
used: string;
available: string;
use_percent: number;
mounted: string;
}

interface DiskUsageProps {
refreshRate: number;
}

export function DiskUsage({ refreshRate }: DiskUsageProps) {
const [disks, setDisks] = useState<DiskInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);

useEffect(() => {
const fetchDiskInfo = async () => {
try {
const response = await fetch('http://localhost:3001/api/disks');
if (!response.ok) {
throw new Error('Failed to fetch disk info');
}
const data = await response.json();
setDisks(data.disks);
setLoading(false);
setError(null);
} catch (err) {
console.log('Error fetching disk info:', err);
setError('Failed to load disk info');
setLoading(false);
}
};

fetchDiskInfo();
const interval = setInterval(fetchDiskInfo, refreshRate);
return () => clearInterval(interval);
}, [refreshRate]);
Comment on lines +22 to +43
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hardcoded URL and missing fetch abort.

  1. The hardcoded http://localhost:3001 URL will break in production. Consider using an environment variable or relative URL with a proxy configuration.
  2. Use console.error instead of console.log for error logging (line 34).
  3. Consider adding an AbortController to cancel in-flight requests when the component unmounts or refreshRate changes, preventing state updates on unmounted components.
Suggested improvements
 useEffect(() => {
+   const controller = new AbortController();
+
    const fetchDiskInfo = async () => {
      try {
-       const response = await fetch('http://localhost:3001/api/disks');
+       const response = await fetch('/api/disks', { signal: controller.signal });
        if (!response.ok) {
          throw new Error('Failed to fetch disk info');
        }
        const data = await response.json();
        setDisks(data.disks);
        setLoading(false);
        setError(null);
      } catch (err) {
-       console.log('Error fetching disk info:', err);
-       setError('Failed to load disk info');
-       setLoading(false);
+       if (err instanceof Error && err.name !== 'AbortError') {
+         console.error('Error fetching disk info:', err);
+         setError('Failed to load disk info');
+         setLoading(false);
+       }
      }
    };

    fetchDiskInfo();
    const interval = setInterval(fetchDiskInfo, refreshRate);
-   return () => clearInterval(interval);
+   return () => {
+     controller.abort();
+     clearInterval(interval);
+   };
  }, [refreshRate]);
🤖 Prompt for AI Agents
In @src/components/DiskUsage.tsx around lines 22 - 43, Replace the hardcoded
fetch URL in fetchDiskInfo with a configurable one (use a relative path like
'/api/disks' or derive from an env var) so it works in production, change
console.log to console.error when logging errors, and add an AbortController
inside the effect: pass controller.signal to fetch, catch and ignore AbortError
(or skip setDisks/setLoading/setError when err.name === 'AbortError'), and call
controller.abort() in the cleanup along with clearInterval; keep using the
existing fetchDiskInfo, setDisks, setLoading, setError, and refreshRate
identifiers so changes are localized.


// Get color based on usage percentage
const getUsageColor = (percent: number) => {
if (percent < 50) {
return '#34d399';
} else if (percent < 75) {
return '#fbbf24';
} else if (percent < 90) {
return '#fb923c';
} else {
return '#f87171';
}
};

// Calculate total disk stats
const calculateTotals = () => {
let totalUsed = 0;
let totalSize = 0;

for (let i = 0; i < disks.length; i++) {
const disk = disks[i];
// Parse size strings to get numeric values
const sizeNum = parseSize(disk.size);
const usedNum = parseSize(disk.used);
totalSize = totalSize + sizeNum;
totalUsed = totalUsed + usedNum;
}

return { totalUsed, totalSize };
};

// TODO: Move this to a utility file
const parseSize = (sizeStr: string): number => {
const match = sizeStr.match(/^([\d.]+)([KMGTP]?)i?$/i);
if (!match) return 0;

const value = parseFloat(match[1]);
const unit = match[2].toUpperCase();

const multipliers: { [key: string]: number } = {
'': 1,
'K': 1024,
'M': 1024 * 1024,
'G': 1024 * 1024 * 1024,
'T': 1024 * 1024 * 1024 * 1024,
'P': 1024 * 1024 * 1024 * 1024 * 1024,
};

return value * (multipliers[unit] || 1);
};

const formatTotalSize = (bytes: number): string => {
if (bytes == 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

if (loading) {
return <div className="disk-usage">Loading disk info...</div>;
}

if (error) {
return <div className="disk-usage error">{error}</div>;
}

const totals = calculateTotals();
const totalPercent = totals.totalSize > 0 ? Math.round((totals.totalUsed / totals.totalSize) * 100) : 0;
const displayDisks = expanded ? disks : disks.slice(0, 3);

return (
<div className="disk-usage">
<div className="disk-header">
<span className="disk-title">DISK</span>
<span className="disk-total" style={{ color: getUsageColor(totalPercent) }}>
{formatTotalSize(totals.totalUsed)} / {formatTotalSize(totals.totalSize)} ({totalPercent}%)
</span>
</div>

<div className="disk-list">
{displayDisks.map((disk, index) => (
<div key={index} className="disk-item">
<div className="disk-info">
<span className="disk-mount">{disk.mounted}</span>
<span className="disk-size">{disk.used} / {disk.size}</span>
</div>
<div className="disk-bar-container">
<div
className="disk-bar"
style={{
width: `${disk.use_percent}%`,
backgroundColor: getUsageColor(disk.use_percent)
}}
/>
</div>
<span className="disk-percent" style={{ color: getUsageColor(disk.use_percent) }}>
{disk.use_percent}%
</span>
</div>
))}
</div>

{disks.length > 3 && (
<button
className="disk-expand-btn"
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Show less' : `Show all (${disks.length})`}
</button>
)}
</div>
);
}