VirtualPrinterDiagnosticModal.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { useEffect } from 'react';
  2. import { useMutation } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. X,
  6. Stethoscope,
  7. CheckCircle2,
  8. XCircle,
  9. AlertTriangle,
  10. MinusCircle,
  11. Loader2,
  12. } from 'lucide-react';
  13. import {
  14. multiVirtualPrinterApi,
  15. type VPDiagnosticCheck,
  16. type VPDiagnosticStatus,
  17. type VPDiagnosticResult,
  18. } from '../api/client';
  19. function StatusIcon({ status }: { status: VPDiagnosticStatus }) {
  20. if (status === 'pass') return <CheckCircle2 className="w-5 h-5 text-bambu-green flex-shrink-0" />;
  21. if (status === 'fail') return <XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />;
  22. if (status === 'warn') return <AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0" />;
  23. return <MinusCircle className="w-5 h-5 text-bambu-gray flex-shrink-0" />;
  24. }
  25. /**
  26. * Setup-check modal for a single virtual printer. Opens straight into the
  27. * check (run on mount); "Run again" re-runs it. Each row's title and fix
  28. * text are localized via `vpDiagnostic.check.<id>.*`.
  29. */
  30. export function VirtualPrinterDiagnosticModal({
  31. vpId,
  32. vpName,
  33. onClose,
  34. }: {
  35. vpId: number;
  36. vpName: string;
  37. onClose: () => void;
  38. }) {
  39. const { t } = useTranslation();
  40. const diagnose = useMutation({
  41. mutationFn: (): Promise<VPDiagnosticResult> => multiVirtualPrinterApi.diagnose(vpId),
  42. });
  43. useEffect(() => {
  44. diagnose.mutate();
  45. // Run once on mount — re-running is the explicit "Run again" button.
  46. // eslint-disable-next-line react-hooks/exhaustive-deps
  47. }, []);
  48. useEffect(() => {
  49. const handleKeyDown = (e: KeyboardEvent) => {
  50. if (e.key === 'Escape') onClose();
  51. };
  52. window.addEventListener('keydown', handleKeyDown);
  53. return () => window.removeEventListener('keydown', handleKeyDown);
  54. }, [onClose]);
  55. const result = diagnose.data;
  56. const overallClass =
  57. result?.overall === 'ok'
  58. ? 'bg-bambu-green/10 border-bambu-green/30 text-bambu-green'
  59. : result?.overall === 'warnings'
  60. ? 'bg-amber-500/10 border-amber-500/30 text-amber-300'
  61. : 'bg-red-500/10 border-red-500/30 text-red-300';
  62. const renderCheck = (check: VPDiagnosticCheck) => {
  63. const detail = t(`vpDiagnostic.check.${check.id}.${check.status}`, {
  64. ...check.params,
  65. defaultValue: '',
  66. });
  67. return (
  68. <li
  69. key={check.id}
  70. className={`flex items-start gap-3 bg-bambu-dark rounded-lg px-4 py-2.5 ${
  71. check.status === 'skip' ? 'opacity-60' : ''
  72. }`}
  73. >
  74. <div className="mt-0.5">
  75. <StatusIcon status={check.status} />
  76. </div>
  77. <div className="flex-1 min-w-0">
  78. <div className="text-sm text-white">
  79. {t(`vpDiagnostic.check.${check.id}.title`, check.params)}
  80. </div>
  81. {detail && <div className="text-xs text-bambu-gray mt-0.5">{detail}</div>}
  82. </div>
  83. </li>
  84. );
  85. };
  86. return (
  87. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
  88. <div
  89. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg flex flex-col max-h-[85vh]"
  90. onClick={(e) => e.stopPropagation()}
  91. >
  92. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  93. <div className="flex items-center gap-2 min-w-0">
  94. <Stethoscope className="w-5 h-5 text-bambu-green flex-shrink-0" />
  95. <h2 className="text-lg font-semibold text-white truncate">
  96. {t('vpDiagnostic.title', { name: vpName })}
  97. </h2>
  98. </div>
  99. <button
  100. onClick={onClose}
  101. className="text-bambu-gray hover:text-white transition-colors"
  102. title={t('common.close')}
  103. >
  104. <X className="w-5 h-5" />
  105. </button>
  106. </div>
  107. <div className="p-6 space-y-4 overflow-y-auto">
  108. {diagnose.isPending && (
  109. <div className="flex items-center gap-2 text-bambu-gray">
  110. <Loader2 className="w-4 h-4 animate-spin" />
  111. <span>{t('vpDiagnostic.running')}</span>
  112. </div>
  113. )}
  114. {diagnose.isError && (
  115. <div className="rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300">
  116. {t('vpDiagnostic.runFailed', { error: (diagnose.error as Error).message })}
  117. </div>
  118. )}
  119. {result && (
  120. <div className="space-y-4">
  121. <ol className="space-y-2">{result.checks.map(renderCheck)}</ol>
  122. <div className={`rounded-lg border px-4 py-3 text-sm ${overallClass}`}>
  123. {t(`vpDiagnostic.overall.${result.overall}`)}
  124. </div>
  125. </div>
  126. )}
  127. </div>
  128. <div className="px-6 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
  129. <button
  130. onClick={() => diagnose.mutate()}
  131. disabled={diagnose.isPending}
  132. className="px-4 py-2 bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
  133. >
  134. {t('vpDiagnostic.retry')}
  135. </button>
  136. <button
  137. onClick={onClose}
  138. className="px-4 py-2 bg-bambu-green hover:bg-bambu-green/90 text-white text-sm rounded-lg transition-colors"
  139. >
  140. {t('common.close')}
  141. </button>
  142. </div>
  143. </div>
  144. </div>
  145. );
  146. }