SystemInfoPage.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. import { useState } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Server,
  6. Database,
  7. HardDrive,
  8. Cpu,
  9. MemoryStick,
  10. Printer,
  11. Archive,
  12. Clock,
  13. CheckCircle2,
  14. XCircle,
  15. Loader2,
  16. RefreshCw,
  17. Plug,
  18. FolderKanban,
  19. Palette,
  20. Bug,
  21. Download,
  22. Headphones,
  23. FolderOpen,
  24. Stethoscope,
  25. HeartPulse,
  26. } from 'lucide-react';
  27. import { api, supportApi, type Printer as PrinterModel } from '../api/client';
  28. import { useAuth } from '../contexts/AuthContext';
  29. import { Card } from '../components/Card';
  30. import { LogViewer } from '../components/LogViewer';
  31. import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
  32. import { SystemHealthPanel } from '../components/SystemHealthPanel';
  33. import { formatDateTime, type TimeFormat } from '../utils/date';
  34. import { useUiSettings } from '../hooks/useUiSettings';
  35. function formatBytes(bytes: number): string {
  36. if (bytes < 1024) return `${bytes} B`;
  37. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  38. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  39. return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
  40. }
  41. function StatCard({
  42. icon: Icon,
  43. label,
  44. value,
  45. subValue,
  46. color = 'text-bambu-green',
  47. }: {
  48. icon: React.ElementType;
  49. label: string;
  50. value: string | number;
  51. subValue?: string;
  52. color?: string;
  53. }) {
  54. return (
  55. <div className="flex items-start gap-3 p-4 bg-bambu-dark rounded-lg">
  56. <div className={`p-2 rounded-lg bg-bambu-dark-tertiary ${color}`}>
  57. <Icon className="w-5 h-5" />
  58. </div>
  59. <div className="flex-1 min-w-0">
  60. <p className="text-sm text-bambu-gray">{label}</p>
  61. <p className="text-lg font-semibold text-white truncate">{value}</p>
  62. {subValue && <p className="text-xs text-bambu-gray mt-0.5">{subValue}</p>}
  63. </div>
  64. </div>
  65. );
  66. }
  67. function ProgressBar({ percent, color = 'bg-bambu-green' }: { percent: number; color?: string }) {
  68. return (
  69. <div className="w-full h-2 bg-bambu-dark rounded-full overflow-hidden">
  70. <div
  71. className={`h-full ${color} transition-all duration-300`}
  72. style={{ width: `${Math.min(100, percent)}%` }}
  73. />
  74. </div>
  75. );
  76. }
  77. function Section({
  78. title,
  79. icon: Icon,
  80. children,
  81. }: {
  82. title: string;
  83. icon: React.ElementType;
  84. children: React.ReactNode;
  85. }) {
  86. return (
  87. <Card className="p-6">
  88. <div className="flex items-center gap-2 mb-4">
  89. <Icon className="w-5 h-5 text-bambu-green" />
  90. <h2 className="text-lg font-semibold text-white">{title}</h2>
  91. </div>
  92. {children}
  93. </Card>
  94. );
  95. }
  96. export function SystemInfoPage() {
  97. const { t } = useTranslation();
  98. const { hasPermission } = useAuth();
  99. // The logs and the bundle need settings:update, as debug logging does.
  100. const canUseSupportTools = hasPermission('settings:update');
  101. const queryClient = useQueryClient();
  102. const [bundleError, setBundleError] = useState<string | null>(null);
  103. const [bundleDownloading, setBundleDownloading] = useState(false);
  104. const [debugToggling, setDebugToggling] = useState(false);
  105. const [diagnosticPrinter, setDiagnosticPrinter] = useState<PrinterModel | null>(null);
  106. const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({
  107. queryKey: ['systemInfo'],
  108. queryFn: api.getSystemInfo,
  109. refetchInterval: 30000, // Auto-refresh every 30 seconds
  110. });
  111. const { data: debugLoggingState } = useQuery({
  112. queryKey: ['debugLogging'],
  113. queryFn: supportApi.getDebugLoggingState,
  114. staleTime: 10 * 1000, // 10 seconds
  115. refetchInterval: 10 * 1000,
  116. });
  117. const { data: settings } = useUiSettings();
  118. const { data: libraryStats } = useQuery({
  119. queryKey: ['library-stats'],
  120. queryFn: api.getLibraryStats,
  121. });
  122. const { data: allPrinters } = useQuery({
  123. queryKey: ['printers'],
  124. queryFn: api.getPrinters,
  125. });
  126. const {
  127. data: systemHealth,
  128. refetch: refetchHealth,
  129. isFetching: healthFetching,
  130. } = useQuery({
  131. queryKey: ['systemHealth'],
  132. queryFn: api.getSystemHealth,
  133. staleTime: 60 * 1000,
  134. });
  135. const timeFormat: TimeFormat = settings?.time_format || 'system';
  136. const handleToggleDebugLogging = async () => {
  137. setDebugToggling(true);
  138. try {
  139. const newState = await supportApi.setDebugLogging(!debugLoggingState?.enabled);
  140. // Immediately update the cache with the new state (includes fresh enabled_at timestamp)
  141. queryClient.setQueryData(['debugLogging'], newState);
  142. } catch (err) {
  143. console.error('Failed to toggle debug logging:', err);
  144. } finally {
  145. setDebugToggling(false);
  146. }
  147. };
  148. const handleDownloadBundle = async () => {
  149. setBundleError(null);
  150. setBundleDownloading(true);
  151. try {
  152. await supportApi.downloadSupportBundle();
  153. } catch (err) {
  154. setBundleError(err instanceof Error ? err.message : 'Failed to download support bundle');
  155. } finally {
  156. setBundleDownloading(false);
  157. }
  158. };
  159. if (isLoading) {
  160. return (
  161. <div className="flex items-center justify-center h-64">
  162. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  163. </div>
  164. );
  165. }
  166. if (!systemInfo) {
  167. return (
  168. <div className="p-6 text-center text-bambu-gray">
  169. {t('system.failedToLoad', 'Failed to load system information')}
  170. </div>
  171. );
  172. }
  173. const diskColor =
  174. systemInfo.storage.disk_percent_used > 90
  175. ? 'bg-red-500'
  176. : systemInfo.storage.disk_percent_used > 75
  177. ? 'bg-yellow-500'
  178. : 'bg-bambu-green';
  179. const memoryColor =
  180. systemInfo.memory.percent_used > 90
  181. ? 'bg-red-500'
  182. : systemInfo.memory.percent_used > 75
  183. ? 'bg-yellow-500'
  184. : 'bg-bambu-green';
  185. return (
  186. <div className="p-6 space-y-6">
  187. {/* Header. Same stacking as the Spool Inventory header (#2813) -- one
  188. button rather than five, so it only just overflows at 390px, but the
  189. overflow is the same. items-start keeps Refresh at its own width
  190. while stacked instead of stretching it across the page. */}
  191. <div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
  192. <div>
  193. <h1 className="text-2xl font-bold text-white">{t('system.title', 'System Information')}</h1>
  194. <p className="text-bambu-gray mt-1">
  195. {t('system.subtitle', 'Monitor system resources and database statistics')}
  196. </p>
  197. </div>
  198. <button
  199. onClick={() => refetch()}
  200. disabled={isFetching}
  201. className="flex items-center gap-2 px-4 py-2 bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
  202. >
  203. <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
  204. {t('common.refresh', 'Refresh')}
  205. </button>
  206. </div>
  207. {/* Application Info */}
  208. <Section title={t('system.application', 'Application')} icon={Server}>
  209. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  210. <StatCard
  211. icon={Server}
  212. label={t('system.version', 'Version')}
  213. value={`v${systemInfo.app.version}`}
  214. />
  215. <StatCard
  216. icon={Clock}
  217. label={t('system.uptime', 'System Uptime')}
  218. value={systemInfo.system.uptime_formatted}
  219. />
  220. <StatCard
  221. icon={Server}
  222. label={t('system.hostname', 'Hostname')}
  223. value={systemInfo.system.hostname}
  224. />
  225. </div>
  226. </Section>
  227. {/* Support & Troubleshooting */}
  228. {canUseSupportTools && (
  229. <Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
  230. <div className="space-y-4">
  231. <p className="text-sm text-bambu-gray">
  232. {t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
  233. </p>
  234. {/* Debug Logging Toggle */}
  235. <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
  236. <div className="flex items-center gap-3">
  237. <div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
  238. <Bug className="w-5 h-5" />
  239. </div>
  240. <div>
  241. <p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
  242. <p className="text-sm text-bambu-gray">
  243. {debugLoggingState?.enabled
  244. ? t('support.debugLoggingEnabled', 'Capturing detailed logs')
  245. : t('support.debugLoggingDisabled', 'Normal logging level')}
  246. {debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
  247. <span className="text-amber-700 dark:text-amber-400 ml-2">
  248. ({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
  249. </span>
  250. )}
  251. </p>
  252. </div>
  253. </div>
  254. <button
  255. onClick={handleToggleDebugLogging}
  256. disabled={debugToggling}
  257. className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
  258. debugLoggingState?.enabled
  259. ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30'
  260. : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
  261. } disabled:opacity-50`}
  262. >
  263. {debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
  264. {debugLoggingState?.enabled
  265. ? t('support.disableDebug', 'Disable')
  266. : t('support.enableDebug', 'Enable')}
  267. </button>
  268. </div>
  269. {/* Support Bundle Download */}
  270. <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
  271. <div className="flex items-center gap-3">
  272. <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
  273. <Download className="w-5 h-5" />
  274. </div>
  275. <div>
  276. <p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
  277. <p className="text-sm text-bambu-gray">
  278. {t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
  279. </p>
  280. </div>
  281. </div>
  282. <button
  283. onClick={handleDownloadBundle}
  284. disabled={bundleDownloading || !debugLoggingState?.enabled}
  285. className="px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
  286. title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
  287. >
  288. {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
  289. {bundleDownloading
  290. ? t('support.bundleGenerating', 'Generating...')
  291. : t('common.download', 'Download')}
  292. </button>
  293. </div>
  294. {/* Progress indicator — bundle generation now runs connection +
  295. virtual-printer diagnostics and the log-health scan before
  296. writing the ZIP (#1506 follow-up), so the wait is longer than
  297. a pure file-export. List what's running so it's not opaque. */}
  298. {bundleDownloading && (
  299. <div className="p-3 bg-bambu-dark-tertiary/40 rounded-lg space-y-1">
  300. <p className="text-sm font-medium text-white flex items-center gap-2">
  301. <Loader2 className="w-3.5 h-3.5 animate-spin text-bambu-green" />
  302. {t('support.bundleGenerating', 'Generating...')}
  303. </p>
  304. <ul className="text-xs text-bambu-gray list-disc list-inside space-y-0.5 pl-1">
  305. <li>{t('support.bundleStepConnection', 'Running printer connectivity checks')}</li>
  306. <li>{t('support.bundleStepVirtualPrinters', 'Running virtual-printer setup checks')}</li>
  307. <li>{t('support.bundleStepLogScan', 'Scanning recent logs for known issues')}</li>
  308. <li>{t('support.bundleStepBuild', 'Building the support bundle ZIP')}</li>
  309. </ul>
  310. </div>
  311. )}
  312. {/* Error message */}
  313. {bundleError && (
  314. <div className="p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 dark:bg-red-500/20 dark:border-red-500/30 dark:text-red-400 text-sm">
  315. {bundleError}
  316. </div>
  317. )}
  318. {/* Instructions */}
  319. {!debugLoggingState?.enabled && (
  320. <div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
  321. <p className="text-sm text-bambu-gray">
  322. <span className="text-amber-700 dark:text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
  323. <br />
  324. 1. {t('support.step1', 'Enable debug logging')}
  325. <br />
  326. 2. {t('support.step2', 'Reproduce the issue')}
  327. <br />
  328. 3. {t('support.step3', 'Download the support bundle')}
  329. <br />
  330. 4. {t('support.step4', 'Attach the ZIP file to your issue report')}
  331. </p>
  332. </div>
  333. )}
  334. {/* Privacy Info */}
  335. <div className="p-4 bg-bambu-dark rounded-lg space-y-3">
  336. <p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
  337. <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
  338. <div>
  339. <p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
  340. <ul className="text-bambu-gray space-y-0.5">
  341. <li>• {t('support.collectItem1', 'App version and debug mode')}</li>
  342. <li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
  343. <li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
  344. <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
  345. <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
  346. <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
  347. <li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
  348. <li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
  349. <li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
  350. <li>• {t('support.collectItem10', 'Python package versions')}</li>
  351. <li>• {t('support.collectItem11', 'Database health checks')}</li>
  352. <li>• {t('support.collectItem12', 'Docker environment details')}</li>
  353. </ul>
  354. </div>
  355. <div>
  356. <p className="text-red-700 dark:text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
  357. <ul className="text-bambu-gray space-y-0.5">
  358. <li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>
  359. <li>• {t('support.notItem2', 'Access codes and passwords')}</li>
  360. <li>• {t('support.notItem3', 'Email addresses')}</li>
  361. <li>• {t('support.notItem4', 'API keys and tokens')}</li>
  362. <li>• {t('support.notItem5', 'Webhook URLs')}</li>
  363. <li>• {t('support.notItem6', 'Your hostname or username')}</li>
  364. <li>• {t('support.notItem7', 'IP addresses')}</li>
  365. </ul>
  366. </div>
  367. </div>
  368. <p className="text-xs text-bambu-gray/70">
  369. {t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].')}
  370. </p>
  371. </div>
  372. {/* Log Viewer */}
  373. <LogViewer />
  374. </div>
  375. </Section>
  376. )}
  377. {/* Connection Diagnostic */}
  378. <Section title={t('diagnostic.sectionTitle', 'Connection Diagnostic')} icon={Stethoscope}>
  379. <p className="text-sm text-bambu-gray mb-4">
  380. {t(
  381. 'diagnostic.sectionDescription',
  382. "Check why a printer won't connect or won't print — port reachability, LAN developer mode, Docker network mode, and credentials.",
  383. )}
  384. </p>
  385. {allPrinters && allPrinters.length > 0 ? (
  386. <div className="space-y-2">
  387. {allPrinters.map((printer) => (
  388. <div
  389. key={printer.id}
  390. className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
  391. >
  392. <div className="min-w-0">
  393. <span className="font-medium text-white">{printer.name}</span>
  394. <span className="text-sm text-bambu-gray ml-2">{printer.ip_address}</span>
  395. </div>
  396. <button
  397. onClick={() => setDiagnosticPrinter(printer)}
  398. className="flex items-center gap-2 px-3 py-1.5 text-sm bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white rounded-lg transition-colors flex-shrink-0"
  399. >
  400. <Stethoscope className="w-4 h-4" />
  401. {t('diagnostic.runButton', 'Run diagnostic')}
  402. </button>
  403. </div>
  404. ))}
  405. </div>
  406. ) : (
  407. <p className="text-bambu-gray">{t('diagnostic.noPrinters', 'No printers configured.')}</p>
  408. )}
  409. </Section>
  410. {/* System Health */}
  411. <Section title={t('systemHealth.sectionTitle')} icon={HeartPulse}>
  412. <div className="flex items-start justify-between gap-3 mb-3">
  413. <p className="text-sm text-bambu-gray">{t('systemHealth.sectionDescription')}</p>
  414. <button
  415. onClick={() => refetchHealth()}
  416. disabled={healthFetching}
  417. className="flex items-center gap-2 px-3 py-1.5 text-sm bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white rounded-lg transition-colors flex-shrink-0 disabled:opacity-50"
  418. >
  419. <RefreshCw className={`w-4 h-4 ${healthFetching ? 'animate-spin' : ''}`} />
  420. {t('systemHealth.rescan')}
  421. </button>
  422. </div>
  423. {systemHealth ? (
  424. <SystemHealthPanel result={systemHealth} />
  425. ) : (
  426. <p className="text-sm text-bambu-gray">{t('common.loading')}</p>
  427. )}
  428. </Section>
  429. {/* Database Stats */}
  430. <Section title={t('system.database', 'Database')} icon={Database}>
  431. <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
  432. <StatCard
  433. icon={Database}
  434. label={t('system.dbEngine', 'Database Engine')}
  435. value={systemInfo.database.engine || 'SQLite'}
  436. />
  437. <StatCard
  438. icon={Database}
  439. label={t('system.dbVersion', 'Version')}
  440. value={systemInfo.database.version || 'unknown'}
  441. />
  442. </div>
  443. <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
  444. <StatCard
  445. icon={Archive}
  446. label={t('system.totalArchives', 'Total Archives')}
  447. value={systemInfo.database.archives}
  448. />
  449. <StatCard
  450. icon={CheckCircle2}
  451. label={t('system.completed', 'Completed')}
  452. value={systemInfo.database.archives_completed}
  453. color="text-green-500"
  454. />
  455. <StatCard
  456. icon={XCircle}
  457. label={t('system.failed', 'Failed')}
  458. value={systemInfo.database.archives_failed}
  459. color="text-red-500"
  460. />
  461. <StatCard
  462. icon={Loader2}
  463. label={t('system.printing', 'Printing')}
  464. value={systemInfo.database.archives_printing}
  465. color="text-yellow-500"
  466. />
  467. </div>
  468. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  469. <StatCard
  470. icon={Printer}
  471. label={t('system.printers', 'Printers')}
  472. value={systemInfo.database.printers}
  473. />
  474. <StatCard
  475. icon={Palette}
  476. label={t('system.filaments', 'Filaments')}
  477. value={systemInfo.database.filaments}
  478. />
  479. <StatCard
  480. icon={FolderKanban}
  481. label={t('system.projects', 'Projects')}
  482. value={systemInfo.database.projects}
  483. />
  484. <StatCard
  485. icon={Plug}
  486. label={t('system.smartPlugs', 'Smart Plugs')}
  487. value={systemInfo.database.smart_plugs}
  488. />
  489. </div>
  490. <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
  491. <StatCard
  492. icon={Clock}
  493. label={t('system.totalPrintTime', 'Total Print Time')}
  494. value={systemInfo.database.total_print_time_formatted}
  495. />
  496. <StatCard
  497. icon={Archive}
  498. label={t('system.totalFilament', 'Total Filament Used')}
  499. value={`${systemInfo.database.total_filament_kg} kg`}
  500. subValue={`${systemInfo.database.total_filament_grams.toLocaleString()} g`}
  501. />
  502. </div>
  503. </Section>
  504. {/* Connected Printers */}
  505. <Section title={t('system.connectedPrinters', 'Connected Printers')} icon={Printer}>
  506. <div className="flex items-center gap-4 mb-4">
  507. <div className="text-3xl font-bold text-bambu-green">
  508. {systemInfo.printers.connected}
  509. </div>
  510. <div className="text-bambu-gray">
  511. {t('system.ofTotal', 'of {{total}} printers connected', {
  512. total: systemInfo.printers.total,
  513. })}
  514. </div>
  515. </div>
  516. {systemInfo.printers.connected_list.length > 0 ? (
  517. <div className="space-y-2">
  518. {systemInfo.printers.connected_list.map((printer) => (
  519. <div
  520. key={printer.id}
  521. className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
  522. >
  523. <div className="flex items-center gap-3">
  524. <div className="w-2 h-2 rounded-full bg-bambu-green" />
  525. <span className="font-medium text-white">{printer.name}</span>
  526. </div>
  527. <div className="flex items-center gap-4 text-sm text-bambu-gray">
  528. <span>{printer.model}</span>
  529. <span
  530. className={`px-2 py-0.5 rounded ${
  531. printer.state === 'RUNNING'
  532. ? 'bg-bambu-green/20 text-bambu-green'
  533. : printer.state === 'IDLE'
  534. ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
  535. : 'bg-bambu-dark-tertiary'
  536. }`}
  537. >
  538. {printer.state}
  539. </span>
  540. </div>
  541. </div>
  542. ))}
  543. </div>
  544. ) : (
  545. <p className="text-bambu-gray">{t('system.noPrintersConnected', 'No printers connected')}</p>
  546. )}
  547. </Section>
  548. {/* Storage */}
  549. <Section title={t('system.storage', 'Storage')} icon={HardDrive}>
  550. <div className="space-y-4">
  551. <div>
  552. <div className="flex justify-between text-sm mb-1">
  553. <span className="text-bambu-gray">{t('system.diskUsage', 'Disk Usage')}</span>
  554. <span className="text-white">
  555. {systemInfo.storage.disk_used_formatted} / {systemInfo.storage.disk_total_formatted}
  556. </span>
  557. </div>
  558. <ProgressBar percent={systemInfo.storage.disk_percent_used} color={diskColor} />
  559. <p className="text-xs text-bambu-gray mt-1">
  560. {systemInfo.storage.disk_free_formatted} {t('system.free', 'free')} (
  561. {(100 - systemInfo.storage.disk_percent_used).toFixed(1)}%)
  562. </p>
  563. </div>
  564. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
  565. <StatCard
  566. icon={Archive}
  567. label={t('system.archiveStorage', 'Archive Storage')}
  568. value={systemInfo.storage.archive_size_formatted}
  569. />
  570. <StatCard
  571. icon={Database}
  572. label={t('system.databaseSize', 'Database Size')}
  573. value={systemInfo.storage.database_size_formatted}
  574. />
  575. {libraryStats && (
  576. <StatCard
  577. icon={FolderOpen}
  578. label={t('system.fileManagerStorage', 'File Manager')}
  579. value={formatBytes(libraryStats.total_size_bytes)}
  580. subValue={`${libraryStats.total_files} files, ${libraryStats.total_folders} folders`}
  581. />
  582. )}
  583. </div>
  584. </div>
  585. </Section>
  586. {/* System Resources */}
  587. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  588. {/* Memory */}
  589. <Section title={t('system.memory', 'Memory')} icon={MemoryStick}>
  590. <div className="space-y-4">
  591. <div>
  592. <div className="flex justify-between text-sm mb-1">
  593. <span className="text-bambu-gray">{t('system.memoryUsage', 'Memory Usage')}</span>
  594. <span className="text-white">
  595. {systemInfo.memory.used_formatted} / {systemInfo.memory.total_formatted}
  596. </span>
  597. </div>
  598. <ProgressBar percent={systemInfo.memory.percent_used} color={memoryColor} />
  599. <p className="text-xs text-bambu-gray mt-1">
  600. {systemInfo.memory.available_formatted} {t('system.available', 'available')}
  601. </p>
  602. </div>
  603. </div>
  604. </Section>
  605. {/* CPU */}
  606. <Section title={t('system.cpu', 'CPU')} icon={Cpu}>
  607. <div className="space-y-4">
  608. <div className="grid grid-cols-2 gap-4">
  609. <StatCard
  610. icon={Cpu}
  611. label={t('system.cores', 'Cores')}
  612. value={systemInfo.cpu.count}
  613. subValue={`${systemInfo.cpu.count_logical} logical`}
  614. />
  615. <StatCard
  616. icon={Cpu}
  617. label={t('system.usage', 'Usage')}
  618. value={`${systemInfo.cpu.percent}%`}
  619. />
  620. </div>
  621. </div>
  622. </Section>
  623. </div>
  624. {/* System Details */}
  625. <Section title={t('system.systemDetails', 'System Details')} icon={Server}>
  626. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  627. <StatCard
  628. icon={Server}
  629. label={t('system.os', 'Operating System')}
  630. value={systemInfo.system.platform}
  631. subValue={systemInfo.system.platform_release}
  632. />
  633. <StatCard
  634. icon={Cpu}
  635. label={t('system.architecture', 'Architecture')}
  636. value={systemInfo.system.architecture}
  637. />
  638. <StatCard
  639. icon={Server}
  640. label={t('system.python', 'Python')}
  641. value={systemInfo.system.python_version}
  642. />
  643. <StatCard
  644. icon={Clock}
  645. label={t('system.bootTime', 'Boot Time')}
  646. value={formatDateTime(systemInfo.system.boot_time, timeFormat)}
  647. />
  648. </div>
  649. </Section>
  650. {diagnosticPrinter && (
  651. <ConnectionDiagnosticModal
  652. printerId={diagnosticPrinter.id}
  653. printerName={diagnosticPrinter.name}
  654. onClose={() => setDiagnosticPrinter(null)}
  655. />
  656. )}
  657. </div>
  658. );
  659. }