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:
maziggy
2026-03-26 09:57:09 +01:00
parent 1df5130df2
commit 77cb7158d2
14 changed files with 974 additions and 427 deletions
@@ -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();
});
});
});
+9
View File
@@ -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>