From e6a0fce76e7b820270103ea128d2e8c0d8fdabe4 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 20 Apr 2026 17:59:02 +0200 Subject: [PATCH 1/8] Upgrade toktrack to 2.5.0 --- .gitignore | 1 + README.md | 4 ++-- bun.lock | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- shared/toktrack-version.d.ts | 4 ++-- shared/toktrack-version.js | 2 +- tests/frontend/settings-modal.test.tsx | 12 ++++++------ tests/unit/api.test.ts | 4 ++-- tests/unit/auto-import.test.ts | 4 ++-- 10 files changed, 23 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 74a26da..1546cb3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ test-json/ .idea coverage/ requirements/ +docs/security/ /activity-*.png /cache-hit-rate-*.png /request-*.png diff --git a/README.md b/README.md index ce21813..34c8f5e 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,8 @@ Then either: The auto-import path prefers: 1. local `toktrack` -2. `bunx toktrack@2.4.0` -3. `npx --yes toktrack@2.4.0` +2. `bunx toktrack@2.5.0` +3. `npx --yes toktrack@2.5.0` ## Common Commands diff --git a/bun.lock b/bun.lock index 0def288..81f04f2 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "i18next": "^26.0.3", "react-i18next": "^17.0.3", "react-is": "^19.2.4", - "toktrack": "2.4.0", + "toktrack": "2.5.0", }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -1116,7 +1116,7 @@ "to-valid-identifier": ["to-valid-identifier@1.0.0", "", { "dependencies": { "@sindresorhus/base62": "^1.0.0", "reserved-identifiers": "^1.0.0" } }, "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw=="], - "toktrack": ["toktrack@2.4.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "toktrack": "bin/run.js" } }, "sha512-rKqMm/9vGgxnXRs/+VfSgxYRi7wdiFMCA4PubnPyloV06SpbrndIwTk90jYC64RmaOtCqfRp7yV/hRY3a9jciQ=="], + "toktrack": ["toktrack@2.5.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "toktrack": "bin/run.js" } }, "sha512-WJYj86vF6uQ2NA3eBQZ7bzN9Idrn12i3u2vEqT7FkjuTAz6zaRXX1/Z1JjIyurc7OR8xfzE78PiONhCTl0T3jw=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], diff --git a/package-lock.json b/package-lock.json index d99f7e6..1f7be56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "i18next": "^26.0.3", "react-i18next": "^17.0.3", "react-is": "^19.2.4", - "toktrack": "2.4.0" + "toktrack": "2.5.0" }, "bin": { "ttdash": "server.js" @@ -8471,9 +8471,9 @@ } }, "node_modules/toktrack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/toktrack/-/toktrack-2.4.0.tgz", - "integrity": "sha512-rKqMm/9vGgxnXRs/+VfSgxYRi7wdiFMCA4PubnPyloV06SpbrndIwTk90jYC64RmaOtCqfRp7yV/hRY3a9jciQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/toktrack/-/toktrack-2.5.0.tgz", + "integrity": "sha512-WJYj86vF6uQ2NA3eBQZ7bzN9Idrn12i3u2vEqT7FkjuTAz6zaRXX1/Z1JjIyurc7OR8xfzE78PiONhCTl0T3jw==", "cpu": [ "x64", "arm64" diff --git a/package.json b/package.json index c2f219f..e8381dd 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,6 @@ "i18next": "^26.0.3", "react-i18next": "^17.0.3", "react-is": "^19.2.4", - "toktrack": "2.4.0" + "toktrack": "2.5.0" } } diff --git a/shared/toktrack-version.d.ts b/shared/toktrack-version.d.ts index a8395c1..538d026 100644 --- a/shared/toktrack-version.d.ts +++ b/shared/toktrack-version.d.ts @@ -1,6 +1,6 @@ /** Canonical npm package name used for toktrack lookups and execution. */ export const TOKTRACK_PACKAGE_NAME: 'toktrack' /** Pinned toktrack version validated by TTDash. */ -export const TOKTRACK_VERSION: '2.4.0' +export const TOKTRACK_VERSION: '2.5.0' /** Fully qualified toktrack package spec used by npm and bun executors. */ -export const TOKTRACK_PACKAGE_SPEC: 'toktrack@2.4.0' +export const TOKTRACK_PACKAGE_SPEC: 'toktrack@2.5.0' diff --git a/shared/toktrack-version.js b/shared/toktrack-version.js index c750498..e8f7286 100644 --- a/shared/toktrack-version.js +++ b/shared/toktrack-version.js @@ -1,5 +1,5 @@ const TOKTRACK_PACKAGE_NAME = 'toktrack' -const TOKTRACK_VERSION = '2.4.0' +const TOKTRACK_VERSION = '2.5.0' const TOKTRACK_PACKAGE_SPEC = `${TOKTRACK_PACKAGE_NAME}@${TOKTRACK_VERSION}` module.exports = { diff --git a/tests/frontend/settings-modal.test.tsx b/tests/frontend/settings-modal.test.tsx index 163998a..03082af 100644 --- a/tests/frontend/settings-modal.test.tsx +++ b/tests/frontend/settings-modal.test.tsx @@ -81,8 +81,8 @@ describe('SettingsModal', () => { vi.fn().mockResolvedValue( new Response( JSON.stringify({ - configuredVersion: '2.4.0', - latestVersion: '2.4.0', + configuredVersion: '2.5.0', + latestVersion: '2.5.0', isLatest: true, lookupStatus: 'ok', }), @@ -161,7 +161,7 @@ describe('SettingsModal', () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ - configuredVersion: '2.4.0', + configuredVersion: '2.5.0', latestVersion: '2.4.1', isLatest: false, lookupStatus: 'ok', @@ -176,7 +176,7 @@ describe('SettingsModal', () => { renderSettingsModal() - expect(screen.getByTestId('settings-toktrack-version')).toHaveTextContent('2.4.0') + expect(screen.getByTestId('settings-toktrack-version')).toHaveTextContent('2.5.0') await vi.waitFor(() => { expect(screen.getByTestId('settings-toktrack-status')).toHaveTextContent( @@ -191,8 +191,8 @@ describe('SettingsModal', () => { const fetchMock = vi.fn().mockResolvedValue( new Response( JSON.stringify({ - configuredVersion: '2.4.0', - latestVersion: '2.4.0', + configuredVersion: '2.5.0', + latestVersion: '2.5.0', isLatest: true, lookupStatus: 'ok', }), diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts index 250d979..6a4e705 100644 --- a/tests/unit/api.test.ts +++ b/tests/unit/api.test.ts @@ -137,7 +137,7 @@ describe('api error handling', () => { vi.fn().mockResolvedValue( new Response( JSON.stringify({ - configuredVersion: '2.4.0', + configuredVersion: '2.5.0', latestVersion: '2.4.1', isLatest: false, lookupStatus: 'ok', @@ -151,7 +151,7 @@ describe('api error handling', () => { ) await expect(fetchToktrackVersionStatus()).resolves.toEqual({ - configuredVersion: '2.4.0', + configuredVersion: '2.5.0', latestVersion: '2.4.1', isLatest: false, lookupStatus: 'ok', diff --git a/tests/unit/auto-import.test.ts b/tests/unit/auto-import.test.ts index 8ac8e52..0f5c471 100644 --- a/tests/unit/auto-import.test.ts +++ b/tests/unit/auto-import.test.ts @@ -29,11 +29,11 @@ describe('translateAutoImportEvent', () => { translateAutoImportEvent( { key: 'loadingUsageData', - vars: { command: 'npx --yes toktrack@2.4.0 daily --json' }, + vars: { command: 'npx --yes toktrack@2.5.0 daily --json' }, }, translate, ), - ).toBe('Lade Nutzungsdaten via npx --yes toktrack@2.4.0 daily --json...') + ).toBe('Lade Nutzungsdaten via npx --yes toktrack@2.5.0 daily --json...') expect( translateAutoImportEvent( { From 8bbbf209ef1bec6fc506550cf221a57d54bda6d9 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 20 Apr 2026 18:43:54 +0200 Subject: [PATCH 2/8] Harden toktrack auto-import flow --- server.js | 331 +++++++++++++++--- .../features/auto-import/AutoImportModal.tsx | 18 +- src/lib/auto-import.ts | 36 ++ src/locales/de/common.json | 8 + src/locales/en/common.json | 8 + tests/frontend/auto-import-modal.test.tsx | 54 +++ tests/integration/server.test.ts | 48 +++ tests/unit/auto-import.test.ts | 22 ++ tests/unit/server-helpers.test.ts | 113 ++++++ 9 files changed, 596 insertions(+), 42 deletions(-) create mode 100644 tests/frontend/auto-import-modal.test.tsx diff --git a/server.js b/server.js index 06e825a..cb326a5 100755 --- a/server.js +++ b/server.js @@ -43,12 +43,9 @@ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB const IS_WINDOWS = process.platform === 'win32'; const SECURE_DIR_MODE = 0o700; const SECURE_FILE_MODE = 0o600; -const TOKTRACK_LOCAL_BIN = path.join( - ROOT, - 'node_modules', - '.bin', - IS_WINDOWS ? 'toktrack.cmd' : 'toktrack', -); +const TOKTRACK_LOCAL_BIN = + process.env.TTDASH_TOKTRACK_LOCAL_BIN || + path.join(ROOT, 'node_modules', '.bin', IS_WINDOWS ? 'toktrack.cmd' : 'toktrack'); const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', @@ -64,9 +61,12 @@ const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1'; const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1'; const BACKGROUND_START_TIMEOUT_MS = 15000; const TOKTRACK_RUNNER_PROBE_TIMEOUT_MS = 7000; +const TOKTRACK_VERSION_CHECK_TIMEOUT_MS = 7000; +const TOKTRACK_IMPORT_TIMEOUT_MS = 60000; const TOKTRACK_LATEST_LOOKUP_TIMEOUT_MS = 7000; const TOKTRACK_LATEST_CACHE_SUCCESS_TTL_MS = 5 * 60 * 1000; const TOKTRACK_LATEST_CACHE_FAILURE_TTL_MS = 60 * 1000; +const PROCESS_TERMINATION_GRACE_MS = 1000; const DASHBOARD_DATE_PRESETS = dashboardPreferences.datePresets; const DASHBOARD_SECTION_IDS = dashboardPreferences.sectionDefinitions.map((section) => section.id); const DEFAULT_SETTINGS = { @@ -1146,6 +1146,14 @@ function createAutoImportError(message, key, vars = {}) { return error; } +function summarizeCommandError(error, fallbackMessage = 'Unknown error') { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + + return fallbackMessage; +} + function toAutoImportErrorEvent(error) { if (error && typeof error.messageKey === 'string') { return createAutoImportMessageEvent(error.messageKey, error.messageVars || {}); @@ -1168,6 +1176,20 @@ function formatAutoImportMessageEvent(event) { return 'An auto-import is already running. Please wait.'; case 'noRunnerFound': return 'No local toktrack, Bun, or npm exec installation found.'; + case 'localToktrackVersionMismatch': + return `Local toktrack v${event.vars?.detectedVersion || 'unknown'} does not match the required v${event.vars?.expectedVersion || TOKTRACK_VERSION}.`; + case 'localToktrackFailed': + return `Local toktrack could not be started: ${event.vars?.message || 'Unknown error'}`; + case 'packageRunnerFailed': + return `No compatible bunx or npm exec runner succeeded: ${event.vars?.message || 'Unknown error'}`; + case 'toktrackVersionCheckFailed': + return `Toktrack was found, but the version check failed: ${event.vars?.message || 'Unknown error'}`; + case 'toktrackExecutionFailed': + return `Toktrack failed while loading usage data: ${event.vars?.message || 'Unknown error'}`; + case 'toktrackInvalidJson': + return `Toktrack returned invalid JSON output: ${event.vars?.message || 'Unknown error'}`; + case 'toktrackInvalidData': + return `Toktrack returned data that TTDash could not process: ${event.vars?.message || 'Unknown error'}`; case 'errorPrefix': return `Error: ${event.vars?.message || 'Unknown error'}`; default: @@ -1749,10 +1771,20 @@ function createLocalToktrackRunner() { env: process.env, method: 'local', label: 'local toktrack', - displayCommand: 'node_modules/.bin/toktrack daily --json', + displayCommand: getLocalToktrackDisplayCommand(), }; } +function getLocalToktrackDisplayCommand(isWindows = IS_WINDOWS) { + if (process.env.TTDASH_TOKTRACK_LOCAL_BIN) { + return `${TOKTRACK_LOCAL_BIN} daily --json`; + } + + return isWindows + ? 'node_modules\\.bin\\toktrack.cmd daily --json' + : 'node_modules/.bin/toktrack daily --json'; +} + function createBunxToktrackRunner() { return { command: getExecutableName('bunx'), @@ -1778,6 +1810,42 @@ function createNpxToktrackRunner() { }; } +function formatCommandForDisplay(command, args = []) { + return [command, ...args].join(' ').trim(); +} + +function createCommandError( + message, + { command, args = [], stdout = '', stderr = '', exitCode = null, timedOut = false } = {}, +) { + const error = new Error(message); + error.command = command; + error.args = args; + error.stdout = stdout; + error.stderr = stderr; + error.exitCode = exitCode; + error.timedOut = timedOut; + return error; +} + +function terminateChildProcess(child) { + if (!child || child.exitCode !== null) { + return; + } + + child.kill('SIGTERM'); + + const forceKillTimeout = setTimeout(() => { + if (child.exitCode === null) { + child.kill('SIGKILL'); + } + }, PROCESS_TERMINATION_GRACE_MS); + + child.once('close', () => { + clearTimeout(forceKillTimeout); + }); +} + function runCommand( command, args, @@ -1788,6 +1856,7 @@ function runCommand( stdio: ['ignore', 'pipe', 'pipe'], env, }); + const commandLabel = formatCommandForDisplay(command, args); let stdout = ''; let stderr = ''; @@ -1806,13 +1875,22 @@ function runCommand( }; if (signalOnClose) { - signalOnClose(() => child.kill('SIGTERM')); + signalOnClose(() => terminateChildProcess(child)); } if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) { timeoutId = setTimeout(() => { - child.kill('SIGTERM'); - settle(reject, new Error(`Command timed out after ${timeoutMs}ms: ${command}`)); + terminateChildProcess(child); + settle( + reject, + createCommandError(`Command timed out after ${timeoutMs}ms: ${commandLabel}`, { + command, + args, + stdout, + stderr, + timedOut: true, + }), + ); }, timeoutMs); } @@ -1828,7 +1906,17 @@ function runCommand( } }); - child.on('error', (error) => settle(reject, error)); + child.on('error', (error) => + settle( + reject, + createCommandError(error.message || `Could not start ${commandLabel}.`, { + command, + args, + stdout, + stderr, + }), + ), + ); child.on('close', (code) => { if (finished) { return; @@ -1837,7 +1925,19 @@ function runCommand( settle(resolve, stdout.trimEnd()); return; } - settle(reject, new Error(stderr.trim() || `Could not start ${command}.`)); + settle( + reject, + createCommandError( + stderr.trim() || stdout.trim() || `Command exited with code ${code}: ${commandLabel}`, + { + command, + args, + stdout, + stderr, + exitCode: code, + }, + ), + ); }); }); } @@ -1845,18 +1945,28 @@ function runCommand( async function probeToktrackRunner(runner, timeoutMs = TOKTRACK_RUNNER_PROBE_TIMEOUT_MS) { try { await runToktrack(runner, ['--version'], { timeoutMs }); - return true; + return { + ok: true, + errorMessage: null, + }; } catch (error) { - const message = - error instanceof Error && error.message.trim() - ? error.message.trim() - : `Could not start ${runner.label}.`; + const message = summarizeCommandError(error, `Could not start ${runner.label}.`); console.warn(`Failed to probe ${runner.label}: ${message}`); - return false; + return { + ok: false, + errorMessage: message, + }; } } -async function resolveToktrackRunner() { +async function resolveToktrackRunnerWithDiagnostics() { + const resolution = { + runner: null, + localVersionMismatch: null, + localFailure: null, + runnerFailures: [], + }; + if (fs.existsSync(TOKTRACK_LOCAL_BIN)) { const localRunner = createLocalToktrackRunner(); @@ -1867,24 +1977,95 @@ async function resolveToktrackRunner() { }), ); if (localVersion === TOKTRACK_VERSION) { - return localRunner; + resolution.runner = localRunner; + return resolution; } - } catch { - // Fall back to pinned package runners when the local binary is missing or invalid. + resolution.localVersionMismatch = { + detectedVersion: localVersion || 'unknown', + expectedVersion: TOKTRACK_VERSION, + }; + } catch (error) { + resolution.localFailure = summarizeCommandError( + error, + 'The local toktrack binary could not be started.', + ); } } const bunxRunner = createBunxToktrackRunner(); - if (await probeToktrackRunner(bunxRunner)) { - return bunxRunner; + const bunxProbe = await probeToktrackRunner(bunxRunner); + if (bunxProbe.ok) { + resolution.runner = bunxRunner; + return resolution; + } + if (bunxProbe.errorMessage) { + resolution.runnerFailures.push(`bunx: ${bunxProbe.errorMessage}`); } const npxRunner = createNpxToktrackRunner(); - if (await probeToktrackRunner(npxRunner)) { - return npxRunner; + const npxProbe = await probeToktrackRunner(npxRunner); + if (npxProbe.ok) { + resolution.runner = npxRunner; + return resolution; + } + if (npxProbe.errorMessage) { + resolution.runnerFailures.push(`npm exec: ${npxProbe.errorMessage}`); } - return null; + return resolution; +} + +async function resolveToktrackRunner() { + const resolution = await resolveToktrackRunnerWithDiagnostics(); + return resolution.runner; +} + +function toAutoImportRunnerResolutionError(resolution) { + if (resolution.localVersionMismatch) { + return createAutoImportError( + formatAutoImportMessageEvent( + createAutoImportMessageEvent( + 'localToktrackVersionMismatch', + resolution.localVersionMismatch, + ), + ), + 'localToktrackVersionMismatch', + resolution.localVersionMismatch, + ); + } + + if (resolution.localFailure) { + return createAutoImportError( + formatAutoImportMessageEvent( + createAutoImportMessageEvent('localToktrackFailed', { + message: resolution.localFailure, + }), + ), + 'localToktrackFailed', + { + message: resolution.localFailure, + }, + ); + } + + if (resolution.runnerFailures.length > 0) { + return createAutoImportError( + formatAutoImportMessageEvent( + createAutoImportMessageEvent('packageRunnerFailed', { + message: resolution.runnerFailures.join(' | '), + }), + ), + 'packageRunnerFailed', + { + message: resolution.runnerFailures.join(' | '), + }, + ); + } + + return createAutoImportError( + 'No local toktrack, Bun, or npm exec installation found.', + 'noRunnerFound', + ); } function runToktrack( @@ -1989,16 +2170,34 @@ async function performAutoImport({ onCheck({ tool: 'toktrack', status: 'checking' }); onProgress(createAutoImportMessageEvent('startingLocalImport')); - const runner = await resolveToktrackRunner(); + const resolution = await resolveToktrackRunnerWithDiagnostics(); + const runner = resolution.runner; if (!runner) { - onCheck({ tool: 'toktrack', status: 'not_found' }); + const resolutionError = toAutoImportRunnerResolutionError(resolution); + if (resolutionError.messageKey === 'noRunnerFound') { + onCheck({ tool: 'toktrack', status: 'not_found' }); + } + throw resolutionError; + } + + let versionResult; + try { + versionResult = await runToktrack(runner, ['--version'], { + timeoutMs: TOKTRACK_VERSION_CHECK_TIMEOUT_MS, + }); + } catch (error) { throw createAutoImportError( - 'No local toktrack, Bun, or npm exec installation found.', - 'noRunnerFound', + formatAutoImportMessageEvent( + createAutoImportMessageEvent('toktrackVersionCheckFailed', { + message: summarizeCommandError(error), + }), + ), + 'toktrackVersionCheckFailed', + { + message: summarizeCommandError(error), + }, ); } - - const versionResult = await runToktrack(runner, ['--version']); onCheck({ tool: 'toktrack', status: 'found', @@ -2011,15 +2210,63 @@ async function performAutoImport({ }), ); - const rawJson = await runToktrack(runner, ['daily', '--json'], { - streamStderr: true, - onStderr: (line) => { - onOutput(line); - }, - signalOnClose, - }); + let rawJson; + try { + rawJson = await runToktrack(runner, ['daily', '--json'], { + streamStderr: true, + onStderr: (line) => { + onOutput(line); + }, + signalOnClose, + timeoutMs: TOKTRACK_IMPORT_TIMEOUT_MS, + }); + } catch (error) { + throw createAutoImportError( + formatAutoImportMessageEvent( + createAutoImportMessageEvent('toktrackExecutionFailed', { + message: summarizeCommandError(error), + }), + ), + 'toktrackExecutionFailed', + { + message: summarizeCommandError(error), + }, + ); + } - const normalized = normalizeIncomingData(JSON.parse(rawJson)); + let parsedJson; + try { + parsedJson = JSON.parse(rawJson); + } catch (error) { + throw createAutoImportError( + formatAutoImportMessageEvent( + createAutoImportMessageEvent('toktrackInvalidJson', { + message: summarizeCommandError(error), + }), + ), + 'toktrackInvalidJson', + { + message: summarizeCommandError(error), + }, + ); + } + + let normalized; + try { + normalized = normalizeIncomingData(parsedJson); + } catch (error) { + throw createAutoImportError( + formatAutoImportMessageEvent( + createAutoImportMessageEvent('toktrackInvalidData', { + message: summarizeCommandError(error), + }), + ), + 'toktrackInvalidData', + { + message: summarizeCommandError(error), + }, + ); + } await withSettingsAndDataMutationLock(async () => { await writeData(normalized); await updateDataLoadState({ @@ -2493,8 +2740,10 @@ module.exports = { __test__: { commandExists, getExecutableName, + getLocalToktrackDisplayCommand, parseToktrackVersionOutput, resolveToktrackRunner, + toAutoImportRunnerResolutionError, runToktrack, lookupLatestToktrackVersion, resetLatestToktrackVersionCache: () => { diff --git a/src/components/features/auto-import/AutoImportModal.tsx b/src/components/features/auto-import/AutoImportModal.tsx index 2dea986..2a95cd9 100644 --- a/src/components/features/auto-import/AutoImportModal.tsx +++ b/src/components/features/auto-import/AutoImportModal.tsx @@ -40,6 +40,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod const [status, setStatus] = useState('idle') const [lines, setLines] = useState([]) const [summary, setSummary] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) const scrollRef = useRef(null) const closeRef = useRef<{ close: () => void } | null>(null) @@ -57,6 +58,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod setStatus('checking') setLines([]) setSummary(null) + setErrorMessage(null) const handle = startAutoImport( { @@ -89,11 +91,13 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod t('autoImportModal.importedDays', { days: data.days, cost: data.totalCost.toFixed(2) }), ) setSummary(data) + setErrorMessage(null) setStatus('success') onSuccess() }, onError: (data) => { addLine('error', data.message) + setErrorMessage(data.message) setStatus('error') }, onDone: () => { @@ -119,6 +123,10 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod }, [lines]) const isRunning = status === 'checking' || status === 'running' + const handleCancel = () => { + closeRef.current?.close() + onOpenChange(false) + } return ( - {t('autoImportModal.errorOccurred')} + + {errorMessage ?? t('autoImportModal.errorOccurred')} + )} + {isRunning && ( + + )} + {!isRunning && (