@purposeinplay/payload-ai-translate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +714 -0
- package/dist/alerts-collection.d.ts +21 -0
- package/dist/alerts-collection.js +159 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +918 -0
- package/dist/bulk-translate-batches-collection.d.ts +29 -0
- package/dist/bulk-translate-batches-collection.js +404 -0
- package/dist/bulk-translate-units-collection.d.ts +35 -0
- package/dist/bulk-translate-units-collection.js +310 -0
- package/dist/client/estimated-cost-cell.d.ts +6 -0
- package/dist/client/estimated-cost-cell.js +12 -0
- package/dist/client/excluded-fields-field.d.ts +45 -0
- package/dist/client/excluded-fields-field.js +553 -0
- package/dist/client/field-translate-button.d.ts +6 -0
- package/dist/client/field-translate-button.js +199 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +6 -0
- package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
- package/dist/client/lib/use-global-kill-switches.js +58 -0
- package/dist/client/translate-button.d.ts +2 -0
- package/dist/client/translate-button.js +228 -0
- package/dist/client/translate-modal.d.ts +16 -0
- package/dist/client/translate-modal.js +549 -0
- package/dist/client/translation-progress.d.ts +10 -0
- package/dist/client/translation-progress.js +297 -0
- package/dist/components/TranslationNavGroup.d.ts +45 -0
- package/dist/components/TranslationNavGroup.js +104 -0
- package/dist/defaults.d.ts +11 -0
- package/dist/defaults.js +16 -0
- package/dist/endpoints/client-config.d.ts +44 -0
- package/dist/endpoints/client-config.js +145 -0
- package/dist/endpoints/estimate.d.ts +5 -0
- package/dist/endpoints/estimate.js +237 -0
- package/dist/endpoints/progress.d.ts +2 -0
- package/dist/endpoints/progress.js +314 -0
- package/dist/endpoints/translate.d.ts +11 -0
- package/dist/endpoints/translate.js +376 -0
- package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
- package/dist/endpoints/translation-hub/_helpers.js +297 -0
- package/dist/endpoints/translation-hub/active.d.ts +21 -0
- package/dist/endpoints/translation-hub/active.js +220 -0
- package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
- package/dist/endpoints/translation-hub/cancel.js +233 -0
- package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
- package/dist/endpoints/translation-hub/enqueue.js +529 -0
- package/dist/endpoints/translation-hub/failures.d.ts +12 -0
- package/dist/endpoints/translation-hub/failures.js +67 -0
- package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
- package/dist/endpoints/translation-hub/force-reset.js +144 -0
- package/dist/endpoints/translation-hub/index.d.ts +21 -0
- package/dist/endpoints/translation-hub/index.js +20 -0
- package/dist/endpoints/translation-hub/list.d.ts +40 -0
- package/dist/endpoints/translation-hub/list.js +182 -0
- package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
- package/dist/endpoints/translation-hub/preflight.js +141 -0
- package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
- package/dist/endpoints/translation-hub/retry-failed.js +235 -0
- package/dist/endpoints/translation-hub/revert.d.ts +88 -0
- package/dist/endpoints/translation-hub/revert.js +405 -0
- package/dist/endpoints/translation-hub/status.d.ts +45 -0
- package/dist/endpoints/translation-hub/status.js +391 -0
- package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
- package/dist/endpoints/translation-hub/usage-summary.js +481 -0
- package/dist/exports/client.d.ts +6 -0
- package/dist/exports/client.js +6 -0
- package/dist/exports/components.d.ts +6 -0
- package/dist/exports/components.js +5 -0
- package/dist/exports/index.d.ts +8 -0
- package/dist/exports/index.js +7 -0
- package/dist/exports/providers.d.ts +9 -0
- package/dist/exports/providers.js +5 -0
- package/dist/exports/views-client.d.ts +23 -0
- package/dist/exports/views-client.js +22 -0
- package/dist/exports/views.d.ts +30 -0
- package/dist/exports/views.js +29 -0
- package/dist/hooks/after-change-global.d.ts +4 -0
- package/dist/hooks/after-change-global.js +109 -0
- package/dist/hooks/after-change.d.ts +16 -0
- package/dist/hooks/after-change.js +205 -0
- package/dist/hooks/after-delete.d.ts +30 -0
- package/dist/hooks/after-delete.js +95 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/jobs-collection.d.ts +17 -0
- package/dist/jobs-collection.js +139 -0
- package/dist/lexical/classifier.d.ts +3 -0
- package/dist/lexical/classifier.js +108 -0
- package/dist/lexical/deserializer.d.ts +4 -0
- package/dist/lexical/deserializer.js +263 -0
- package/dist/lexical/placeholder-integrity.d.ts +6 -0
- package/dist/lexical/placeholder-integrity.js +21 -0
- package/dist/lexical/placeholders.d.ts +21 -0
- package/dist/lexical/placeholders.js +117 -0
- package/dist/lexical/serializer.d.ts +21 -0
- package/dist/lexical/serializer.js +233 -0
- package/dist/lexical/types.d.ts +32 -0
- package/dist/lexical/types.js +1 -0
- package/dist/lib/auth-diagnostics.d.ts +14 -0
- package/dist/lib/auth-diagnostics.js +19 -0
- package/dist/lib/batch-counts.d.ts +58 -0
- package/dist/lib/batch-counts.js +105 -0
- package/dist/lib/bulk-translate-migrations.d.ts +92 -0
- package/dist/lib/bulk-translate-migrations.js +153 -0
- package/dist/lib/coalescing-queue.d.ts +38 -0
- package/dist/lib/coalescing-queue.js +69 -0
- package/dist/lib/content-extractor.d.ts +16 -0
- package/dist/lib/content-extractor.js +410 -0
- package/dist/lib/content-hash.d.ts +1 -0
- package/dist/lib/content-hash.js +19 -0
- package/dist/lib/content-patcher.d.ts +15 -0
- package/dist/lib/content-patcher.js +293 -0
- package/dist/lib/cost-guards.d.ts +2 -0
- package/dist/lib/cost-guards.js +18 -0
- package/dist/lib/daily-spend-cap.d.ts +58 -0
- package/dist/lib/daily-spend-cap.js +233 -0
- package/dist/lib/effective-locales.d.ts +181 -0
- package/dist/lib/effective-locales.js +302 -0
- package/dist/lib/error-messages.d.ts +245 -0
- package/dist/lib/error-messages.js +626 -0
- package/dist/lib/events.d.ts +39 -0
- package/dist/lib/events.js +146 -0
- package/dist/lib/exclude-fields.d.ts +3 -0
- package/dist/lib/exclude-fields.js +64 -0
- package/dist/lib/field-breadcrumb.d.ts +31 -0
- package/dist/lib/field-breadcrumb.js +227 -0
- package/dist/lib/field-diff.d.ts +1 -0
- package/dist/lib/field-diff.js +25 -0
- package/dist/lib/field-empty.d.ts +2 -0
- package/dist/lib/field-empty.js +68 -0
- package/dist/lib/field-resolver.d.ts +3 -0
- package/dist/lib/field-resolver.js +164 -0
- package/dist/lib/group-soft-skips.d.ts +39 -0
- package/dist/lib/group-soft-skips.js +45 -0
- package/dist/lib/locale-merge.d.ts +44 -0
- package/dist/lib/locale-merge.js +357 -0
- package/dist/lib/locale-row-check.d.ts +30 -0
- package/dist/lib/locale-row-check.js +64 -0
- package/dist/lib/logger.d.ts +74 -0
- package/dist/lib/logger.js +97 -0
- package/dist/lib/manual-edit-guard.d.ts +128 -0
- package/dist/lib/manual-edit-guard.js +393 -0
- package/dist/lib/output-validation.d.ts +48 -0
- package/dist/lib/output-validation.js +148 -0
- package/dist/lib/payload-read.d.ts +16 -0
- package/dist/lib/payload-read.js +51 -0
- package/dist/lib/per-doc-claim.d.ts +90 -0
- package/dist/lib/per-doc-claim.js +140 -0
- package/dist/lib/per-doc-lock.d.ts +94 -0
- package/dist/lib/per-doc-lock.js +119 -0
- package/dist/lib/persist-usage.d.ts +91 -0
- package/dist/lib/persist-usage.js +116 -0
- package/dist/lib/progress-store.d.ts +103 -0
- package/dist/lib/progress-store.js +314 -0
- package/dist/lib/rate-limiter.d.ts +3 -0
- package/dist/lib/rate-limiter.js +53 -0
- package/dist/lib/snapshot-select.d.ts +43 -0
- package/dist/lib/snapshot-select.js +108 -0
- package/dist/lib/translate-prompt.d.ts +31 -0
- package/dist/lib/translate-prompt.js +66 -0
- package/dist/lib/translation-token-bucket.d.ts +57 -0
- package/dist/lib/translation-token-bucket.js +365 -0
- package/dist/lib/truncate-source-value.d.ts +1 -0
- package/dist/lib/truncate-source-value.js +27 -0
- package/dist/manual-edit-collection.d.ts +22 -0
- package/dist/manual-edit-collection.js +124 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +934 -0
- package/dist/providers/ai-sdk-adapter.d.ts +35 -0
- package/dist/providers/ai-sdk-adapter.js +100 -0
- package/dist/providers/anthropic.d.ts +31 -0
- package/dist/providers/anthropic.js +66 -0
- package/dist/providers/custom.d.ts +36 -0
- package/dist/providers/custom.js +24 -0
- package/dist/providers/gemini.d.ts +20 -0
- package/dist/providers/gemini.js +48 -0
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.js +29 -0
- package/dist/providers/openai.d.ts +28 -0
- package/dist/providers/openai.js +69 -0
- package/dist/settings-global.d.ts +74 -0
- package/dist/settings-global.js +216 -0
- package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
- package/dist/tasks/bulk-translate-coordinator.js +708 -0
- package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
- package/dist/tasks/bulk-translate-doc-task.js +1000 -0
- package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
- package/dist/tasks/bulk-translate-janitor.js +311 -0
- package/dist/tasks/translate-job-task.d.ts +51 -0
- package/dist/tasks/translate-job-task.js +154 -0
- package/dist/translate.d.ts +113 -0
- package/dist/translate.js +911 -0
- package/dist/translation-daily-spend-collection.d.ts +24 -0
- package/dist/translation-daily-spend-collection.js +133 -0
- package/dist/translation-rate-limits-collection.d.ts +30 -0
- package/dist/translation-rate-limits-collection.js +144 -0
- package/dist/types.d.ts +672 -0
- package/dist/types.js +1 -0
- package/dist/usage-collection.d.ts +14 -0
- package/dist/usage-collection.js +377 -0
- package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
- package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
- package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
- package/dist/views/BulkRunsHub/BucketRow.js +982 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
- package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
- package/dist/views/BulkRunsHub/EmptyState.js +64 -0
- package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
- package/dist/views/BulkRunsHub/FilterBar.js +284 -0
- package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
- package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
- package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
- package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
- package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
- package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
- package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
- package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
- package/dist/views/BulkRunsHub/index.d.ts +17 -0
- package/dist/views/BulkRunsHub/index.js +80 -0
- package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
- package/dist/views/BulkRunsHub/urlFilters.js +50 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
- package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
- package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
- package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
- package/dist/views/TranslationHub/ActiveJobs.js +320 -0
- package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
- package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
- package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
- package/dist/views/TranslationHub/AlertBanner.js +568 -0
- package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
- package/dist/views/TranslationHub/AuditPanel.js +1367 -0
- package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
- package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
- package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
- package/dist/views/TranslationHub/Hub.client.js +357 -0
- package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
- package/dist/views/TranslationHub/ModelCombobox.js +415 -0
- package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
- package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
- package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
- package/dist/views/TranslationHub/SettingsRail.js +382 -0
- package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
- package/dist/views/TranslationHub/StatusStrip.js +451 -0
- package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
- package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
- package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
- package/dist/views/TranslationHub/UsageTable.js +1240 -0
- package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
- package/dist/views/TranslationHub/alertGrouping.js +99 -0
- package/dist/views/TranslationHub/index.d.ts +20 -0
- package/dist/views/TranslationHub/index.js +109 -0
- package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
- package/dist/views/TranslationHub/tabNavigation.js +74 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
- package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
- package/dist/views/TranslationHub/useFocusTrap.js +81 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
- package/dist/views/shared/EditorError.d.ts +97 -0
- package/dist/views/shared/EditorError.js +205 -0
- package/dist/views/shared/ModelCell.d.ts +18 -0
- package/dist/views/shared/ModelCell.js +31 -0
- package/dist/views/shared/docHref.d.ts +16 -0
- package/dist/views/shared/docHref.js +26 -0
- package/dist/views/shared/fetch-error-body.d.ts +25 -0
- package/dist/views/shared/fetch-error-body.js +42 -0
- package/dist/views/shared/filterPillStyle.d.ts +35 -0
- package/dist/views/shared/filterPillStyle.js +40 -0
- package/dist/views/shared/format.d.ts +75 -0
- package/dist/views/shared/format.js +131 -0
- package/package.json +141 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { categorizeFailure } from '../../lib/error-messages.js';
|
|
5
|
+
import { docHref } from '../shared/docHref.js';
|
|
6
|
+
import { EditorError } from '../shared/EditorError.js';
|
|
7
|
+
import { readResponseError } from '../shared/fetch-error-body.js';
|
|
8
|
+
import { filterPillColors } from '../shared/filterPillStyle.js';
|
|
9
|
+
import { formatCost, formatDuration } from '../shared/format.js';
|
|
10
|
+
import { isActiveStatus } from '../TranslationHub/BulkTranslate.types.js';
|
|
11
|
+
import { RevertConfirmModal } from '../TranslationHub/BulkTranslateTerminalCard.js';
|
|
12
|
+
import { BucketRow } from './BucketRow.js';
|
|
13
|
+
import { groupJobsIntoBuckets, shouldBucketBeExpandedByDefault, shouldBucketBeVisibleUnderFilter } from './bucket-grouping.js';
|
|
14
|
+
import { StatusBadge, UnitStatusBadge } from './StatusBadge.js';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Constants
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const PROGRESS_TRACK_STYLE = {
|
|
19
|
+
height: '3px',
|
|
20
|
+
width: '48px',
|
|
21
|
+
background: 'var(--theme-elevation-100)',
|
|
22
|
+
borderRadius: '2px',
|
|
23
|
+
overflow: 'hidden',
|
|
24
|
+
display: 'inline-block',
|
|
25
|
+
verticalAlign: 'middle'
|
|
26
|
+
};
|
|
27
|
+
const TD_STYLE = {
|
|
28
|
+
padding: '0.55rem 0.5rem',
|
|
29
|
+
borderTop: '1px solid var(--theme-elevation-100)',
|
|
30
|
+
whiteSpace: 'nowrap',
|
|
31
|
+
fontSize: '0.8125rem',
|
|
32
|
+
color: 'var(--theme-elevation-800)',
|
|
33
|
+
verticalAlign: 'middle'
|
|
34
|
+
};
|
|
35
|
+
const TH_STYLE = {
|
|
36
|
+
textAlign: 'left',
|
|
37
|
+
padding: '0.4rem 0.5rem',
|
|
38
|
+
fontSize: '0.7rem',
|
|
39
|
+
fontWeight: 600,
|
|
40
|
+
textTransform: 'uppercase',
|
|
41
|
+
letterSpacing: '0.05em',
|
|
42
|
+
color: 'var(--theme-elevation-500)',
|
|
43
|
+
borderBottom: '1px solid var(--theme-elevation-150)',
|
|
44
|
+
whiteSpace: 'nowrap'
|
|
45
|
+
};
|
|
46
|
+
// Unit-level filter chips. `'completed'` is the API's status param value
|
|
47
|
+
// for the success bucket — UI labels it "Succeeded" since "Completed"
|
|
48
|
+
// reads as ambiguous with "finished, regardless of outcome." See
|
|
49
|
+
// CHIP_LABELS below for the display mapping.
|
|
50
|
+
const UNIT_FILTER_CHIPS = [
|
|
51
|
+
'all',
|
|
52
|
+
'pending',
|
|
53
|
+
'running',
|
|
54
|
+
'completed',
|
|
55
|
+
'failed',
|
|
56
|
+
'skipped',
|
|
57
|
+
'reverted'
|
|
58
|
+
];
|
|
59
|
+
const CHIP_LABELS = {
|
|
60
|
+
all: 'All',
|
|
61
|
+
pending: 'Pending',
|
|
62
|
+
running: 'Running',
|
|
63
|
+
completed: 'Succeeded',
|
|
64
|
+
failed: 'Failed',
|
|
65
|
+
skipped: 'Skipped',
|
|
66
|
+
reverted: 'Reverted'
|
|
67
|
+
};
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Helpers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// ROUND3-6: per-batch `/status` fetch dedup lives in its own module so
|
|
72
|
+
// it can be unit-tested without dragging the React component in. See
|
|
73
|
+
// `dedupedStatusFetch.ts` for the rationale.
|
|
74
|
+
import { dedupedStatusFetch } from './dedupedStatusFetch.js';
|
|
75
|
+
function relTime(iso) {
|
|
76
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
77
|
+
const s = Math.floor(diff / 1000);
|
|
78
|
+
if (s < 86_400) {
|
|
79
|
+
if (s < 60) return `${s}s ago`;
|
|
80
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
81
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
82
|
+
}
|
|
83
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
84
|
+
month: 'short',
|
|
85
|
+
day: 'numeric'
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Renders a wallclock span between two ISO timestamps. Wrapper around
|
|
90
|
+
* `formatDuration(ms)` for sites that have start / end strings rather
|
|
91
|
+
* than a raw duration. Open-ended spans (no `endIso`) use `Date.now()`
|
|
92
|
+
* so a running batch's wallclock keeps ticking.
|
|
93
|
+
*/ function fmtWallclock(startIso, endIso) {
|
|
94
|
+
if (!startIso) return '—';
|
|
95
|
+
const end = endIso ? new Date(endIso) : new Date();
|
|
96
|
+
const ms = end.getTime() - new Date(startIso).getTime();
|
|
97
|
+
return formatDuration(ms);
|
|
98
|
+
}
|
|
99
|
+
export function describeBatchProgress(completedUnits, totalUnits) {
|
|
100
|
+
if (totalUnits <= 0) return {
|
|
101
|
+
kind: 'empty'
|
|
102
|
+
};
|
|
103
|
+
const completed = Math.max(0, completedUnits);
|
|
104
|
+
const percent = Math.min(100, completed / totalUnits * 100);
|
|
105
|
+
return {
|
|
106
|
+
kind: 'fraction',
|
|
107
|
+
completed,
|
|
108
|
+
total: totalUnits,
|
|
109
|
+
percent
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function truncateId(id, max = 12) {
|
|
113
|
+
if (id.length <= max) return id;
|
|
114
|
+
return `${id.slice(0, max)}…`;
|
|
115
|
+
}
|
|
116
|
+
/** Revert-window countdown string: "22h left", "45m left", or "expired". */ function revertCountdown(expiresAt) {
|
|
117
|
+
if (!expiresAt) return null;
|
|
118
|
+
const ms = new Date(expiresAt).getTime() - Date.now();
|
|
119
|
+
if (ms <= 0) return null;
|
|
120
|
+
const h = Math.floor(ms / 3_600_000);
|
|
121
|
+
const m = Math.floor(ms % 3_600_000 / 60_000);
|
|
122
|
+
if (h > 0) return `${h}h ${m}m left`;
|
|
123
|
+
return `${m}m left`;
|
|
124
|
+
}
|
|
125
|
+
/** Map public filter chip value to API status param. */ function chipToApiStatus(chip) {
|
|
126
|
+
if (chip === 'all') return undefined;
|
|
127
|
+
// 'completed' maps to the API's status param value
|
|
128
|
+
return chip;
|
|
129
|
+
}
|
|
130
|
+
const DrillDown = ({ basePath, batch, onAfterAction })=>{
|
|
131
|
+
const [unitFilter, setUnitFilter] = useState(batch.failedUnits > 0 ? 'failed' : 'all');
|
|
132
|
+
const [statusData, setStatusData] = useState(null);
|
|
133
|
+
const [statusError, setStatusError] = useState(null);
|
|
134
|
+
const [nextCursor, setNextCursor] = useState(null);
|
|
135
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
136
|
+
const [expandedUnits, setExpandedUnits] = useState(new Set());
|
|
137
|
+
const [revertOpen, setRevertOpen] = useState(false);
|
|
138
|
+
const [cancelling, setCancelling] = useState(false);
|
|
139
|
+
const [cancelError, setCancelError] = useState(null);
|
|
140
|
+
const [retryingFailed, setRetryingFailed] = useState(false);
|
|
141
|
+
const [retryError, setRetryError] = useState(null);
|
|
142
|
+
const cancelledRef = useRef(false);
|
|
143
|
+
const timerRef = useRef(undefined);
|
|
144
|
+
// Track which bucket collections have been auto-loaded via
|
|
145
|
+
// onLoadBucketDocs. Declared at the top of the component so the
|
|
146
|
+
// polling effect can reset it when filter changes.
|
|
147
|
+
const loadedBucketsRef = useRef(new Set());
|
|
148
|
+
// Per-bucket pagination cursor for the "Load more in this bucket"
|
|
149
|
+
// button. Map value: opaque cursor string for the NEXT page, or
|
|
150
|
+
// `null` if this bucket has no more pages. Missing key = never
|
|
151
|
+
// fetched. Reset on filter change.
|
|
152
|
+
const bucketCursorsRef = useRef(new Map());
|
|
153
|
+
// De-dupe re-entrant calls to onLoadBucketDocs for the same
|
|
154
|
+
// collection while a request is in flight.
|
|
155
|
+
const loadingBucketRef = useRef(new Set());
|
|
156
|
+
// Bump on every bucket load to force a re-render so BucketRow
|
|
157
|
+
// can read fresh cursor + loading state via the helpers below.
|
|
158
|
+
const [bucketLoadingCount, setBucketLoadingCount] = useState(0);
|
|
159
|
+
void bucketLoadingCount; // referenced via helper closures
|
|
160
|
+
// v1.2.7: track which buckets are currently expanded so the filter-
|
|
161
|
+
// change effect can re-fetch them under the new filter. Without this,
|
|
162
|
+
// clicking "Failed" while `pages` is expanded leaves an empty expanded
|
|
163
|
+
// bucket (no rows match because the bucket was originally loaded with
|
|
164
|
+
// `?status=all`, and the polling page doesn't include the bucket's
|
|
165
|
+
// slice). Set bumps via `notifyBucketExpanded` from BucketRow.
|
|
166
|
+
const expandedBucketsRef = useRef(new Set());
|
|
167
|
+
// v1.2.7: per-bucket AbortController so a stale in-flight fetch from
|
|
168
|
+
// the previous filter can't bleed its rows into the new filter's
|
|
169
|
+
// view. Each call to `onLoadBucketDocs` registers a controller keyed
|
|
170
|
+
// by `${collection}:${unitFilter}`; the filter-change effect aborts
|
|
171
|
+
// every controller before resetting state.
|
|
172
|
+
const bucketAbortControllersRef = useRef(new Map());
|
|
173
|
+
// v1.2.7: filter epoch — bumped on every filter change. Late-arriving
|
|
174
|
+
// bucket fetch responses tagged with a stale epoch are dropped before
|
|
175
|
+
// they can populate the new filter's bucket state. Belt-and-braces
|
|
176
|
+
// alongside the AbortController above so a non-abortable fetch
|
|
177
|
+
// (mocked test, browser back-compat) can't race the new filter.
|
|
178
|
+
const filterEpochRef = useRef(0);
|
|
179
|
+
// Fetch / poll unit status when drill-down is open.
|
|
180
|
+
//
|
|
181
|
+
// v1.2.5 fix (the "expanding any bucket shows nothing" bug): the
|
|
182
|
+
// polling tick used to call `setStatusData(json.data)` — a full
|
|
183
|
+
// replacement. That wiped out the bucket-loaded jobs from
|
|
184
|
+
// `onLoadBucketDocs` (which appends collection-scoped pages to
|
|
185
|
+
// `statusData.jobs` outside the main pagination). Every 5 seconds
|
|
186
|
+
// the polling response wiped them away and the bucket went empty,
|
|
187
|
+
// while `loadedBucketsRef` still said "already loaded" so the
|
|
188
|
+
// auto-load couldn't re-fire. Stuck-empty until filter change.
|
|
189
|
+
//
|
|
190
|
+
// The fix splits "first fetch" from "subsequent polls":
|
|
191
|
+
// - First fetch (filter changed / drill-down opened) REPLACES —
|
|
192
|
+
// a clean reset is what the user expects.
|
|
193
|
+
// - Subsequent polls MERGE — new jobs override matching unitIds,
|
|
194
|
+
// prev jobs not in the new response survive. Bucket-loaded
|
|
195
|
+
// pages persist across polling ticks.
|
|
196
|
+
//
|
|
197
|
+
// We also reset `loadedBucketsRef` on filter change so each bucket
|
|
198
|
+
// can re-auto-load against the new filter scope.
|
|
199
|
+
useEffect(()=>{
|
|
200
|
+
cancelledRef.current = false;
|
|
201
|
+
loadedBucketsRef.current = new Set();
|
|
202
|
+
bucketCursorsRef.current = new Map();
|
|
203
|
+
// v1.2.7: abort any in-flight bucket fetches still riding the
|
|
204
|
+
// previous filter so they can't append stale rows to the new
|
|
205
|
+
// filter's bucket view. Then bump the epoch so any non-abortable
|
|
206
|
+
// late response is dropped server-side via the epoch check inside
|
|
207
|
+
// `onLoadBucketDocs`.
|
|
208
|
+
for (const ctrl of bucketAbortControllersRef.current.values()){
|
|
209
|
+
ctrl.abort();
|
|
210
|
+
}
|
|
211
|
+
bucketAbortControllersRef.current = new Map();
|
|
212
|
+
filterEpochRef.current += 1;
|
|
213
|
+
let isFirstFetch = true;
|
|
214
|
+
async function fetchStatus(cursor) {
|
|
215
|
+
const apiStatus = chipToApiStatus(unitFilter);
|
|
216
|
+
const params = new URLSearchParams({
|
|
217
|
+
limit: '20'
|
|
218
|
+
});
|
|
219
|
+
if (apiStatus) params.set('status', apiStatus);
|
|
220
|
+
if (cursor) params.set('cursor', cursor);
|
|
221
|
+
// ROUND2-1: Payload's collection-list router intercepts requests
|
|
222
|
+
// whose only query string is `?limit=N`, 308-redirecting them to a
|
|
223
|
+
// locale-prefixed canonical URL that 404s our custom endpoint.
|
|
224
|
+
// `useBulkRunsList.buildUrl` got the same fix for the runs list;
|
|
225
|
+
// the per-batch status fetcher also needs it for the "All" chip
|
|
226
|
+
// (no `status=…` filter, no `cursor=…`). `depth=0` is a no-op for
|
|
227
|
+
// our handler but makes the URL non-bare.
|
|
228
|
+
params.set('depth', '0');
|
|
229
|
+
try {
|
|
230
|
+
// ROUND3-6: route through the module-level dedup so StrictMode
|
|
231
|
+
// dev double-mount collapses to a single network call instead
|
|
232
|
+
// of the paired-burst pattern editors saw on every row-expand.
|
|
233
|
+
const res = await dedupedStatusFetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/status?${params}`);
|
|
234
|
+
if (cancelledRef.current) return;
|
|
235
|
+
if (!res.ok) throw new Error(await readResponseError(res));
|
|
236
|
+
const json = await res.json();
|
|
237
|
+
if (cancelledRef.current) return;
|
|
238
|
+
if (isFirstFetch) {
|
|
239
|
+
setStatusData(json.data);
|
|
240
|
+
isFirstFetch = false;
|
|
241
|
+
// v1.2.7: re-fetch any buckets that were expanded under the
|
|
242
|
+
// previous filter so the expanded panel doesn't go empty
|
|
243
|
+
// when the filter changes. The first-page response only
|
|
244
|
+
// covers ONE bucket's slice (pagination starts at offset 0)
|
|
245
|
+
// — without an explicit re-fetch every other previously-
|
|
246
|
+
// expanded bucket would render with zero rows while the
|
|
247
|
+
// header still claimed N matching docs. Skip buckets that
|
|
248
|
+
// have zero docs under the new filter (they get hidden
|
|
249
|
+
// anyway via `shouldBucketBeVisibleUnderFilter`).
|
|
250
|
+
const statusKey = unitFilter === 'all' ? 'all' : unitFilter;
|
|
251
|
+
for (const collection of expandedBucketsRef.current){
|
|
252
|
+
const counts = json.data.countsByCollection?.[collection]?.docCountsByStatus;
|
|
253
|
+
const matching = counts ? counts[statusKey] ?? 0 : 0;
|
|
254
|
+
if (matching > 0) {
|
|
255
|
+
// Fire-and-forget — `onLoadBucketDocs` de-dupes via
|
|
256
|
+
// loadingBucketRef and guards against epoch drift.
|
|
257
|
+
void onLoadBucketDocs(collection);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// Merge: keep prev.jobs that aren't in the new response
|
|
262
|
+
// (these are bucket-loaded extras the polling page doesn't
|
|
263
|
+
// see). Server-side aggregates (countsByCollection,
|
|
264
|
+
// nextCursor, etc.) come from the fresh response.
|
|
265
|
+
setStatusData((prev)=>{
|
|
266
|
+
if (!prev) return json.data;
|
|
267
|
+
const newJobsById = new Map(json.data.jobs.map((j)=>[
|
|
268
|
+
j.unitId,
|
|
269
|
+
j
|
|
270
|
+
]));
|
|
271
|
+
const preserved = prev.jobs.filter((j)=>!newJobsById.has(j.unitId));
|
|
272
|
+
return {
|
|
273
|
+
...json.data,
|
|
274
|
+
jobs: [
|
|
275
|
+
...json.data.jobs,
|
|
276
|
+
...preserved
|
|
277
|
+
]
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
setNextCursor(json.data.nextCursor);
|
|
282
|
+
setStatusError(null);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
if (!cancelledRef.current) {
|
|
285
|
+
setStatusError(e instanceof Error ? e.message : String(e));
|
|
286
|
+
}
|
|
287
|
+
} finally{
|
|
288
|
+
if (!cancelledRef.current && isActiveStatus(batch.status)) {
|
|
289
|
+
if (document.visibilityState === 'visible') {
|
|
290
|
+
timerRef.current = setTimeout(()=>fetchStatus(), 5000);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function onVisibility() {
|
|
296
|
+
if (document.visibilityState === 'visible' && !cancelledRef.current) {
|
|
297
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
298
|
+
fetchStatus();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
302
|
+
fetchStatus();
|
|
303
|
+
return ()=>{
|
|
304
|
+
cancelledRef.current = true;
|
|
305
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
306
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
307
|
+
};
|
|
308
|
+
}, [
|
|
309
|
+
basePath,
|
|
310
|
+
batch.id,
|
|
311
|
+
batch.status,
|
|
312
|
+
unitFilter
|
|
313
|
+
]);
|
|
314
|
+
async function onLoadMore() {
|
|
315
|
+
if (!nextCursor || loadingMore) return;
|
|
316
|
+
setLoadingMore(true);
|
|
317
|
+
const apiStatus = chipToApiStatus(unitFilter);
|
|
318
|
+
const params = new URLSearchParams({
|
|
319
|
+
limit: '20',
|
|
320
|
+
cursor: nextCursor
|
|
321
|
+
});
|
|
322
|
+
if (apiStatus) params.set('status', apiStatus);
|
|
323
|
+
// ROUND2-1: see fetchStatus above — `depth=0` defeats the Payload
|
|
324
|
+
// collection-list redirect heuristic on `?limit=N` URLs.
|
|
325
|
+
params.set('depth', '0');
|
|
326
|
+
try {
|
|
327
|
+
const res = await dedupedStatusFetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/status?${params}`);
|
|
328
|
+
if (!res.ok) throw new Error(await readResponseError(res));
|
|
329
|
+
const json = await res.json();
|
|
330
|
+
setStatusData((prev)=>prev ? {
|
|
331
|
+
...json.data,
|
|
332
|
+
jobs: [
|
|
333
|
+
...prev.jobs,
|
|
334
|
+
...json.data.jobs
|
|
335
|
+
]
|
|
336
|
+
} : json.data);
|
|
337
|
+
setNextCursor(json.data.nextCursor);
|
|
338
|
+
} catch {
|
|
339
|
+
// Non-fatal.
|
|
340
|
+
} finally{
|
|
341
|
+
setLoadingMore(false);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Bucket-on-expand + Load More within bucket. Tracks per-bucket
|
|
345
|
+
// pagination state via `bucketCursors` so callers can fetch
|
|
346
|
+
// additional pages of the same collection. The first call (no
|
|
347
|
+
// existing cursor) is the auto-load fired by BucketRow on expand;
|
|
348
|
+
// subsequent calls are the per-bucket "Load N more" button.
|
|
349
|
+
//
|
|
350
|
+
// De-duplicates against existing jobs by unitId so re-firing doesn't
|
|
351
|
+
// double up. Keeps the batch-level `nextCursor` untouched — this
|
|
352
|
+
// pagination is parallel to the batch-level main cursor.
|
|
353
|
+
async function onLoadBucketDocs(collection) {
|
|
354
|
+
if (loadingBucketRef.current.has(collection)) return;
|
|
355
|
+
loadingBucketRef.current.add(collection);
|
|
356
|
+
setBucketLoadingCount((n)=>n + 1);
|
|
357
|
+
const existingCursor = bucketCursorsRef.current.get(collection);
|
|
358
|
+
// If this bucket previously hit "no more pages" (cursor=null), the
|
|
359
|
+
// map still has the entry → skip. `has` distinguishes from
|
|
360
|
+
// `get === undefined` (never fetched).
|
|
361
|
+
if (bucketCursorsRef.current.has(collection) && existingCursor === null) {
|
|
362
|
+
loadingBucketRef.current.delete(collection);
|
|
363
|
+
setBucketLoadingCount((n)=>n - 1);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const params = new URLSearchParams({
|
|
367
|
+
limit: '100',
|
|
368
|
+
collection
|
|
369
|
+
});
|
|
370
|
+
if (existingCursor) params.set('cursor', existingCursor);
|
|
371
|
+
const apiStatus = chipToApiStatus(unitFilter);
|
|
372
|
+
if (apiStatus) params.set('status', apiStatus);
|
|
373
|
+
// ROUND2-1: the `collection=` segment already breaks the bare-`limit`
|
|
374
|
+
// heuristic, but we set `depth=0` defensively to keep all status
|
|
375
|
+
// URLs in the same shape — and so any future refactor that drops
|
|
376
|
+
// `collection=` doesn't reintroduce the regression.
|
|
377
|
+
params.set('depth', '0');
|
|
378
|
+
// v1.2.7: register an AbortController + capture the current filter
|
|
379
|
+
// epoch so the filter-change effect can abort in-flight requests
|
|
380
|
+
// before they pollute the new filter's bucket state. The dedup
|
|
381
|
+
// module currently doesn't forward the signal; we abort the
|
|
382
|
+
// controller anyway, and the epoch guard inside the success path
|
|
383
|
+
// drops the response if the filter changed while the request was
|
|
384
|
+
// riding the wire.
|
|
385
|
+
const controller = new AbortController();
|
|
386
|
+
bucketAbortControllersRef.current.set(collection, controller);
|
|
387
|
+
const requestEpoch = filterEpochRef.current;
|
|
388
|
+
try {
|
|
389
|
+
const res = await dedupedStatusFetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/status?${params}`);
|
|
390
|
+
// Filter changed while this request was in flight — drop the
|
|
391
|
+
// response so its rows don't bleed into the new filter view.
|
|
392
|
+
if (controller.signal.aborted || requestEpoch !== filterEpochRef.current) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (!res.ok) throw new Error(await readResponseError(res));
|
|
396
|
+
const json = await res.json();
|
|
397
|
+
if (controller.signal.aborted || requestEpoch !== filterEpochRef.current) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
bucketCursorsRef.current.set(collection, json.data.nextCursor);
|
|
401
|
+
loadedBucketsRef.current.add(collection);
|
|
402
|
+
setStatusData((prev)=>{
|
|
403
|
+
if (!prev) return json.data;
|
|
404
|
+
const seen = new Set(prev.jobs.map((j)=>j.unitId));
|
|
405
|
+
const newJobs = json.data.jobs.filter((j)=>!seen.has(j.unitId));
|
|
406
|
+
return {
|
|
407
|
+
...prev,
|
|
408
|
+
jobs: [
|
|
409
|
+
...prev.jobs,
|
|
410
|
+
...newJobs
|
|
411
|
+
]
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
} finally{
|
|
415
|
+
// Only clear the controller entry if it's still the latest one
|
|
416
|
+
// for this bucket — a later call may have overwritten it.
|
|
417
|
+
if (bucketAbortControllersRef.current.get(collection) === controller) {
|
|
418
|
+
bucketAbortControllersRef.current.delete(collection);
|
|
419
|
+
}
|
|
420
|
+
loadingBucketRef.current.delete(collection);
|
|
421
|
+
setBucketLoadingCount((n)=>n - 1);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Notification hook for BucketRow — tracks which buckets are expanded
|
|
426
|
+
* so the filter-change effect can re-fetch them under the new filter.
|
|
427
|
+
* Idempotent: collapsing a bucket then re-expanding refreshes the
|
|
428
|
+
* tracked state without piling duplicate fetches.
|
|
429
|
+
*/ function notifyBucketExpanded(collection, expanded) {
|
|
430
|
+
if (expanded) {
|
|
431
|
+
expandedBucketsRef.current.add(collection);
|
|
432
|
+
} else {
|
|
433
|
+
expandedBucketsRef.current.delete(collection);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function onCancel() {
|
|
437
|
+
setCancelling(true);
|
|
438
|
+
setCancelError(null);
|
|
439
|
+
try {
|
|
440
|
+
const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/cancel`, {
|
|
441
|
+
method: 'POST',
|
|
442
|
+
credentials: 'include'
|
|
443
|
+
});
|
|
444
|
+
if (!res.ok) throw new Error(await readResponseError(res));
|
|
445
|
+
onAfterAction();
|
|
446
|
+
} catch (e) {
|
|
447
|
+
setCancelError(e instanceof Error ? e.message : String(e));
|
|
448
|
+
} finally{
|
|
449
|
+
setCancelling(false);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function onRetryFailed() {
|
|
453
|
+
setRetryingFailed(true);
|
|
454
|
+
setRetryError(null);
|
|
455
|
+
try {
|
|
456
|
+
const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/retry`, {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
credentials: 'include',
|
|
459
|
+
headers: {
|
|
460
|
+
'Content-Type': 'application/json'
|
|
461
|
+
},
|
|
462
|
+
body: JSON.stringify({
|
|
463
|
+
allFailed: true
|
|
464
|
+
})
|
|
465
|
+
});
|
|
466
|
+
if (!res.ok) throw new Error(await readResponseError(res));
|
|
467
|
+
onAfterAction();
|
|
468
|
+
} catch (e) {
|
|
469
|
+
setRetryError(e instanceof Error ? e.message : String(e));
|
|
470
|
+
} finally{
|
|
471
|
+
setRetryingFailed(false);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// ----- Per-bucket retry -----
|
|
475
|
+
// Filed by the BucketRow inside the expanded DrillDown. Calls the
|
|
476
|
+
// existing retry-failed endpoint with a `collection` body field so
|
|
477
|
+
// only that bucket's failed units get requeued. Tracks a separate
|
|
478
|
+
// "currently retrying" collection slug so the right bucket button
|
|
479
|
+
// is the only one disabled while the request is in flight.
|
|
480
|
+
const [retryingBucket, setRetryingBucket] = useState(null);
|
|
481
|
+
async function onRetryBucket(collection) {
|
|
482
|
+
setRetryingBucket(collection);
|
|
483
|
+
setRetryError(null);
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/retry`, {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
credentials: 'include',
|
|
488
|
+
headers: {
|
|
489
|
+
'Content-Type': 'application/json'
|
|
490
|
+
},
|
|
491
|
+
body: JSON.stringify({
|
|
492
|
+
allFailed: true,
|
|
493
|
+
collection
|
|
494
|
+
})
|
|
495
|
+
});
|
|
496
|
+
if (!res.ok) throw new Error(await readResponseError(res));
|
|
497
|
+
onAfterAction();
|
|
498
|
+
} catch (e) {
|
|
499
|
+
setRetryError(e instanceof Error ? e.message : String(e));
|
|
500
|
+
} finally{
|
|
501
|
+
setRetryingBucket(null);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// ----- Per-doc retry -----
|
|
505
|
+
// Requested by user after the UX-designer-recommended omit was tried
|
|
506
|
+
// and felt insufficient. Retries every failed locale on a specific
|
|
507
|
+
// (collection, documentId). Tracks the in-flight doc as
|
|
508
|
+
// `${collection}/${documentId}` so the row's button is the only one
|
|
509
|
+
// disabled during the round-trip.
|
|
510
|
+
const [retryingDocKey, setRetryingDocKey] = useState(null);
|
|
511
|
+
async function onRetryDoc(collection, documentId) {
|
|
512
|
+
setRetryingDocKey(`${collection}/${documentId}`);
|
|
513
|
+
setRetryError(null);
|
|
514
|
+
try {
|
|
515
|
+
const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/${batch.id}/retry`, {
|
|
516
|
+
method: 'POST',
|
|
517
|
+
credentials: 'include',
|
|
518
|
+
headers: {
|
|
519
|
+
'Content-Type': 'application/json'
|
|
520
|
+
},
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
allFailed: true,
|
|
523
|
+
collection,
|
|
524
|
+
documentId
|
|
525
|
+
})
|
|
526
|
+
});
|
|
527
|
+
if (!res.ok) throw new Error(await readResponseError(res));
|
|
528
|
+
onAfterAction();
|
|
529
|
+
} catch (e) {
|
|
530
|
+
setRetryError(e instanceof Error ? e.message : String(e));
|
|
531
|
+
} finally{
|
|
532
|
+
setRetryingDocKey(null);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function toggleUnit(unitId) {
|
|
536
|
+
setExpandedUnits((prev)=>{
|
|
537
|
+
const next = new Set(prev);
|
|
538
|
+
if (next.has(unitId)) {
|
|
539
|
+
next.delete(unitId);
|
|
540
|
+
} else {
|
|
541
|
+
next.add(unitId);
|
|
542
|
+
}
|
|
543
|
+
return next;
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
const counts = statusData?.counts ?? {
|
|
547
|
+
total: batch.totalUnits,
|
|
548
|
+
pending: 0,
|
|
549
|
+
running: 0,
|
|
550
|
+
completed: batch.completedUnits,
|
|
551
|
+
failed: batch.failedUnits,
|
|
552
|
+
skipped: 0,
|
|
553
|
+
reverted: 0
|
|
554
|
+
};
|
|
555
|
+
// Revert affordance stripped from the UI per editor feedback ("I asked
|
|
556
|
+
// for retry IF something fails. as well as retry/doc"). The backend
|
|
557
|
+
// endpoint stays in case engineering ever needs it via the API.
|
|
558
|
+
// `canRevert`, `revertCountdown`, `RevertConfirmModal` are still imported
|
|
559
|
+
// and used by `BulkTranslateTerminalCard` in the older Translation Hub —
|
|
560
|
+
// not removed here to avoid breaking that surface in the same change.
|
|
561
|
+
return /*#__PURE__*/ _jsxs("div", {
|
|
562
|
+
style: {
|
|
563
|
+
padding: '1rem 1.25rem',
|
|
564
|
+
background: 'var(--theme-elevation-50)',
|
|
565
|
+
borderTop: '1px solid var(--theme-elevation-100)'
|
|
566
|
+
},
|
|
567
|
+
children: [
|
|
568
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
569
|
+
style: {
|
|
570
|
+
display: 'flex',
|
|
571
|
+
alignItems: 'flex-start',
|
|
572
|
+
justifyContent: 'space-between',
|
|
573
|
+
gap: '1rem',
|
|
574
|
+
marginBottom: '0.75rem',
|
|
575
|
+
flexWrap: 'wrap'
|
|
576
|
+
},
|
|
577
|
+
children: [
|
|
578
|
+
/*#__PURE__*/ _jsx("div", {
|
|
579
|
+
children: /*#__PURE__*/ _jsxs("p", {
|
|
580
|
+
style: {
|
|
581
|
+
margin: 0,
|
|
582
|
+
fontSize: '0.8125rem',
|
|
583
|
+
color: 'var(--theme-elevation-700)'
|
|
584
|
+
},
|
|
585
|
+
children: [
|
|
586
|
+
counts.completed,
|
|
587
|
+
" succeeded · ",
|
|
588
|
+
counts.failed,
|
|
589
|
+
" failed ·",
|
|
590
|
+
' ',
|
|
591
|
+
counts.pending + counts.running,
|
|
592
|
+
" pending/running · ",
|
|
593
|
+
counts.skipped,
|
|
594
|
+
" skipped"
|
|
595
|
+
]
|
|
596
|
+
})
|
|
597
|
+
}),
|
|
598
|
+
isActiveStatus(batch.status) && /*#__PURE__*/ _jsxs("div", {
|
|
599
|
+
style: {
|
|
600
|
+
display: 'flex',
|
|
601
|
+
flexDirection: 'column',
|
|
602
|
+
alignItems: 'flex-end',
|
|
603
|
+
gap: '0.25rem'
|
|
604
|
+
},
|
|
605
|
+
children: [
|
|
606
|
+
/*#__PURE__*/ _jsx("button", {
|
|
607
|
+
type: "button",
|
|
608
|
+
disabled: cancelling || batch.status === 'cancelling',
|
|
609
|
+
onClick: onCancel,
|
|
610
|
+
style: {
|
|
611
|
+
padding: '0.3rem 0.75rem',
|
|
612
|
+
background: 'transparent',
|
|
613
|
+
border: '1px solid var(--theme-elevation-300)',
|
|
614
|
+
borderRadius: '4px',
|
|
615
|
+
color: 'var(--theme-elevation-700)',
|
|
616
|
+
fontSize: '0.8125rem',
|
|
617
|
+
cursor: cancelling || batch.status === 'cancelling' ? 'not-allowed' : 'pointer'
|
|
618
|
+
},
|
|
619
|
+
children: batch.status === 'cancelling' ? 'Cancelling…' : 'Cancel run'
|
|
620
|
+
}),
|
|
621
|
+
cancelError && /*#__PURE__*/ _jsx("span", {
|
|
622
|
+
style: {
|
|
623
|
+
fontSize: '0.75rem',
|
|
624
|
+
color: 'var(--theme-error-500, #b91c1c)'
|
|
625
|
+
},
|
|
626
|
+
children: cancelError
|
|
627
|
+
})
|
|
628
|
+
]
|
|
629
|
+
})
|
|
630
|
+
]
|
|
631
|
+
}),
|
|
632
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
633
|
+
role: "group",
|
|
634
|
+
"aria-label": "Filter units inside this batch by status",
|
|
635
|
+
style: {
|
|
636
|
+
display: 'flex',
|
|
637
|
+
alignItems: 'center',
|
|
638
|
+
gap: '0.5rem',
|
|
639
|
+
flexWrap: 'wrap',
|
|
640
|
+
marginBottom: '0.75rem'
|
|
641
|
+
},
|
|
642
|
+
children: [
|
|
643
|
+
/*#__PURE__*/ _jsx("span", {
|
|
644
|
+
style: {
|
|
645
|
+
fontSize: '0.6875rem',
|
|
646
|
+
fontWeight: 600,
|
|
647
|
+
textTransform: 'uppercase',
|
|
648
|
+
letterSpacing: '0.05em',
|
|
649
|
+
color: 'var(--theme-elevation-500)',
|
|
650
|
+
marginRight: '0.25rem'
|
|
651
|
+
},
|
|
652
|
+
children: "Units in this batch"
|
|
653
|
+
}),
|
|
654
|
+
UNIT_FILTER_CHIPS.map((chip)=>{
|
|
655
|
+
const isActive = unitFilter === chip;
|
|
656
|
+
const colors = filterPillColors(isActive);
|
|
657
|
+
const count = chip === 'all' ? counts.total : chip === 'completed' ? counts.completed : counts[chip];
|
|
658
|
+
return /*#__PURE__*/ _jsxs("button", {
|
|
659
|
+
"aria-pressed": isActive,
|
|
660
|
+
type: "button",
|
|
661
|
+
onClick: ()=>setUnitFilter(chip),
|
|
662
|
+
style: {
|
|
663
|
+
display: 'inline-flex',
|
|
664
|
+
alignItems: 'center',
|
|
665
|
+
gap: '0.3rem',
|
|
666
|
+
padding: '0.2rem 0.55rem',
|
|
667
|
+
fontSize: '0.75rem',
|
|
668
|
+
borderRadius: '4px',
|
|
669
|
+
border: colors.border,
|
|
670
|
+
background: colors.background,
|
|
671
|
+
color: colors.color,
|
|
672
|
+
cursor: 'pointer',
|
|
673
|
+
fontWeight: isActive ? 600 : 400
|
|
674
|
+
},
|
|
675
|
+
children: [
|
|
676
|
+
CHIP_LABELS[chip],
|
|
677
|
+
count !== undefined && count >= 0 && /*#__PURE__*/ _jsx("span", {
|
|
678
|
+
style: {
|
|
679
|
+
padding: '0 0.3rem',
|
|
680
|
+
// Inner count badge: contrast with the pill body, not
|
|
681
|
+
// the page. Inverted on active pills (light badge on
|
|
682
|
+
// dark pill) so the count stays legible against the
|
|
683
|
+
// near-black background.
|
|
684
|
+
background: isActive ? 'var(--theme-elevation-800)' : 'var(--theme-elevation-150)',
|
|
685
|
+
color: isActive ? 'var(--theme-elevation-50)' : 'var(--theme-elevation-800)',
|
|
686
|
+
borderRadius: '10px',
|
|
687
|
+
fontSize: '0.65rem',
|
|
688
|
+
fontWeight: 600
|
|
689
|
+
},
|
|
690
|
+
children: count
|
|
691
|
+
})
|
|
692
|
+
]
|
|
693
|
+
}, chip);
|
|
694
|
+
})
|
|
695
|
+
]
|
|
696
|
+
}),
|
|
697
|
+
statusError && /*#__PURE__*/ _jsxs("p", {
|
|
698
|
+
style: {
|
|
699
|
+
margin: '0 0 0.5rem',
|
|
700
|
+
fontSize: '0.8125rem',
|
|
701
|
+
color: 'var(--theme-error-500, #b91c1c)'
|
|
702
|
+
},
|
|
703
|
+
children: [
|
|
704
|
+
"Failed to load unit detail: ",
|
|
705
|
+
statusError
|
|
706
|
+
]
|
|
707
|
+
}),
|
|
708
|
+
statusData && /*#__PURE__*/ _jsxs("div", {
|
|
709
|
+
style: {
|
|
710
|
+
overflowX: 'auto'
|
|
711
|
+
},
|
|
712
|
+
children: [
|
|
713
|
+
/*#__PURE__*/ _jsx("table", {
|
|
714
|
+
style: {
|
|
715
|
+
width: '100%',
|
|
716
|
+
borderCollapse: 'collapse',
|
|
717
|
+
fontSize: '0.8125rem',
|
|
718
|
+
color: 'var(--theme-elevation-800)'
|
|
719
|
+
},
|
|
720
|
+
children: /*#__PURE__*/ _jsx("tbody", {
|
|
721
|
+
children: (()=>{
|
|
722
|
+
// v1.2.7: filter-aware bucket list. Build the full
|
|
723
|
+
// bucket set, then hide buckets that have zero docs
|
|
724
|
+
// under the active filter so the bucket header doesn't
|
|
725
|
+
// contradict the doc rows. When the filter is `all`,
|
|
726
|
+
// every bucket renders.
|
|
727
|
+
const allBuckets = groupJobsIntoBuckets(statusData.jobs, statusData.countsByCollection ?? {});
|
|
728
|
+
const visibleBuckets = allBuckets.filter((b)=>shouldBucketBeVisibleUnderFilter(b, unitFilter));
|
|
729
|
+
return visibleBuckets.map((bucket)=>{
|
|
730
|
+
// Per-bucket pagination state. `bucketCursor`:
|
|
731
|
+
// undefined → never fetched (auto-load fires on
|
|
732
|
+
// first expand)
|
|
733
|
+
// string → more pages available (Load More)
|
|
734
|
+
// null → no more pages (Load More hidden)
|
|
735
|
+
const bucketCursor = bucketCursorsRef.current.get(bucket.collection);
|
|
736
|
+
const hasMoreInBucket = bucket.loadedDocs < bucket.totalDocs && (bucketCursor === undefined || bucketCursor !== null);
|
|
737
|
+
const isLoadingMoreInBucket = loadingBucketRef.current.has(bucket.collection);
|
|
738
|
+
return /*#__PURE__*/ _jsx(BucketRow, {
|
|
739
|
+
activeStatusFilter: unitFilter,
|
|
740
|
+
basePath: basePath,
|
|
741
|
+
batchId: String(batch.id),
|
|
742
|
+
batchStatus: batch.status,
|
|
743
|
+
bucket: bucket,
|
|
744
|
+
initialExpanded: shouldBucketBeExpandedByDefault(bucket),
|
|
745
|
+
isRetrying: retryingBucket === bucket.collection,
|
|
746
|
+
onRetryBucket: onRetryBucket,
|
|
747
|
+
retryingDocKey: retryingDocKey,
|
|
748
|
+
onRetryDoc: onRetryDoc,
|
|
749
|
+
onLoadBucketDocs: onLoadBucketDocs,
|
|
750
|
+
onExpandedChange: notifyBucketExpanded,
|
|
751
|
+
hasMoreInBucket: hasMoreInBucket,
|
|
752
|
+
isLoadingMoreInBucket: isLoadingMoreInBucket
|
|
753
|
+
}, bucket.collection);
|
|
754
|
+
});
|
|
755
|
+
})()
|
|
756
|
+
})
|
|
757
|
+
}),
|
|
758
|
+
(()=>{
|
|
759
|
+
// v1.2.7: the global "No units match" message ONLY renders
|
|
760
|
+
// when EVERY bucket is hidden under the active filter.
|
|
761
|
+
// Previously it rendered any time the top-level
|
|
762
|
+
// `jobs.length === 0` — which fired even when the bucket
|
|
763
|
+
// headers above were still showing pre-filter unit counts
|
|
764
|
+
// (the contradiction Bug 1 / Bug 3). Now the empty state
|
|
765
|
+
// is the sole signal when the filter excludes everything.
|
|
766
|
+
const allBuckets = groupJobsIntoBuckets(statusData.jobs, statusData.countsByCollection ?? {});
|
|
767
|
+
const anyVisible = allBuckets.some((b)=>shouldBucketBeVisibleUnderFilter(b, unitFilter));
|
|
768
|
+
if (anyVisible) return null;
|
|
769
|
+
const label = unitFilter === 'all' ? '' : CHIP_LABELS[unitFilter];
|
|
770
|
+
return /*#__PURE__*/ _jsx("p", {
|
|
771
|
+
style: {
|
|
772
|
+
padding: '0.75rem 0',
|
|
773
|
+
margin: 0,
|
|
774
|
+
fontSize: '0.8125rem',
|
|
775
|
+
color: 'var(--theme-elevation-500)'
|
|
776
|
+
},
|
|
777
|
+
children: unitFilter === 'all' ? 'No units in this batch.' : `No ${label.toLowerCase()} units in this batch.`
|
|
778
|
+
});
|
|
779
|
+
})()
|
|
780
|
+
]
|
|
781
|
+
}),
|
|
782
|
+
batch.failedUnits > 0 && !isActiveStatus(batch.status) && /*#__PURE__*/ _jsxs("div", {
|
|
783
|
+
style: {
|
|
784
|
+
marginTop: '0.75rem',
|
|
785
|
+
display: 'flex',
|
|
786
|
+
alignItems: 'center',
|
|
787
|
+
gap: '0.75rem'
|
|
788
|
+
},
|
|
789
|
+
children: [
|
|
790
|
+
/*#__PURE__*/ _jsx("button", {
|
|
791
|
+
type: "button",
|
|
792
|
+
disabled: retryingFailed,
|
|
793
|
+
onClick: onRetryFailed,
|
|
794
|
+
style: {
|
|
795
|
+
padding: '0.35rem 0.75rem',
|
|
796
|
+
background: 'transparent',
|
|
797
|
+
border: '1px solid var(--theme-elevation-300)',
|
|
798
|
+
borderRadius: '4px',
|
|
799
|
+
color: 'var(--theme-elevation-800)',
|
|
800
|
+
fontSize: '0.8125rem',
|
|
801
|
+
cursor: retryingFailed ? 'not-allowed' : 'pointer'
|
|
802
|
+
},
|
|
803
|
+
children: retryingFailed ? 'Retrying…' : `Retry ${batch.failedUnits} failed unit${batch.failedUnits === 1 ? '' : 's'}`
|
|
804
|
+
}),
|
|
805
|
+
retryError && /*#__PURE__*/ _jsx("span", {
|
|
806
|
+
style: {
|
|
807
|
+
fontSize: '0.75rem',
|
|
808
|
+
color: 'var(--theme-error-500, #b91c1c)'
|
|
809
|
+
},
|
|
810
|
+
children: retryError
|
|
811
|
+
})
|
|
812
|
+
]
|
|
813
|
+
}),
|
|
814
|
+
revertOpen && /*#__PURE__*/ _jsx(RevertConfirmModal, {
|
|
815
|
+
basePath: basePath,
|
|
816
|
+
batchId: batch.id,
|
|
817
|
+
onClose: ()=>setRevertOpen(false),
|
|
818
|
+
onSuccess: ()=>{
|
|
819
|
+
setRevertOpen(false);
|
|
820
|
+
onAfterAction();
|
|
821
|
+
}
|
|
822
|
+
})
|
|
823
|
+
]
|
|
824
|
+
});
|
|
825
|
+
};
|
|
826
|
+
const UnitRow = ({ basePath, job, isExpanded, onToggle })=>{
|
|
827
|
+
const hasFailed = job.status === 'failed';
|
|
828
|
+
const unitDocHref = job.collection && job.documentId ? docHref(basePath, job.collection, job.documentId, job.locale) : null;
|
|
829
|
+
// Editor-facing duration is the provider's actual LLM call time, not
|
|
830
|
+
// the wallclock delta — that would include throttle wait. Falls back
|
|
831
|
+
// to wallclock for legacy rows that lack the column.
|
|
832
|
+
const duration = job.processingDurationMs != null ? formatDuration(job.processingDurationMs) : fmtWallclock(job.startedAt, job.completedAt);
|
|
833
|
+
const durationTooltip = job.processingDurationMs != null ? 'AI call time. Excludes throttle wait — the batch took longer overall because of rate-limit queuing.' : 'Legacy row: showing wallclock between worker pickup and completion. Includes throttle wait.';
|
|
834
|
+
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
835
|
+
children: [
|
|
836
|
+
/*#__PURE__*/ _jsxs("tr", {
|
|
837
|
+
onClick: hasFailed ? onToggle : undefined,
|
|
838
|
+
style: {
|
|
839
|
+
cursor: hasFailed ? 'pointer' : undefined,
|
|
840
|
+
background: isExpanded ? 'var(--theme-elevation-100)' : undefined
|
|
841
|
+
},
|
|
842
|
+
children: [
|
|
843
|
+
/*#__PURE__*/ _jsx("td", {
|
|
844
|
+
style: TD_STYLE,
|
|
845
|
+
children: /*#__PURE__*/ _jsx("code", {
|
|
846
|
+
style: {
|
|
847
|
+
fontSize: '0.75rem'
|
|
848
|
+
},
|
|
849
|
+
children: job.collection || '—'
|
|
850
|
+
})
|
|
851
|
+
}),
|
|
852
|
+
/*#__PURE__*/ _jsxs("td", {
|
|
853
|
+
style: TD_STYLE,
|
|
854
|
+
children: [
|
|
855
|
+
hasFailed && /*#__PURE__*/ _jsx("span", {
|
|
856
|
+
"aria-hidden": "true",
|
|
857
|
+
style: {
|
|
858
|
+
display: 'inline-block',
|
|
859
|
+
width: '1rem',
|
|
860
|
+
color: 'var(--theme-elevation-500)',
|
|
861
|
+
fontSize: '0.6rem',
|
|
862
|
+
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
863
|
+
transition: 'transform 80ms ease',
|
|
864
|
+
marginRight: '0.25rem'
|
|
865
|
+
},
|
|
866
|
+
children: "▶"
|
|
867
|
+
}),
|
|
868
|
+
unitDocHref ? /*#__PURE__*/ _jsx("a", {
|
|
869
|
+
href: unitDocHref,
|
|
870
|
+
onClick: (e)=>e.stopPropagation(),
|
|
871
|
+
rel: "noopener noreferrer",
|
|
872
|
+
style: {
|
|
873
|
+
fontFamily: 'monospace',
|
|
874
|
+
fontSize: '0.75rem',
|
|
875
|
+
color: 'var(--theme-success-500, #16a34a)',
|
|
876
|
+
textDecoration: 'none'
|
|
877
|
+
},
|
|
878
|
+
target: "_blank",
|
|
879
|
+
title: `Open ${job.collection} #${job.documentId} in ${job.locale}`,
|
|
880
|
+
children: truncateId(job.documentId)
|
|
881
|
+
}) : /*#__PURE__*/ _jsx("span", {
|
|
882
|
+
title: job.documentId,
|
|
883
|
+
style: {
|
|
884
|
+
fontFamily: 'monospace',
|
|
885
|
+
fontSize: '0.75rem'
|
|
886
|
+
},
|
|
887
|
+
children: truncateId(job.documentId)
|
|
888
|
+
})
|
|
889
|
+
]
|
|
890
|
+
}),
|
|
891
|
+
/*#__PURE__*/ _jsx("td", {
|
|
892
|
+
style: TD_STYLE,
|
|
893
|
+
children: /*#__PURE__*/ _jsx("span", {
|
|
894
|
+
style: {
|
|
895
|
+
display: 'inline-block',
|
|
896
|
+
padding: '0.1rem 0.35rem',
|
|
897
|
+
background: 'var(--theme-elevation-100)',
|
|
898
|
+
borderRadius: '4px',
|
|
899
|
+
fontSize: '0.7rem',
|
|
900
|
+
fontFamily: 'monospace',
|
|
901
|
+
color: 'var(--theme-elevation-800)'
|
|
902
|
+
},
|
|
903
|
+
children: job.locale
|
|
904
|
+
})
|
|
905
|
+
}),
|
|
906
|
+
/*#__PURE__*/ _jsx("td", {
|
|
907
|
+
style: TD_STYLE,
|
|
908
|
+
children: /*#__PURE__*/ _jsx(UnitStatusBadge, {
|
|
909
|
+
status: job.status,
|
|
910
|
+
small: true
|
|
911
|
+
})
|
|
912
|
+
}),
|
|
913
|
+
/*#__PURE__*/ _jsx("td", {
|
|
914
|
+
style: {
|
|
915
|
+
...TD_STYLE,
|
|
916
|
+
textAlign: 'right',
|
|
917
|
+
color: 'var(--theme-elevation-600)'
|
|
918
|
+
},
|
|
919
|
+
children: job.attempts
|
|
920
|
+
}),
|
|
921
|
+
/*#__PURE__*/ _jsx("td", {
|
|
922
|
+
style: {
|
|
923
|
+
...TD_STYLE,
|
|
924
|
+
textAlign: 'right',
|
|
925
|
+
fontFamily: 'monospace',
|
|
926
|
+
fontSize: '0.8125rem'
|
|
927
|
+
},
|
|
928
|
+
children: formatCost(job.costUsd)
|
|
929
|
+
}),
|
|
930
|
+
/*#__PURE__*/ _jsx("td", {
|
|
931
|
+
style: {
|
|
932
|
+
...TD_STYLE,
|
|
933
|
+
textAlign: 'right',
|
|
934
|
+
color: 'var(--theme-elevation-600)'
|
|
935
|
+
},
|
|
936
|
+
title: durationTooltip,
|
|
937
|
+
children: duration
|
|
938
|
+
})
|
|
939
|
+
]
|
|
940
|
+
}),
|
|
941
|
+
isExpanded && hasFailed && /*#__PURE__*/ _jsx("tr", {
|
|
942
|
+
children: /*#__PURE__*/ _jsxs("td", {
|
|
943
|
+
colSpan: 7,
|
|
944
|
+
style: {
|
|
945
|
+
...TD_STYLE,
|
|
946
|
+
background: 'var(--theme-elevation-50)',
|
|
947
|
+
whiteSpace: 'normal',
|
|
948
|
+
padding: '0.75rem 1.25rem',
|
|
949
|
+
borderTop: 'none'
|
|
950
|
+
},
|
|
951
|
+
children: [
|
|
952
|
+
(()=>{
|
|
953
|
+
// NEW-12 (v1.2.6): route through `categorizeFailure` so
|
|
954
|
+
// cancel-mid-flight, mis-classified config-as-provider
|
|
955
|
+
// (COST_UNDEFINED), and bare "Not Found" rows all surface
|
|
956
|
+
// the right copy instead of the catch-all "Translation
|
|
957
|
+
// system is not set up" message. UnitRow doesn't currently
|
|
958
|
+
// get the parent batch status — pass `undefined` so the
|
|
959
|
+
// categorizer only flips to `cancelled-mid-flight` when
|
|
960
|
+
// we wire batchStatus through (call sites must opt in).
|
|
961
|
+
if (!job.failureCode && !job.failureMessage) return null;
|
|
962
|
+
const categorized = categorizeFailure({
|
|
963
|
+
failureCode: job.failureCode,
|
|
964
|
+
failureMessage: job.failureMessage,
|
|
965
|
+
attemptCount: job.attempts
|
|
966
|
+
});
|
|
967
|
+
return /*#__PURE__*/ _jsx("div", {
|
|
968
|
+
style: {
|
|
969
|
+
margin: '0 0 0.5rem'
|
|
970
|
+
},
|
|
971
|
+
children: /*#__PURE__*/ _jsx(EditorError, {
|
|
972
|
+
code: categorized.editorCode,
|
|
973
|
+
compact: true,
|
|
974
|
+
details: categorized.details
|
|
975
|
+
})
|
|
976
|
+
});
|
|
977
|
+
})(),
|
|
978
|
+
/*#__PURE__*/ _jsxs("p", {
|
|
979
|
+
style: {
|
|
980
|
+
margin: '0 0 0.4rem',
|
|
981
|
+
fontSize: '0.75rem',
|
|
982
|
+
color: 'var(--theme-elevation-600)'
|
|
983
|
+
},
|
|
984
|
+
children: [
|
|
985
|
+
"Attempts: ",
|
|
986
|
+
job.attempts
|
|
987
|
+
]
|
|
988
|
+
}),
|
|
989
|
+
unitDocHref && /*#__PURE__*/ _jsx("a", {
|
|
990
|
+
href: unitDocHref,
|
|
991
|
+
rel: "noopener noreferrer",
|
|
992
|
+
style: {
|
|
993
|
+
fontSize: '0.75rem',
|
|
994
|
+
color: 'var(--theme-success-500, #16a34a)',
|
|
995
|
+
textDecoration: 'none'
|
|
996
|
+
},
|
|
997
|
+
target: "_blank",
|
|
998
|
+
children: "Open in Payload →"
|
|
999
|
+
})
|
|
1000
|
+
]
|
|
1001
|
+
})
|
|
1002
|
+
})
|
|
1003
|
+
]
|
|
1004
|
+
});
|
|
1005
|
+
};
|
|
1006
|
+
export const BatchRow = ({ basePath, batch, isExpanded, onToggle, onAfterAction, colCount })=>{
|
|
1007
|
+
const isActive = isActiveStatus(batch.status);
|
|
1008
|
+
const progressDescriptor = describeBatchProgress(batch.completedUnits, batch.totalUnits);
|
|
1009
|
+
const countdown = revertCountdown(batch.revertExpiresAt);
|
|
1010
|
+
const showRowRevertNote = countdown !== null && batch.status !== 'reverted' && batch.status !== 'cancelled';
|
|
1011
|
+
// Minute ticker for revert-countdown string — updates every 60s.
|
|
1012
|
+
const [, setTick] = useState(0);
|
|
1013
|
+
useEffect(()=>{
|
|
1014
|
+
if (!showRowRevertNote) return;
|
|
1015
|
+
const id = setInterval(()=>setTick((n)=>n + 1), 60_000);
|
|
1016
|
+
return ()=>clearInterval(id);
|
|
1017
|
+
}, [
|
|
1018
|
+
showRowRevertNote
|
|
1019
|
+
]);
|
|
1020
|
+
// Click on the row (outside interactive elements) toggles drill-down.
|
|
1021
|
+
function handleRowClick(e) {
|
|
1022
|
+
const target = e.target;
|
|
1023
|
+
if (target.closest('a, button, input, select, textarea')) return;
|
|
1024
|
+
onToggle();
|
|
1025
|
+
}
|
|
1026
|
+
const rowStyle = {
|
|
1027
|
+
cursor: 'pointer',
|
|
1028
|
+
background: isActive ? 'var(--theme-elevation-50)' : isExpanded ? 'var(--theme-elevation-100)' : undefined,
|
|
1029
|
+
transition: 'background 100ms ease'
|
|
1030
|
+
};
|
|
1031
|
+
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
1032
|
+
children: [
|
|
1033
|
+
/*#__PURE__*/ _jsxs("tr", {
|
|
1034
|
+
onClick: handleRowClick,
|
|
1035
|
+
style: rowStyle,
|
|
1036
|
+
children: [
|
|
1037
|
+
/*#__PURE__*/ _jsx("td", {
|
|
1038
|
+
style: {
|
|
1039
|
+
...TD_STYLE,
|
|
1040
|
+
width: '140px',
|
|
1041
|
+
paddingLeft: isActive ? '0' : TD_STYLE.padding,
|
|
1042
|
+
borderLeft: isActive ? '3px solid var(--theme-success-500, #16a34a)' : undefined
|
|
1043
|
+
},
|
|
1044
|
+
children: /*#__PURE__*/ _jsx("div", {
|
|
1045
|
+
style: {
|
|
1046
|
+
paddingLeft: isActive ? '0.35rem' : undefined
|
|
1047
|
+
},
|
|
1048
|
+
children: /*#__PURE__*/ _jsx(StatusBadge, {
|
|
1049
|
+
status: batch.status
|
|
1050
|
+
})
|
|
1051
|
+
})
|
|
1052
|
+
}),
|
|
1053
|
+
/*#__PURE__*/ _jsx("td", {
|
|
1054
|
+
style: {
|
|
1055
|
+
...TD_STYLE,
|
|
1056
|
+
minWidth: '160px'
|
|
1057
|
+
},
|
|
1058
|
+
children: /*#__PURE__*/ _jsxs("div", {
|
|
1059
|
+
style: {
|
|
1060
|
+
display: 'flex',
|
|
1061
|
+
alignItems: 'center',
|
|
1062
|
+
gap: '0.35rem'
|
|
1063
|
+
},
|
|
1064
|
+
children: [
|
|
1065
|
+
/*#__PURE__*/ _jsx("span", {
|
|
1066
|
+
"aria-hidden": "true",
|
|
1067
|
+
style: {
|
|
1068
|
+
fontSize: '0.6rem',
|
|
1069
|
+
color: 'var(--theme-elevation-400)',
|
|
1070
|
+
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
1071
|
+
display: 'inline-block',
|
|
1072
|
+
transition: 'transform 80ms ease'
|
|
1073
|
+
},
|
|
1074
|
+
children: "▶"
|
|
1075
|
+
}),
|
|
1076
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
1077
|
+
children: [
|
|
1078
|
+
/*#__PURE__*/ _jsx("span", {
|
|
1079
|
+
style: {
|
|
1080
|
+
display: 'block',
|
|
1081
|
+
fontFamily: 'monospace',
|
|
1082
|
+
fontSize: '0.75rem',
|
|
1083
|
+
color: 'var(--theme-elevation-900)'
|
|
1084
|
+
},
|
|
1085
|
+
title: batch.id,
|
|
1086
|
+
children: truncateId(batch.id, 14)
|
|
1087
|
+
}),
|
|
1088
|
+
batch.triggeredByEmail && /*#__PURE__*/ _jsx("span", {
|
|
1089
|
+
style: {
|
|
1090
|
+
display: 'block',
|
|
1091
|
+
fontSize: '0.7rem',
|
|
1092
|
+
color: 'var(--theme-elevation-500)',
|
|
1093
|
+
marginTop: '0.1rem'
|
|
1094
|
+
},
|
|
1095
|
+
children: batch.triggeredByEmail
|
|
1096
|
+
})
|
|
1097
|
+
]
|
|
1098
|
+
})
|
|
1099
|
+
]
|
|
1100
|
+
})
|
|
1101
|
+
}),
|
|
1102
|
+
/*#__PURE__*/ _jsx("td", {
|
|
1103
|
+
style: {
|
|
1104
|
+
...TD_STYLE,
|
|
1105
|
+
width: '120px',
|
|
1106
|
+
color: 'var(--theme-elevation-600)',
|
|
1107
|
+
fontSize: '0.75rem'
|
|
1108
|
+
},
|
|
1109
|
+
children: /*#__PURE__*/ _jsx("code", {
|
|
1110
|
+
children: batch.mode
|
|
1111
|
+
})
|
|
1112
|
+
}),
|
|
1113
|
+
/*#__PURE__*/ _jsx("td", {
|
|
1114
|
+
style: {
|
|
1115
|
+
...TD_STYLE,
|
|
1116
|
+
width: '160px'
|
|
1117
|
+
},
|
|
1118
|
+
children: progressDescriptor.kind === 'empty' ? /*#__PURE__*/ _jsx("span", {
|
|
1119
|
+
style: {
|
|
1120
|
+
fontSize: '0.8rem',
|
|
1121
|
+
color: 'var(--theme-elevation-600)',
|
|
1122
|
+
fontStyle: 'italic'
|
|
1123
|
+
},
|
|
1124
|
+
title: "This batch finished without enqueueing any units — typically a cancelled-before-start run or an empty-scope selection.",
|
|
1125
|
+
children: "No units enqueued"
|
|
1126
|
+
}) : /*#__PURE__*/ _jsxs("div", {
|
|
1127
|
+
style: {
|
|
1128
|
+
display: 'flex',
|
|
1129
|
+
alignItems: 'center',
|
|
1130
|
+
gap: '0.4rem'
|
|
1131
|
+
},
|
|
1132
|
+
children: [
|
|
1133
|
+
/*#__PURE__*/ _jsxs("span", {
|
|
1134
|
+
style: {
|
|
1135
|
+
fontSize: '0.8rem',
|
|
1136
|
+
color: 'var(--theme-elevation-800)'
|
|
1137
|
+
},
|
|
1138
|
+
children: [
|
|
1139
|
+
progressDescriptor.completed,
|
|
1140
|
+
" / ",
|
|
1141
|
+
progressDescriptor.total
|
|
1142
|
+
]
|
|
1143
|
+
}),
|
|
1144
|
+
/*#__PURE__*/ _jsx("div", {
|
|
1145
|
+
role: "progressbar",
|
|
1146
|
+
"aria-valuemin": 0,
|
|
1147
|
+
"aria-valuemax": progressDescriptor.total,
|
|
1148
|
+
"aria-valuenow": progressDescriptor.completed,
|
|
1149
|
+
"aria-label": `${progressDescriptor.completed} of ${progressDescriptor.total} units`,
|
|
1150
|
+
style: PROGRESS_TRACK_STYLE,
|
|
1151
|
+
children: /*#__PURE__*/ _jsx("div", {
|
|
1152
|
+
style: {
|
|
1153
|
+
width: `${progressDescriptor.percent}%`,
|
|
1154
|
+
height: '100%',
|
|
1155
|
+
background: 'var(--theme-success-500, #16a34a)'
|
|
1156
|
+
}
|
|
1157
|
+
})
|
|
1158
|
+
})
|
|
1159
|
+
]
|
|
1160
|
+
})
|
|
1161
|
+
}),
|
|
1162
|
+
/*#__PURE__*/ _jsx("td", {
|
|
1163
|
+
style: {
|
|
1164
|
+
...TD_STYLE,
|
|
1165
|
+
width: '80px',
|
|
1166
|
+
textAlign: 'right',
|
|
1167
|
+
fontFamily: 'monospace',
|
|
1168
|
+
fontSize: '0.8125rem'
|
|
1169
|
+
},
|
|
1170
|
+
children: formatCost(batch.actualCostUsd ?? batch.estimatedCostUsd)
|
|
1171
|
+
}),
|
|
1172
|
+
/*#__PURE__*/ _jsxs("td", {
|
|
1173
|
+
style: {
|
|
1174
|
+
...TD_STYLE,
|
|
1175
|
+
width: '70px',
|
|
1176
|
+
textAlign: 'right',
|
|
1177
|
+
color: 'var(--theme-elevation-600)'
|
|
1178
|
+
},
|
|
1179
|
+
title: "Wallclock from when the worker first picked up this batch to when it finished. Includes time spent queued waiting for rate-limit slots — expand the row for an AI-active vs queue-wait split per bucket.",
|
|
1180
|
+
children: [
|
|
1181
|
+
fmtWallclock(batch.startedAt ?? null, batch.completedAt ?? null),
|
|
1182
|
+
/*#__PURE__*/ _jsx("span", {
|
|
1183
|
+
"aria-hidden": "true",
|
|
1184
|
+
style: {
|
|
1185
|
+
marginLeft: '0.25rem',
|
|
1186
|
+
color: 'var(--theme-elevation-400)',
|
|
1187
|
+
fontSize: '0.65rem',
|
|
1188
|
+
cursor: 'help'
|
|
1189
|
+
},
|
|
1190
|
+
children: "ⓘ"
|
|
1191
|
+
})
|
|
1192
|
+
]
|
|
1193
|
+
}),
|
|
1194
|
+
/*#__PURE__*/ _jsx("td", {
|
|
1195
|
+
style: {
|
|
1196
|
+
...TD_STYLE,
|
|
1197
|
+
width: '90px',
|
|
1198
|
+
color: 'var(--theme-elevation-500)',
|
|
1199
|
+
fontSize: '0.75rem'
|
|
1200
|
+
},
|
|
1201
|
+
title: new Date(batch.createdAt).toISOString(),
|
|
1202
|
+
children: relTime(batch.createdAt)
|
|
1203
|
+
})
|
|
1204
|
+
]
|
|
1205
|
+
}),
|
|
1206
|
+
isExpanded && /*#__PURE__*/ _jsx("tr", {
|
|
1207
|
+
children: /*#__PURE__*/ _jsx("td", {
|
|
1208
|
+
colSpan: colCount,
|
|
1209
|
+
style: {
|
|
1210
|
+
padding: 0,
|
|
1211
|
+
borderTop: 'none'
|
|
1212
|
+
},
|
|
1213
|
+
children: /*#__PURE__*/ _jsx(DrillDown, {
|
|
1214
|
+
basePath: basePath,
|
|
1215
|
+
batch: batch,
|
|
1216
|
+
onAfterAction: onAfterAction
|
|
1217
|
+
})
|
|
1218
|
+
})
|
|
1219
|
+
})
|
|
1220
|
+
]
|
|
1221
|
+
});
|
|
1222
|
+
};
|