DiagnosticModal.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import { useState, useEffect, useCallback } from 'react';
  2. import { X, Play, RotateCw } from 'lucide-react';
  3. import { spoolbuddyApi } from '../../api/client';
  4. import { useTranslation } from 'react-i18next';
  5. interface DiagnosticModalProps {
  6. type: 'scale' | 'nfc' | 'read_tag';
  7. deviceId: string;
  8. onClose: () => void;
  9. }
  10. export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {
  11. const { t } = useTranslation();
  12. const [isRunning, setIsRunning] = useState(false);
  13. const [output, setOutput] = useState<string>('');
  14. const [error, setError] = useState<string>('');
  15. const [hasRun, setHasRun] = useState(false);
  16. // Close on Escape
  17. useEffect(() => {
  18. const handleKeyDown = (e: KeyboardEvent) => {
  19. if (e.key === 'Escape' && !isRunning) {
  20. onClose();
  21. }
  22. };
  23. window.addEventListener('keydown', handleKeyDown);
  24. return () => window.removeEventListener('keydown', handleKeyDown);
  25. }, [isRunning, onClose]);
  26. const runDiagnostic = useCallback(async () => {
  27. setIsRunning(true);
  28. setOutput('');
  29. setError('');
  30. setHasRun(true);
  31. try {
  32. // Step 1: Queue the diagnostic on the device
  33. setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n'));
  34. await spoolbuddyApi.queueDiagnostics(deviceId, type);
  35. // Step 2: Poll for results with timeout
  36. let result = null;
  37. const maxRetries = 60; // 30s timeout with 500ms polling
  38. let retryCount = 0;
  39. while (retryCount < maxRetries && !result) {
  40. // Wait a bit before polling
  41. await new Promise(resolve => setTimeout(resolve, 500));
  42. try {
  43. result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);
  44. break;
  45. } catch {
  46. // Not ready yet, continue polling
  47. retryCount++;
  48. if (retryCount % 4 === 0) {
  49. // Update every 2 seconds (after 4 retries of 500ms)
  50. setOutput(prev => prev + '.');
  51. }
  52. }
  53. }
  54. if (!result) {
  55. throw new Error('Diagnostic timed out - device did not report results');
  56. }
  57. setOutput(result.output);
  58. if (!result.success) {
  59. setError(`Exit code: ${result.exit_code}`);
  60. }
  61. } catch (e) {
  62. setError(e instanceof Error ? e.message : 'Unknown error');
  63. setOutput('');
  64. } finally {
  65. setIsRunning(false);
  66. }
  67. }, [type, deviceId, t]);
  68. const title = type === 'scale'
  69. ? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')
  70. : type === 'read_tag'
  71. ? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')
  72. : t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');
  73. return (
  74. <div
  75. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in"
  76. onClick={onClose}
  77. >
  78. <div
  79. className="bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up"
  80. onClick={(e) => e.stopPropagation()}
  81. >
  82. {/* Header */}
  83. <div className="flex justify-between items-center p-4 border-b border-zinc-700">
  84. <h2 className="text-lg font-semibold text-white">{title}</h2>
  85. <button
  86. onClick={onClose}
  87. className="text-zinc-400 hover:text-white transition-colors"
  88. aria-label="Close"
  89. >
  90. <X size={20} />
  91. </button>
  92. </div>
  93. <div className="flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm">
  94. {isRunning ? (
  95. <div className="flex items-center gap-2 text-green-400">
  96. <div className="animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full" />
  97. <span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>
  98. </div>
  99. ) : output ? (
  100. <>
  101. <div className="text-green-400 whitespace-pre-wrap break-words">
  102. {output}
  103. </div>
  104. {error && (
  105. <div className="text-red-400 mt-2">
  106. ❌ {error}
  107. </div>
  108. )}
  109. </>
  110. ) : hasRun ? (
  111. <div>
  112. {error ? (
  113. <div className="text-red-400">ERROR: {error}</div>
  114. ) : (
  115. <span className="text-green-400">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>
  116. )}
  117. </div>
  118. ) : (
  119. <div className="text-zinc-500">
  120. {t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}.
  121. </div>
  122. )}
  123. </div>
  124. {/* Footer */}
  125. <div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
  126. <button
  127. onClick={runDiagnostic}
  128. disabled={isRunning}
  129. className="flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors"
  130. >
  131. {isRunning ? (
  132. <>
  133. <div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
  134. {t('spoolbuddy.diagnostic.runningBtn', 'Running...')}
  135. </>
  136. ) : hasRun ? (
  137. <>
  138. <RotateCw size={16} />
  139. {t('spoolbuddy.diagnostic.runAgain', 'Run Again')}
  140. </>
  141. ) : (
  142. <>
  143. <Play size={16} />
  144. {t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}
  145. </>
  146. )}
  147. </button>
  148. <button
  149. onClick={onClose}
  150. className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors"
  151. >
  152. {t('spoolbuddy.diagnostic.close', 'Close')}
  153. </button>
  154. </div>
  155. </div>
  156. </div>
  157. );
  158. }