mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
Add SpoolBuddy System tab with live OS stats from Raspberry Pi
The daemon now collects CPU temp, core count, load average, memory/disk usage, OS info, and system uptime every heartbeat using stdlib-only reads from /proc and /sys. Stats are sent as a JSON blob in the heartbeat payload, stored in a new system_stats TEXT column, and displayed in a new "System" tab in SpoolBuddy Settings with color-coded usage bars.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Tests for SpoolBuddySettingsPage:
|
||||
* - Renders 4 tabs (Device, Display, Scale, Updates)
|
||||
* - Renders 5 tabs (Device, Display, Scale, Updates, System)
|
||||
* - Device tab shows hostname, IP, NFC status
|
||||
* - Updates tab shows "Check for Updates" button
|
||||
* - System tab shows OS stats
|
||||
* - Tab switching works
|
||||
*/
|
||||
|
||||
@@ -39,6 +40,15 @@ vi.mock('../../api/client', () => ({
|
||||
uptime_s: 3600,
|
||||
update_status: null,
|
||||
update_message: null,
|
||||
system_stats: {
|
||||
os: { os: 'Raspbian GNU/Linux 12', kernel: '6.1.0-rpi7', arch: 'aarch64', python: '3.11.2' },
|
||||
cpu_temp_c: 52.1,
|
||||
cpu_count: 4,
|
||||
load_avg: [0.15, 0.22, 0.18],
|
||||
memory: { total_mb: 1024, available_mb: 512, used_mb: 512, percent: 50.0 },
|
||||
disk: { total_gb: 29.7, used_gb: 8.2, free_gb: 21.5, percent: 27.6 },
|
||||
system_uptime_s: 86400,
|
||||
},
|
||||
online: true,
|
||||
}]),
|
||||
updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
@@ -106,13 +116,14 @@ describe('SpoolBuddySettingsPage', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders 4 tabs', async () => {
|
||||
it('renders 5 tabs', async () => {
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Device')).toBeDefined();
|
||||
expect(screen.getByText('Display')).toBeDefined();
|
||||
expect(screen.getByText('Scale')).toBeDefined();
|
||||
expect(screen.getByText('Updates')).toBeDefined();
|
||||
expect(screen.getByText('System')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,4 +222,92 @@ describe('SpoolBuddySettingsPage', () => {
|
||||
expect(screen.getByText('Calibrate')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('System tab shows CPU info', async () => {
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System')).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText('System'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CPU')).toBeDefined();
|
||||
expect(screen.getByText('4')).toBeDefined(); // cpu_count
|
||||
expect(screen.getByText('0.15 / 0.22 / 0.18')).toBeDefined(); // load_avg
|
||||
});
|
||||
});
|
||||
|
||||
it('System tab shows memory stats', async () => {
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System')).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText('System'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory')).toBeDefined();
|
||||
expect(screen.getByText('512 / 1024 MB')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('System tab shows disk stats', async () => {
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System')).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText('System'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disk')).toBeDefined();
|
||||
expect(screen.getByText('8.2 GB')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('System tab shows OS info', async () => {
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System')).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText('System'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Raspbian GNU/Linux 12')).toBeDefined();
|
||||
expect(screen.getByText('aarch64')).toBeDefined();
|
||||
expect(screen.getByText('3.11.2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('System tab shows waiting message when no stats', async () => {
|
||||
const { spoolbuddyApi } = await import('../../api/client');
|
||||
vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([{
|
||||
id: 1,
|
||||
device_id: 'sb-test-001',
|
||||
hostname: 'spoolbuddy-pi',
|
||||
ip_address: '192.168.1.100',
|
||||
firmware_version: '1.2.3',
|
||||
has_nfc: true,
|
||||
has_scale: true,
|
||||
tare_offset: 0,
|
||||
calibration_factor: 1.0,
|
||||
nfc_reader_type: 'PN532',
|
||||
nfc_connection: 'I2C',
|
||||
display_brightness: 80,
|
||||
display_blank_timeout: 300,
|
||||
has_backlight: true,
|
||||
last_calibrated_at: null,
|
||||
last_seen: '2026-03-22T12:00:00Z',
|
||||
pending_command: null,
|
||||
nfc_ok: true,
|
||||
scale_ok: true,
|
||||
uptime_s: 3600,
|
||||
update_status: null,
|
||||
update_message: null,
|
||||
system_stats: null,
|
||||
online: true,
|
||||
}]);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System')).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText('System'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Waiting for system stats...')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5016,6 +5016,15 @@ export interface SpoolBuddyDevice {
|
||||
uptime_s: number;
|
||||
update_status: string | null;
|
||||
update_message: string | null;
|
||||
system_stats: {
|
||||
os?: { os?: string; kernel?: string; arch?: string; python?: string };
|
||||
cpu_temp_c?: number;
|
||||
cpu_count?: number;
|
||||
load_avg?: number[];
|
||||
memory?: { total_mb?: number; available_mb?: number; used_mb?: number; percent?: number };
|
||||
disk?: { total_gb?: number; used_gb?: number; free_gb?: number; percent?: number };
|
||||
system_uptime_s?: number;
|
||||
} | null;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -784,9 +784,172 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- System Tab ---
|
||||
|
||||
function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||
return (
|
||||
<div className="w-full h-2 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${color}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSystemUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function SystemTab({ device }: { device: SpoolBuddyDevice }) {
|
||||
const { t } = useTranslation();
|
||||
const stats = device.system_stats;
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{t('spoolbuddy.settings.systemStatsWaiting', 'Waiting for system stats...')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mem = stats.memory;
|
||||
const disk = stats.disk;
|
||||
const tempColor = (stats.cpu_temp_c ?? 0) >= 80 ? 'text-red-400' : (stats.cpu_temp_c ?? 0) >= 65 ? 'text-amber-400' : 'text-green-400';
|
||||
const memColor = (mem?.percent ?? 0) >= 90 ? 'bg-red-500' : (mem?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';
|
||||
const diskColor = (disk?.percent ?? 0) >= 90 ? 'bg-red-500' : (disk?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* CPU + Temp side by side */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-zinc-800 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-300 mb-2">CPU</h3>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.cores', 'Cores')}</span>
|
||||
<span className="text-zinc-300 font-mono">{stats.cpu_count ?? '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.loadAvg', 'Load Avg')}</span>
|
||||
<span className="text-zinc-300 font-mono">
|
||||
{stats.load_avg ? stats.load_avg.join(' / ') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.temp', 'Temp')}</span>
|
||||
<span className={`font-mono font-medium ${tempColor}`}>
|
||||
{stats.cpu_temp_c != null ? `${stats.cpu_temp_c}\u00B0C` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory */}
|
||||
<div className="bg-zinc-800 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-300 mb-2">
|
||||
{t('spoolbuddy.settings.memory', 'Memory')}
|
||||
</h3>
|
||||
{mem ? (
|
||||
<div className="space-y-2">
|
||||
<UsageBar percent={mem.percent ?? 0} color={memColor} />
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.used', 'Used')}</span>
|
||||
<span className="text-zinc-300 font-mono">{mem.used_mb} / {mem.total_mb} MB</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.available', 'Free')}</span>
|
||||
<span className="text-zinc-300 font-mono">{mem.available_mb} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk */}
|
||||
<div className="bg-zinc-800 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-300 mb-2">
|
||||
{t('spoolbuddy.settings.disk', 'Disk')}
|
||||
</h3>
|
||||
{disk ? (
|
||||
<div className="space-y-2">
|
||||
<UsageBar percent={disk.percent ?? 0} color={diskColor} />
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-zinc-500 block">{t('spoolbuddy.settings.used', 'Used')}</span>
|
||||
<span className="text-zinc-300 font-mono">{disk.used_gb} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-zinc-500 block">{t('spoolbuddy.settings.available', 'Free')}</span>
|
||||
<span className="text-zinc-300 font-mono">{disk.free_gb} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-zinc-500 block">{t('spoolbuddy.settings.total', 'Total')}</span>
|
||||
<span className="text-zinc-300 font-mono">{disk.total_gb} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OS Info */}
|
||||
<div className="bg-zinc-800 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-300 mb-2">
|
||||
{t('spoolbuddy.settings.osInfo', 'OS')}
|
||||
</h3>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
{stats.os?.os && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.distro', 'Distro')}</span>
|
||||
<span className="text-zinc-300 truncate ml-2">{stats.os.os}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.os?.kernel && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.kernel', 'Kernel')}</span>
|
||||
<span className="text-zinc-300 font-mono truncate ml-2">{stats.os.kernel}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.os?.arch && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.arch', 'Arch')}</span>
|
||||
<span className="text-zinc-300 font-mono">{stats.os.arch}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.os?.python && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Python</span>
|
||||
<span className="text-zinc-300 font-mono">{stats.os.python}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.system_uptime_s != null && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.settings.systemUptime', 'System Uptime')}</span>
|
||||
<span className="text-zinc-300">{formatSystemUptime(stats.system_uptime_s)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Settings Page ---
|
||||
|
||||
type SettingsTab = 'device' | 'display' | 'scale' | 'updates';
|
||||
type SettingsTab = 'device' | 'display' | 'scale' | 'updates' | 'system';
|
||||
|
||||
export function SpoolBuddySettingsPage() {
|
||||
const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
|
||||
@@ -810,6 +973,7 @@ export function SpoolBuddySettingsPage() {
|
||||
{ id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
|
||||
{ id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },
|
||||
{ id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },
|
||||
{ id: 'system', label: t('spoolbuddy.settings.tabSystem', 'System') },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -862,6 +1026,7 @@ export function SpoolBuddySettingsPage() {
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'updates' && <UpdatesTab device={device} />}
|
||||
{activeTab === 'system' && <SystemTab device={device} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user