mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
The three diagnostic surfaces shipped earlier this month (6bc6a1d6VP setup diagnostic,e222a0eflog-health scanner,ed31b8f4connection diagnostic in the bug-report bubble) were only ever shown to the *user*. A bug report arriving in the maintainer's inbox carried raw logs but no diagnostic results — the user-visible "your X1C can't reach MQTT" finding never made it into the issue body, so the maintainer had to ask the user to re-run and paste. New `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with a per-probe 15 s wall-clock cap (so total ≈ max(per-cap), not sum — fleet size doesn't matter) and is fail-soft per probe: a crash inside one printer's check emits `{"printer_id": N, "error": "..."}` for that entry rather than nuking the whole snapshot. The snapshot is then added as a `diagnostics` top-level key by `_collect_support_info()`, so both flows (POST /support/bundle and POST /bug-report/submit via `support_info=...`) pick it up without their own changes. Private-data sanitization ------------------------- The diagnostic schemas embed raw IPv4 in five field shapes that must not land in a submitted GitHub issue or a shared support ZIP: - PrinterDiagnosticResult.ip_address (top-level) - DiagnosticCheck.params.printer_ip (network-mode check) - DiagnosticCheck.params.host_ip (network-mode check) - VPDiagnosticResult per-check params.bind_ip (VP setup) - IPs embedded in log-health sample lines The first two carry the printer's own IP (already in the existing `collect_sensitive_strings` table via the Printer rows); host_ip and bind_ip are NOT in the DB so a sensitive_strings-only pass missed them. Fix: `_sanitize_recursive` walks the full snapshot tree, masks DB-known values with the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer applies (via the shared `collect_sensitive_strings`), then an IPv4-regex pass catches any IP the DB didn't cover — most importantly the Bambuddy host IP returned by `_get_host_ip()` and the VP `bind_ip` the user picked at setup. Recursive walk so arbitrary nested dicts/lists don't slip through future schema additions. Live-DB smoke test against the dev fleet: zero raw IPv4 instances in the serialized snapshot output; all five field shapes plus the embedded log samples render as `[IP]`. Progress indicators ------------------- The bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit / build ZIP). Static, not faked phase progress — we can't actually track server-side phases without SSE and the honest "here's what's happening" list communicates the longer wait without lying about percentage complete. 9 new i18n keys, real translations in all 9 locales (no English fallback). parity script clean at 4993 leaves per locale. Tests: 6 new in test_diagnostic_snapshot.py - empty-input shape stable (the three top-level keys always present) - per-printer / per-VP result coverage (lists match input lengths) - fail-soft on a single-probe crash (other entries + log-health still complete) - timed_out marker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s) - end-to-end IP sanitization across all five field shapes plus log-sample IPs, with a final JSON-serialize-and-regex sweep asserting zero raw IPv4 escapes anywhere in the result - concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s; sequential would be 0.8 s)
690 lines
27 KiB
TypeScript
690 lines
27 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Server,
|
|
Database,
|
|
HardDrive,
|
|
Cpu,
|
|
MemoryStick,
|
|
Printer,
|
|
Archive,
|
|
Clock,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
RefreshCw,
|
|
Plug,
|
|
FolderKanban,
|
|
Palette,
|
|
Bug,
|
|
Download,
|
|
Headphones,
|
|
FolderOpen,
|
|
Stethoscope,
|
|
HeartPulse,
|
|
} from 'lucide-react';
|
|
import { api, supportApi, type Printer as PrinterModel } from '../api/client';
|
|
import { Card } from '../components/Card';
|
|
import { LogViewer } from '../components/LogViewer';
|
|
import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
|
|
import { SystemHealthPanel } from '../components/SystemHealthPanel';
|
|
import { formatDateTime, type TimeFormat } from '../utils/date';
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
function StatCard({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
subValue,
|
|
color = 'text-bambu-green',
|
|
}: {
|
|
icon: React.ElementType;
|
|
label: string;
|
|
value: string | number;
|
|
subValue?: string;
|
|
color?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start gap-3 p-4 bg-bambu-dark rounded-lg">
|
|
<div className={`p-2 rounded-lg bg-bambu-dark-tertiary ${color}`}>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-bambu-gray">{label}</p>
|
|
<p className="text-lg font-semibold text-white truncate">{value}</p>
|
|
{subValue && <p className="text-xs text-bambu-gray mt-0.5">{subValue}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProgressBar({ percent, color = 'bg-bambu-green' }: { percent: number; color?: string }) {
|
|
return (
|
|
<div className="w-full h-2 bg-bambu-dark rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full ${color} transition-all duration-300`}
|
|
style={{ width: `${Math.min(100, percent)}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
icon: Icon,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
icon: React.ElementType;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Card className="p-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Icon className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
|
</div>
|
|
{children}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function SystemInfoPage() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [bundleError, setBundleError] = useState<string | null>(null);
|
|
const [bundleDownloading, setBundleDownloading] = useState(false);
|
|
const [debugToggling, setDebugToggling] = useState(false);
|
|
const [diagnosticPrinter, setDiagnosticPrinter] = useState<PrinterModel | null>(null);
|
|
|
|
const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({
|
|
queryKey: ['systemInfo'],
|
|
queryFn: api.getSystemInfo,
|
|
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
|
});
|
|
|
|
const { data: debugLoggingState } = useQuery({
|
|
queryKey: ['debugLogging'],
|
|
queryFn: supportApi.getDebugLoggingState,
|
|
staleTime: 10 * 1000, // 10 seconds
|
|
refetchInterval: 10 * 1000,
|
|
});
|
|
|
|
const { data: settings } = useQuery({
|
|
queryKey: ['settings'],
|
|
queryFn: api.getSettings,
|
|
});
|
|
|
|
const { data: libraryStats } = useQuery({
|
|
queryKey: ['library-stats'],
|
|
queryFn: api.getLibraryStats,
|
|
});
|
|
|
|
const { data: allPrinters } = useQuery({
|
|
queryKey: ['printers'],
|
|
queryFn: api.getPrinters,
|
|
});
|
|
|
|
const {
|
|
data: systemHealth,
|
|
refetch: refetchHealth,
|
|
isFetching: healthFetching,
|
|
} = useQuery({
|
|
queryKey: ['systemHealth'],
|
|
queryFn: api.getSystemHealth,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
const timeFormat: TimeFormat = settings?.time_format || 'system';
|
|
|
|
const handleToggleDebugLogging = async () => {
|
|
setDebugToggling(true);
|
|
try {
|
|
const newState = await supportApi.setDebugLogging(!debugLoggingState?.enabled);
|
|
// Immediately update the cache with the new state (includes fresh enabled_at timestamp)
|
|
queryClient.setQueryData(['debugLogging'], newState);
|
|
} catch (err) {
|
|
console.error('Failed to toggle debug logging:', err);
|
|
} finally {
|
|
setDebugToggling(false);
|
|
}
|
|
};
|
|
|
|
const handleDownloadBundle = async () => {
|
|
setBundleError(null);
|
|
setBundleDownloading(true);
|
|
try {
|
|
await supportApi.downloadSupportBundle();
|
|
} catch (err) {
|
|
setBundleError(err instanceof Error ? err.message : 'Failed to download support bundle');
|
|
} finally {
|
|
setBundleDownloading(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!systemInfo) {
|
|
return (
|
|
<div className="p-6 text-center text-bambu-gray">
|
|
{t('system.failedToLoad', 'Failed to load system information')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const diskColor =
|
|
systemInfo.storage.disk_percent_used > 90
|
|
? 'bg-red-500'
|
|
: systemInfo.storage.disk_percent_used > 75
|
|
? 'bg-yellow-500'
|
|
: 'bg-bambu-green';
|
|
|
|
const memoryColor =
|
|
systemInfo.memory.percent_used > 90
|
|
? 'bg-red-500'
|
|
: systemInfo.memory.percent_used > 75
|
|
? 'bg-yellow-500'
|
|
: 'bg-bambu-green';
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">{t('system.title', 'System Information')}</h1>
|
|
<p className="text-bambu-gray mt-1">
|
|
{t('system.subtitle', 'Monitor system resources and database statistics')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
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"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
|
|
{t('common.refresh', 'Refresh')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Application Info */}
|
|
<Section title={t('system.application', 'Application')} icon={Server}>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatCard
|
|
icon={Server}
|
|
label={t('system.version', 'Version')}
|
|
value={`v${systemInfo.app.version}`}
|
|
/>
|
|
<StatCard
|
|
icon={Clock}
|
|
label={t('system.uptime', 'System Uptime')}
|
|
value={systemInfo.system.uptime_formatted}
|
|
/>
|
|
<StatCard
|
|
icon={Server}
|
|
label={t('system.hostname', 'Hostname')}
|
|
value={systemInfo.system.hostname}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Support & Troubleshooting */}
|
|
<Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
|
|
</p>
|
|
|
|
{/* Debug Logging Toggle */}
|
|
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
|
|
<Bug className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{debugLoggingState?.enabled
|
|
? t('support.debugLoggingEnabled', 'Capturing detailed logs')
|
|
: t('support.debugLoggingDisabled', 'Normal logging level')}
|
|
{debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
|
|
<span className="text-amber-400 ml-2">
|
|
({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleToggleDebugLogging}
|
|
disabled={debugToggling}
|
|
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
|
|
debugLoggingState?.enabled
|
|
? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'
|
|
: 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
|
|
} disabled:opacity-50`}
|
|
>
|
|
{debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{debugLoggingState?.enabled
|
|
? t('support.disableDebug', 'Disable')
|
|
: t('support.enableDebug', 'Enable')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Support Bundle Download */}
|
|
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
|
|
<Download className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleDownloadBundle}
|
|
disabled={bundleDownloading || !debugLoggingState?.enabled}
|
|
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"
|
|
title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
|
|
>
|
|
{bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{bundleDownloading
|
|
? t('support.bundleGenerating', 'Generating...')
|
|
: t('common.download', 'Download')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress indicator — bundle generation now runs connection +
|
|
virtual-printer diagnostics and the log-health scan before
|
|
writing the ZIP (#1506 follow-up), so the wait is longer than
|
|
a pure file-export. List what's running so it's not opaque. */}
|
|
{bundleDownloading && (
|
|
<div className="p-3 bg-bambu-dark-tertiary/40 rounded-lg space-y-1">
|
|
<p className="text-sm font-medium text-white flex items-center gap-2">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-bambu-green" />
|
|
{t('support.bundleGenerating', 'Generating...')}
|
|
</p>
|
|
<ul className="text-xs text-bambu-gray list-disc list-inside space-y-0.5 pl-1">
|
|
<li>{t('support.bundleStepConnection', 'Running printer connectivity checks')}</li>
|
|
<li>{t('support.bundleStepVirtualPrinters', 'Running virtual-printer setup checks')}</li>
|
|
<li>{t('support.bundleStepLogScan', 'Scanning recent logs for known issues')}</li>
|
|
<li>{t('support.bundleStepBuild', 'Building the support bundle ZIP')}</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{bundleError && (
|
|
<div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
|
{bundleError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Instructions */}
|
|
{!debugLoggingState?.enabled && (
|
|
<div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
|
|
<p className="text-sm text-bambu-gray">
|
|
<span className="text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
|
|
<br />
|
|
1. {t('support.step1', 'Enable debug logging')}
|
|
<br />
|
|
2. {t('support.step2', 'Reproduce the issue')}
|
|
<br />
|
|
3. {t('support.step3', 'Download the support bundle')}
|
|
<br />
|
|
4. {t('support.step4', 'Attach the ZIP file to your issue report')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Privacy Info */}
|
|
<div className="p-4 bg-bambu-dark rounded-lg space-y-3">
|
|
<p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
|
|
<ul className="text-bambu-gray space-y-0.5">
|
|
<li>• {t('support.collectItem1', 'App version and debug mode')}</li>
|
|
<li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
|
|
<li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
|
|
<li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
|
|
<li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
|
|
<li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
|
|
<li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
|
|
<li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
|
|
<li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
|
|
<li>• {t('support.collectItem10', 'Python package versions')}</li>
|
|
<li>• {t('support.collectItem11', 'Database health checks')}</li>
|
|
<li>• {t('support.collectItem12', 'Docker environment details')}</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<p className="text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
|
|
<ul className="text-bambu-gray space-y-0.5">
|
|
<li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>
|
|
<li>• {t('support.notItem2', 'Access codes and passwords')}</li>
|
|
<li>• {t('support.notItem3', 'Email addresses')}</li>
|
|
<li>• {t('support.notItem4', 'API keys and tokens')}</li>
|
|
<li>• {t('support.notItem5', 'Webhook URLs')}</li>
|
|
<li>• {t('support.notItem6', 'Your hostname or username')}</li>
|
|
<li>• {t('support.notItem7', 'IP addresses')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-bambu-gray/70">
|
|
{t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Log Viewer */}
|
|
<LogViewer />
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Connection Diagnostic */}
|
|
<Section title={t('diagnostic.sectionTitle', 'Connection Diagnostic')} icon={Stethoscope}>
|
|
<p className="text-sm text-bambu-gray mb-4">
|
|
{t(
|
|
'diagnostic.sectionDescription',
|
|
"Check why a printer won't connect or won't print — port reachability, LAN developer mode, Docker network mode, and credentials.",
|
|
)}
|
|
</p>
|
|
{allPrinters && allPrinters.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{allPrinters.map((printer) => (
|
|
<div
|
|
key={printer.id}
|
|
className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
|
|
>
|
|
<div className="min-w-0">
|
|
<span className="font-medium text-white">{printer.name}</span>
|
|
<span className="text-sm text-bambu-gray ml-2">{printer.ip_address}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setDiagnosticPrinter(printer)}
|
|
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"
|
|
>
|
|
<Stethoscope className="w-4 h-4" />
|
|
{t('diagnostic.runButton', 'Run diagnostic')}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-bambu-gray">{t('diagnostic.noPrinters', 'No printers configured.')}</p>
|
|
)}
|
|
</Section>
|
|
|
|
{/* System Health */}
|
|
<Section title={t('systemHealth.sectionTitle')} icon={HeartPulse}>
|
|
<div className="flex items-start justify-between gap-3 mb-3">
|
|
<p className="text-sm text-bambu-gray">{t('systemHealth.sectionDescription')}</p>
|
|
<button
|
|
onClick={() => refetchHealth()}
|
|
disabled={healthFetching}
|
|
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"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${healthFetching ? 'animate-spin' : ''}`} />
|
|
{t('systemHealth.rescan')}
|
|
</button>
|
|
</div>
|
|
{systemHealth ? (
|
|
<SystemHealthPanel result={systemHealth} />
|
|
) : (
|
|
<p className="text-sm text-bambu-gray">{t('common.loading')}</p>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Database Stats */}
|
|
<Section title={t('system.database', 'Database')} icon={Database}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<StatCard
|
|
icon={Database}
|
|
label={t('system.dbEngine', 'Database Engine')}
|
|
value={systemInfo.database.engine || 'SQLite'}
|
|
/>
|
|
<StatCard
|
|
icon={Database}
|
|
label={t('system.dbVersion', 'Version')}
|
|
value={systemInfo.database.version || 'unknown'}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
<StatCard
|
|
icon={Archive}
|
|
label={t('system.totalArchives', 'Total Archives')}
|
|
value={systemInfo.database.archives}
|
|
/>
|
|
<StatCard
|
|
icon={CheckCircle2}
|
|
label={t('system.completed', 'Completed')}
|
|
value={systemInfo.database.archives_completed}
|
|
color="text-green-500"
|
|
/>
|
|
<StatCard
|
|
icon={XCircle}
|
|
label={t('system.failed', 'Failed')}
|
|
value={systemInfo.database.archives_failed}
|
|
color="text-red-500"
|
|
/>
|
|
<StatCard
|
|
icon={Loader2}
|
|
label={t('system.printing', 'Printing')}
|
|
value={systemInfo.database.archives_printing}
|
|
color="text-yellow-500"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
icon={Printer}
|
|
label={t('system.printers', 'Printers')}
|
|
value={systemInfo.database.printers}
|
|
/>
|
|
<StatCard
|
|
icon={Palette}
|
|
label={t('system.filaments', 'Filaments')}
|
|
value={systemInfo.database.filaments}
|
|
/>
|
|
<StatCard
|
|
icon={FolderKanban}
|
|
label={t('system.projects', 'Projects')}
|
|
value={systemInfo.database.projects}
|
|
/>
|
|
<StatCard
|
|
icon={Plug}
|
|
label={t('system.smartPlugs', 'Smart Plugs')}
|
|
value={systemInfo.database.smart_plugs}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
|
<StatCard
|
|
icon={Clock}
|
|
label={t('system.totalPrintTime', 'Total Print Time')}
|
|
value={systemInfo.database.total_print_time_formatted}
|
|
/>
|
|
<StatCard
|
|
icon={Archive}
|
|
label={t('system.totalFilament', 'Total Filament Used')}
|
|
value={`${systemInfo.database.total_filament_kg} kg`}
|
|
subValue={`${systemInfo.database.total_filament_grams.toLocaleString()} g`}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Connected Printers */}
|
|
<Section title={t('system.connectedPrinters', 'Connected Printers')} icon={Printer}>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="text-3xl font-bold text-bambu-green">
|
|
{systemInfo.printers.connected}
|
|
</div>
|
|
<div className="text-bambu-gray">
|
|
{t('system.ofTotal', 'of {{total}} printers connected', {
|
|
total: systemInfo.printers.total,
|
|
})}
|
|
</div>
|
|
</div>
|
|
{systemInfo.printers.connected_list.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{systemInfo.printers.connected_list.map((printer) => (
|
|
<div
|
|
key={printer.id}
|
|
className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-2 h-2 rounded-full bg-bambu-green" />
|
|
<span className="font-medium text-white">{printer.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-bambu-gray">
|
|
<span>{printer.model}</span>
|
|
<span
|
|
className={`px-2 py-0.5 rounded ${
|
|
printer.state === 'RUNNING'
|
|
? 'bg-bambu-green/20 text-bambu-green'
|
|
: printer.state === 'IDLE'
|
|
? 'bg-blue-500/20 text-blue-400'
|
|
: 'bg-bambu-dark-tertiary'
|
|
}`}
|
|
>
|
|
{printer.state}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-bambu-gray">{t('system.noPrintersConnected', 'No printers connected')}</p>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Storage */}
|
|
<Section title={t('system.storage', 'Storage')} icon={HardDrive}>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span className="text-bambu-gray">{t('system.diskUsage', 'Disk Usage')}</span>
|
|
<span className="text-white">
|
|
{systemInfo.storage.disk_used_formatted} / {systemInfo.storage.disk_total_formatted}
|
|
</span>
|
|
</div>
|
|
<ProgressBar percent={systemInfo.storage.disk_percent_used} color={diskColor} />
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{systemInfo.storage.disk_free_formatted} {t('system.free', 'free')} (
|
|
{(100 - systemInfo.storage.disk_percent_used).toFixed(1)}%)
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatCard
|
|
icon={Archive}
|
|
label={t('system.archiveStorage', 'Archive Storage')}
|
|
value={systemInfo.storage.archive_size_formatted}
|
|
/>
|
|
<StatCard
|
|
icon={Database}
|
|
label={t('system.databaseSize', 'Database Size')}
|
|
value={systemInfo.storage.database_size_formatted}
|
|
/>
|
|
{libraryStats && (
|
|
<StatCard
|
|
icon={FolderOpen}
|
|
label={t('system.fileManagerStorage', 'File Manager')}
|
|
value={formatBytes(libraryStats.total_size_bytes)}
|
|
subValue={`${libraryStats.total_files} files, ${libraryStats.total_folders} folders`}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* System Resources */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Memory */}
|
|
<Section title={t('system.memory', 'Memory')} icon={MemoryStick}>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span className="text-bambu-gray">{t('system.memoryUsage', 'Memory Usage')}</span>
|
|
<span className="text-white">
|
|
{systemInfo.memory.used_formatted} / {systemInfo.memory.total_formatted}
|
|
</span>
|
|
</div>
|
|
<ProgressBar percent={systemInfo.memory.percent_used} color={memoryColor} />
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{systemInfo.memory.available_formatted} {t('system.available', 'available')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* CPU */}
|
|
<Section title={t('system.cpu', 'CPU')} icon={Cpu}>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<StatCard
|
|
icon={Cpu}
|
|
label={t('system.cores', 'Cores')}
|
|
value={systemInfo.cpu.count}
|
|
subValue={`${systemInfo.cpu.count_logical} logical`}
|
|
/>
|
|
<StatCard
|
|
icon={Cpu}
|
|
label={t('system.usage', 'Usage')}
|
|
value={`${systemInfo.cpu.percent}%`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
|
|
{/* System Details */}
|
|
<Section title={t('system.systemDetails', 'System Details')} icon={Server}>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
icon={Server}
|
|
label={t('system.os', 'Operating System')}
|
|
value={systemInfo.system.platform}
|
|
subValue={systemInfo.system.platform_release}
|
|
/>
|
|
<StatCard
|
|
icon={Cpu}
|
|
label={t('system.architecture', 'Architecture')}
|
|
value={systemInfo.system.architecture}
|
|
/>
|
|
<StatCard
|
|
icon={Server}
|
|
label={t('system.python', 'Python')}
|
|
value={systemInfo.system.python_version}
|
|
/>
|
|
<StatCard
|
|
icon={Clock}
|
|
label={t('system.bootTime', 'Boot Time')}
|
|
value={formatDateTime(systemInfo.system.boot_time, timeFormat)}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
{diagnosticPrinter && (
|
|
<ConnectionDiagnosticModal
|
|
printerId={diagnosticPrinter.id}
|
|
printerName={diagnosticPrinter.name}
|
|
onClose={() => setDiagnosticPrinter(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|