@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,251 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* Shared polling hook for the bulk-translate "active batch" endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Owns the 5s visibility-aware poll loop, the offline-banner threshold
|
|
6
|
+
* (F-UX-11: 3 consecutive failures → degraded), and the multi-admin
|
|
7
|
+
* synchronization expected by F-UX-05. The trigger, monitor, and
|
|
8
|
+
* terminal card all subscribe to the same single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* A 404 from the endpoint is treated as "no active batch" — the
|
|
11
|
+
* endpoints are being built in parallel and we don't want the Hub to
|
|
12
|
+
* crash if a consumer integrates the UI before the backend lands.
|
|
13
|
+
*
|
|
14
|
+
* NEW-5 (v1.2.6): the hook previously owned its own `useEffect` +
|
|
15
|
+
* `setInterval` per mount. The Hub renders three consumers in parallel
|
|
16
|
+
* (`Hub.client.tsx` for the monitor/terminal card,
|
|
17
|
+
* `BulkTranslateTrigger.tsx` for the "is a run already active?" check,
|
|
18
|
+
* `BulkTranslatePostEnqueueTransition.tsx` after the modal closes), so
|
|
19
|
+
* the network panel showed pairs of requests every ~5s. The hook now
|
|
20
|
+
* delegates polling to a module-level singleton — one subscriber per
|
|
21
|
+
* `basePath`, ref-counted so the interval shuts down cleanly when the
|
|
22
|
+
* last consumer unmounts — and components receive the shared state via
|
|
23
|
+
* `useSyncExternalStore`. Single request per tick regardless of how
|
|
24
|
+
* many components subscribe.
|
|
25
|
+
*/ import { useCallback, useSyncExternalStore } from 'react';
|
|
26
|
+
import { readResponseError } from '../shared/fetch-error-body.js';
|
|
27
|
+
// Active polling cadence — used while a bulk batch is in flight. The
|
|
28
|
+
// trigger / monitor / terminal cards all expect ~5s updates so the
|
|
29
|
+
// progress bar and per-doc status feels live.
|
|
30
|
+
const POLL_INTERVAL_MS = 5000;
|
|
31
|
+
// Idle cadence — used when there is no active batch. Pre-1.2.8 the
|
|
32
|
+
// poll re-queued at POLL_INTERVAL_MS unconditionally, so any admin
|
|
33
|
+
// session with the Translation Hub mounted (or any view that uses
|
|
34
|
+
// `useBulkTranslateActive` — the Bulk Translate trigger button does)
|
|
35
|
+
// kept hitting `/api/translation-hub/bulk-translate/active` every 5s
|
|
36
|
+
// forever. Backing off to 60s when idle still gives the trigger
|
|
37
|
+
// reasonable cross-tab freshness (if user A starts a run, user B's
|
|
38
|
+
// idle tab sees it within a minute) without the constant background
|
|
39
|
+
// chatter.
|
|
40
|
+
const IDLE_POLL_INTERVAL_MS = 60_000;
|
|
41
|
+
const OFFLINE_AFTER_FAILED_POLLS = 3;
|
|
42
|
+
const registry = new Map();
|
|
43
|
+
function getEntry(basePath) {
|
|
44
|
+
let entry = registry.get(basePath);
|
|
45
|
+
if (entry) return entry;
|
|
46
|
+
entry = {
|
|
47
|
+
state: {
|
|
48
|
+
data: null,
|
|
49
|
+
loading: true,
|
|
50
|
+
error: null,
|
|
51
|
+
failedPollStreak: 0
|
|
52
|
+
},
|
|
53
|
+
refCount: 0,
|
|
54
|
+
listeners: new Set(),
|
|
55
|
+
timer: undefined,
|
|
56
|
+
active: false,
|
|
57
|
+
onVisibility: undefined,
|
|
58
|
+
forcePoll: ()=>undefined
|
|
59
|
+
};
|
|
60
|
+
registry.set(basePath, entry);
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
function emit(entry) {
|
|
64
|
+
for (const listener of entry.listeners){
|
|
65
|
+
listener();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function updateState(entry, patch) {
|
|
69
|
+
// Build a fresh state object so `useSyncExternalStore` consumers see
|
|
70
|
+
// a stable reference change and re-render.
|
|
71
|
+
entry.state = {
|
|
72
|
+
...entry.state,
|
|
73
|
+
...patch
|
|
74
|
+
};
|
|
75
|
+
emit(entry);
|
|
76
|
+
}
|
|
77
|
+
async function fetchOnce(basePath, entry) {
|
|
78
|
+
if (!entry.active) return;
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`${basePath}/api/translation-hub/bulk-translate/active`, {
|
|
81
|
+
credentials: 'include'
|
|
82
|
+
});
|
|
83
|
+
if (!entry.active) return;
|
|
84
|
+
if (res.status === 404) {
|
|
85
|
+
// Endpoint not yet deployed OR no active batch — both render
|
|
86
|
+
// the same "idle" state in the trigger.
|
|
87
|
+
updateState(entry, {
|
|
88
|
+
data: {
|
|
89
|
+
batch: null
|
|
90
|
+
},
|
|
91
|
+
error: null,
|
|
92
|
+
failedPollStreak: 0,
|
|
93
|
+
loading: false
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(await readResponseError(res));
|
|
99
|
+
}
|
|
100
|
+
const json = await res.json();
|
|
101
|
+
if (!entry.active) return;
|
|
102
|
+
updateState(entry, {
|
|
103
|
+
data: json,
|
|
104
|
+
error: null,
|
|
105
|
+
failedPollStreak: 0,
|
|
106
|
+
loading: false
|
|
107
|
+
});
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (!entry.active) return;
|
|
110
|
+
updateState(entry, {
|
|
111
|
+
error: e instanceof Error ? e.message : String(e),
|
|
112
|
+
failedPollStreak: entry.state.failedPollStreak + 1,
|
|
113
|
+
loading: false
|
|
114
|
+
});
|
|
115
|
+
} finally{
|
|
116
|
+
if (entry.active && typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
|
117
|
+
// Cadence-aware polling: tight (5s) only while a batch is
|
|
118
|
+
// genuinely in flight. The /active endpoint keeps serving
|
|
119
|
+
// TERMINAL batches for the 24h revert-visibility window — those
|
|
120
|
+
// (and error states, where `data.batch` holds its stale value)
|
|
121
|
+
// must poll at the relaxed cadence. Fast-polling a finished
|
|
122
|
+
// batch for 24h multiplied the endpoint's per-poll unit scans
|
|
123
|
+
// across every open Hub tab and helped take prod down on
|
|
124
|
+
// 2026-06-10.
|
|
125
|
+
const batchStatus = entry.state.data?.batch?.status;
|
|
126
|
+
const isInFlight = batchStatus === 'queued' || batchStatus === 'running' || batchStatus === 'cancelling';
|
|
127
|
+
const nextDelay = isInFlight ? POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
|
|
128
|
+
entry.timer = setTimeout(()=>fetchOnce(basePath, entry), nextDelay);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function startPolling(basePath, entry) {
|
|
133
|
+
if (entry.active) return;
|
|
134
|
+
entry.active = true;
|
|
135
|
+
entry.forcePoll = ()=>{
|
|
136
|
+
if (entry.timer) {
|
|
137
|
+
clearTimeout(entry.timer);
|
|
138
|
+
entry.timer = undefined;
|
|
139
|
+
}
|
|
140
|
+
void fetchOnce(basePath, entry);
|
|
141
|
+
};
|
|
142
|
+
entry.onVisibility = ()=>{
|
|
143
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'visible' && entry.active) {
|
|
144
|
+
entry.forcePoll();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
if (typeof document !== 'undefined') {
|
|
148
|
+
document.addEventListener('visibilitychange', entry.onVisibility);
|
|
149
|
+
}
|
|
150
|
+
void fetchOnce(basePath, entry);
|
|
151
|
+
}
|
|
152
|
+
function stopPolling(basePath, entry) {
|
|
153
|
+
entry.active = false;
|
|
154
|
+
if (entry.timer) {
|
|
155
|
+
clearTimeout(entry.timer);
|
|
156
|
+
entry.timer = undefined;
|
|
157
|
+
}
|
|
158
|
+
if (entry.onVisibility && typeof document !== 'undefined') {
|
|
159
|
+
document.removeEventListener('visibilitychange', entry.onVisibility);
|
|
160
|
+
}
|
|
161
|
+
entry.onVisibility = undefined;
|
|
162
|
+
entry.forcePoll = ()=>undefined;
|
|
163
|
+
// Reset state so the next subscriber sees a fresh loading frame
|
|
164
|
+
// instead of stale cached data from a previous mount.
|
|
165
|
+
entry.state = {
|
|
166
|
+
data: null,
|
|
167
|
+
loading: true,
|
|
168
|
+
error: null,
|
|
169
|
+
failedPollStreak: 0
|
|
170
|
+
};
|
|
171
|
+
registry.delete(basePath);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Acquire a subscription slot for `basePath`. Increments the ref-count
|
|
175
|
+
* and starts polling on first subscriber. Returns an unsubscribe
|
|
176
|
+
* function that decrements + stops polling when the last subscriber
|
|
177
|
+
* unmounts. Exported for the test fixture; consumers should call
|
|
178
|
+
* `useBulkTranslateActive`.
|
|
179
|
+
*/ export function subscribeToBulkActive(basePath, listener) {
|
|
180
|
+
const entry = getEntry(basePath);
|
|
181
|
+
entry.listeners.add(listener);
|
|
182
|
+
entry.refCount += 1;
|
|
183
|
+
if (entry.refCount === 1) {
|
|
184
|
+
startPolling(basePath, entry);
|
|
185
|
+
}
|
|
186
|
+
return ()=>{
|
|
187
|
+
entry.listeners.delete(listener);
|
|
188
|
+
entry.refCount -= 1;
|
|
189
|
+
if (entry.refCount <= 0) {
|
|
190
|
+
stopPolling(basePath, entry);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** Test-only: read the current snapshot for a base path. */ export function getBulkActiveSnapshot(basePath) {
|
|
195
|
+
return getEntry(basePath).state;
|
|
196
|
+
}
|
|
197
|
+
/** Test-only: reset the registry between tests. */ export function __resetBulkActiveRegistryForTests() {
|
|
198
|
+
for (const [basePath, entry] of registry){
|
|
199
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
200
|
+
if (entry.onVisibility && typeof document !== 'undefined') {
|
|
201
|
+
document.removeEventListener('visibilitychange', entry.onVisibility);
|
|
202
|
+
}
|
|
203
|
+
registry.delete(basePath);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Test-only: snapshot of refcounts per base path. */ export function __getBulkActiveRefCountsForTests() {
|
|
207
|
+
const out = {};
|
|
208
|
+
for (const [basePath, entry] of registry){
|
|
209
|
+
out[basePath] = entry.refCount;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
export function useBulkTranslateActive(basePath) {
|
|
214
|
+
// `useSyncExternalStore` is the React-recommended primitive for
|
|
215
|
+
// subscribing to an external store. The subscribe callback wires the
|
|
216
|
+
// refcount; the getSnapshot returns the current state object. React
|
|
217
|
+
// re-renders consumers when emit() fires.
|
|
218
|
+
const subscribe = useCallback((listener)=>subscribeToBulkActive(basePath, listener), [
|
|
219
|
+
basePath
|
|
220
|
+
]);
|
|
221
|
+
const getSnapshot = useCallback(()=>getEntry(basePath).state, [
|
|
222
|
+
basePath
|
|
223
|
+
]);
|
|
224
|
+
// SSR-safe server snapshot. The Hub renders client-only but Payload's
|
|
225
|
+
// admin shell hydrates from server-rendered markup, so a stable
|
|
226
|
+
// server snapshot avoids a hydration mismatch flash.
|
|
227
|
+
const getServerSnapshot = useCallback(()=>({
|
|
228
|
+
data: null,
|
|
229
|
+
loading: true,
|
|
230
|
+
error: null,
|
|
231
|
+
failedPollStreak: 0
|
|
232
|
+
}), []);
|
|
233
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
234
|
+
const refetch = useCallback(()=>{
|
|
235
|
+
getEntry(basePath).forcePoll();
|
|
236
|
+
}, [
|
|
237
|
+
basePath
|
|
238
|
+
]);
|
|
239
|
+
return {
|
|
240
|
+
data: state.data,
|
|
241
|
+
loading: state.loading,
|
|
242
|
+
error: state.error,
|
|
243
|
+
isOffline: state.failedPollStreak >= OFFLINE_AFTER_FAILED_POLLS,
|
|
244
|
+
refetch
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Exported for tests so they can compare against the same threshold the
|
|
249
|
+
* hook uses internally.
|
|
250
|
+
*/ export const BULK_TRANSLATE_OFFLINE_THRESHOLD = OFFLINE_AFTER_FAILED_POLLS;
|
|
251
|
+
export const BULK_TRANSLATE_POLL_INTERVAL_MS = POLL_INTERVAL_MS;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal focus-trap implementation for the bulk-translate modal and
|
|
4
|
+
* drawer. Per F-UX-10 the spec requires:
|
|
5
|
+
*
|
|
6
|
+
* - Focus the first interactive element when the surface opens.
|
|
7
|
+
* - Tab / Shift+Tab loops within the surface.
|
|
8
|
+
* - Escape triggers the close callback.
|
|
9
|
+
* - Focus returns to the trigger element on close.
|
|
10
|
+
*
|
|
11
|
+
* We deliberately avoid pulling in a focus-trap library — the surface
|
|
12
|
+
* is small, the requirements are small, and the cms-plugins package
|
|
13
|
+
* already keeps third-party deps to a minimum.
|
|
14
|
+
*/ import { useEffect } from 'react';
|
|
15
|
+
const FOCUSABLE = [
|
|
16
|
+
'a[href]',
|
|
17
|
+
'button:not([disabled])',
|
|
18
|
+
'textarea:not([disabled])',
|
|
19
|
+
'input:not([disabled])',
|
|
20
|
+
'select:not([disabled])',
|
|
21
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
22
|
+
].join(',');
|
|
23
|
+
export function useFocusTrap(containerRef, options) {
|
|
24
|
+
const { enabled, onEscape } = options;
|
|
25
|
+
useEffect(()=>{
|
|
26
|
+
if (!enabled) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const node = containerRef.current;
|
|
30
|
+
if (!node) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Save the element that owned focus before the surface opened so
|
|
34
|
+
// we can restore it on close. Useful for screen-reader users and
|
|
35
|
+
// anyone who triggered the surface via keyboard.
|
|
36
|
+
const previouslyFocused = document.activeElement;
|
|
37
|
+
// Focus first interactive element. RAF defers until paint so
|
|
38
|
+
// refs inside the surface are populated.
|
|
39
|
+
const raf = requestAnimationFrame(()=>{
|
|
40
|
+
const first = node.querySelector(FOCUSABLE);
|
|
41
|
+
if (first) {
|
|
42
|
+
first.focus();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
function onKey(e) {
|
|
46
|
+
if (e.key === 'Escape') {
|
|
47
|
+
onEscape?.();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (e.key !== 'Tab') {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const focusable = Array.from(node.querySelectorAll(FOCUSABLE)).filter((el)=>!el.hasAttribute('disabled'));
|
|
54
|
+
if (focusable.length === 0) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const first = focusable[0];
|
|
58
|
+
const last = focusable[focusable.length - 1];
|
|
59
|
+
const active = document.activeElement;
|
|
60
|
+
if (e.shiftKey && active === first) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
last.focus();
|
|
63
|
+
} else if (!e.shiftKey && active === last) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
first.focus();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
document.addEventListener('keydown', onKey);
|
|
69
|
+
return ()=>{
|
|
70
|
+
cancelAnimationFrame(raf);
|
|
71
|
+
document.removeEventListener('keydown', onKey);
|
|
72
|
+
if (previouslyFocused && document.contains(previouslyFocused)) {
|
|
73
|
+
previouslyFocused.focus();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}, [
|
|
77
|
+
containerRef,
|
|
78
|
+
enabled,
|
|
79
|
+
onEscape
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type UsageSummaryRangeKey = {
|
|
2
|
+
kind: '7d' | '30d' | '90d' | 'all';
|
|
3
|
+
} | {
|
|
4
|
+
kind: 'custom';
|
|
5
|
+
from?: string;
|
|
6
|
+
to?: string;
|
|
7
|
+
};
|
|
8
|
+
export interface UsageSummaryShape {
|
|
9
|
+
totals: {
|
|
10
|
+
runs: number;
|
|
11
|
+
succeeded: number;
|
|
12
|
+
preserved: number;
|
|
13
|
+
failed: number;
|
|
14
|
+
inputTokens: number;
|
|
15
|
+
outputTokens: number;
|
|
16
|
+
costUsd: number;
|
|
17
|
+
totalMatching: number;
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
};
|
|
20
|
+
byCollection: Array<{
|
|
21
|
+
slug: string;
|
|
22
|
+
kind: 'collection' | 'global';
|
|
23
|
+
runs: number;
|
|
24
|
+
tokens: number;
|
|
25
|
+
costUsd: number;
|
|
26
|
+
}>;
|
|
27
|
+
byModel: Array<{
|
|
28
|
+
model: string;
|
|
29
|
+
runs: number;
|
|
30
|
+
tokens: number;
|
|
31
|
+
costUsd: number;
|
|
32
|
+
}>;
|
|
33
|
+
byLocale: Array<{
|
|
34
|
+
locale: string;
|
|
35
|
+
runs: number;
|
|
36
|
+
failed: number;
|
|
37
|
+
}>;
|
|
38
|
+
samples: Array<{
|
|
39
|
+
id: number | string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
slug: string;
|
|
42
|
+
kind: 'collection' | 'global';
|
|
43
|
+
documentId?: string | null;
|
|
44
|
+
status: 'succeeded' | 'failed';
|
|
45
|
+
model?: string | null;
|
|
46
|
+
inputTokens: number;
|
|
47
|
+
outputTokens: number;
|
|
48
|
+
estimatedCostUsd?: number | null;
|
|
49
|
+
durationMs?: number | null;
|
|
50
|
+
error?: string | null;
|
|
51
|
+
failedCount: number;
|
|
52
|
+
succeededCount: number;
|
|
53
|
+
targetLocales?: Array<{
|
|
54
|
+
locale: string;
|
|
55
|
+
status?: 'succeeded' | 'failed' | null;
|
|
56
|
+
}> | null;
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
export interface UseTranslationHubUsageSummaryResult {
|
|
60
|
+
data: UsageSummaryShape | null;
|
|
61
|
+
loading: boolean;
|
|
62
|
+
error: string | null;
|
|
63
|
+
refetch: () => void;
|
|
64
|
+
}
|
|
65
|
+
interface FetchState {
|
|
66
|
+
data: UsageSummaryShape | null;
|
|
67
|
+
loading: boolean;
|
|
68
|
+
error: string | null;
|
|
69
|
+
}
|
|
70
|
+
export declare function subscribeToTranslationHubUsageSummary(basePath: string, range: UsageSummaryRangeKey, listener: () => void): () => void;
|
|
71
|
+
export declare function getTranslationHubUsageSummarySnapshot(basePath: string, range: UsageSummaryRangeKey): FetchState;
|
|
72
|
+
/** Test-only: reset between tests. */
|
|
73
|
+
export declare function __resetTranslationHubUsageSummaryRegistryForTests(): void;
|
|
74
|
+
/** Test-only: snapshot of refcounts. */
|
|
75
|
+
export declare function __getTranslationHubUsageSummaryRefCountsForTests(): Record<string, number>;
|
|
76
|
+
export declare function useTranslationHubUsageSummary(basePath: string, range: UsageSummaryRangeKey): UseTranslationHubUsageSummaryResult;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* Singleton fetcher for `/api/translation-hub/usage-summary` (ROUND2-4).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the singleton-registry pattern in `useBulkTranslateActive` —
|
|
6
|
+
* one module-level cache keyed by `(basePath, range)`, ref-counted
|
|
7
|
+
* subscribers, `useSyncExternalStore` for React reactivity. Multiple
|
|
8
|
+
* mounts of `StatusStrip` + `AuditPanel` (same Hub page, same range)
|
|
9
|
+
* share ONE network request instead of each component firing its own.
|
|
10
|
+
*
|
|
11
|
+
* Before this hook:
|
|
12
|
+
* - StatusStrip and AuditPanel each owned their own `useEffect` +
|
|
13
|
+
* `fetch()`.
|
|
14
|
+
* - React 18 StrictMode dev-double-render produced two requests
|
|
15
|
+
* within ~2.8 ms on every mount.
|
|
16
|
+
* - A page that surfaced both consumers (Overview + Audit & Cost on
|
|
17
|
+
* the same tab swap) doubled cost on the backend SUM-aggregation,
|
|
18
|
+
* which runs across `translation_usage` — potentially thousands of
|
|
19
|
+
* rows.
|
|
20
|
+
*
|
|
21
|
+
* With this hook:
|
|
22
|
+
* - `useTranslationHubUsageSummary(basePath, range, from?, to?)`
|
|
23
|
+
* returns `{ data, loading, error, refetch }`.
|
|
24
|
+
* - Concurrent subscribers (same key) share one in-flight request
|
|
25
|
+
* and one cached result.
|
|
26
|
+
* - Range / from / to changes invalidate the cached entry and fire a
|
|
27
|
+
* fresh request.
|
|
28
|
+
* - Refetch is exposed for explicit user-driven reloads (none today
|
|
29
|
+
* — this view is read-only — but matches the API surface of every
|
|
30
|
+
* other hook in this plugin so future "Refresh" affordances drop
|
|
31
|
+
* in cleanly).
|
|
32
|
+
*/ import { useCallback, useSyncExternalStore } from 'react';
|
|
33
|
+
import { readResponseError } from '../shared/fetch-error-body.js';
|
|
34
|
+
const registry = new Map();
|
|
35
|
+
/**
|
|
36
|
+
* ROUND3-2: TTL window during which an idle entry (refCount === 0)
|
|
37
|
+
* survives in the registry. Mirrors the staleTime semantics that
|
|
38
|
+
* TanStack Query exposes — when an editor flips Overview → Audit & Cost
|
|
39
|
+
* → Overview within this window the second mount serves from the warm
|
|
40
|
+
* cache instead of paying the SQL aggregation cost again. 60s strikes
|
|
41
|
+
* the same balance as the default TanStack `gcTime` shoulder.
|
|
42
|
+
*/ const CACHE_TTL_MS = 60_000;
|
|
43
|
+
/**
|
|
44
|
+
* ROUND3-3: retry policy on `fetchOnce` failures. Exponential backoff
|
|
45
|
+
* with three attempts — 1s, 3s, 9s — matches the upper bound on a
|
|
46
|
+
* transient network blip / 502 retry-after. After the third failure we
|
|
47
|
+
* stop auto-retrying and surface the error to the consumer; the
|
|
48
|
+
* `refetch()` button lets the editor try again on their schedule.
|
|
49
|
+
*/ const MAX_RETRY_ATTEMPTS = 3;
|
|
50
|
+
const RETRY_BACKOFF_MS = [
|
|
51
|
+
1_000,
|
|
52
|
+
3_000,
|
|
53
|
+
9_000
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Build a stable key from the URL query params we'd send. We compose
|
|
57
|
+
* `basePath` + URL search so two `custom` ranges with different
|
|
58
|
+
* `from/to` stay isolated.
|
|
59
|
+
*/ function buildKey(basePath, range) {
|
|
60
|
+
const params = new URLSearchParams();
|
|
61
|
+
if (range.kind === 'custom') {
|
|
62
|
+
params.set('range', 'custom');
|
|
63
|
+
if (range.from) params.set('from', range.from);
|
|
64
|
+
if (range.to) params.set('to', range.to);
|
|
65
|
+
} else {
|
|
66
|
+
params.set('range', range.kind);
|
|
67
|
+
}
|
|
68
|
+
return `${basePath}?${params.toString()}`;
|
|
69
|
+
}
|
|
70
|
+
function buildUrl(basePath, range) {
|
|
71
|
+
const params = new URLSearchParams();
|
|
72
|
+
if (range.kind === 'custom') {
|
|
73
|
+
params.set('range', 'custom');
|
|
74
|
+
if (range.from) params.set('from', range.from);
|
|
75
|
+
if (range.to) params.set('to', range.to);
|
|
76
|
+
} else {
|
|
77
|
+
params.set('range', range.kind);
|
|
78
|
+
}
|
|
79
|
+
return `${basePath}/api/translation-hub/usage-summary?${params.toString()}`;
|
|
80
|
+
}
|
|
81
|
+
function getEntry(key) {
|
|
82
|
+
let entry = registry.get(key);
|
|
83
|
+
if (entry) return entry;
|
|
84
|
+
entry = {
|
|
85
|
+
state: {
|
|
86
|
+
data: null,
|
|
87
|
+
loading: true,
|
|
88
|
+
error: null
|
|
89
|
+
},
|
|
90
|
+
refCount: 0,
|
|
91
|
+
listeners: new Set(),
|
|
92
|
+
hasFetched: false,
|
|
93
|
+
forceFetch: ()=>undefined,
|
|
94
|
+
evictTimer: null,
|
|
95
|
+
lastFetchedAt: 0,
|
|
96
|
+
failureStreak: 0,
|
|
97
|
+
retryTimer: null
|
|
98
|
+
};
|
|
99
|
+
registry.set(key, entry);
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
function emit(entry) {
|
|
103
|
+
for (const listener of entry.listeners){
|
|
104
|
+
listener();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function updateState(entry, patch) {
|
|
108
|
+
entry.state = {
|
|
109
|
+
...entry.state,
|
|
110
|
+
...patch
|
|
111
|
+
};
|
|
112
|
+
emit(entry);
|
|
113
|
+
}
|
|
114
|
+
async function fetchOnce(basePath, range, key) {
|
|
115
|
+
const entry = getEntry(key);
|
|
116
|
+
if (entry.refCount === 0) return;
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(buildUrl(basePath, range), {
|
|
119
|
+
credentials: 'include'
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
throw new Error(await readResponseError(res));
|
|
123
|
+
}
|
|
124
|
+
const json = await res.json();
|
|
125
|
+
if (entry.refCount === 0) return;
|
|
126
|
+
entry.failureStreak = 0;
|
|
127
|
+
entry.lastFetchedAt = Date.now();
|
|
128
|
+
updateState(entry, {
|
|
129
|
+
data: json,
|
|
130
|
+
loading: false,
|
|
131
|
+
error: null
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (entry.refCount === 0) return;
|
|
135
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
136
|
+
// ROUND3-3: auto-retry transient failures with exponential backoff.
|
|
137
|
+
// After MAX_RETRY_ATTEMPTS we surface the error to the consumer
|
|
138
|
+
// and let the editor decide via the `refetch` affordance.
|
|
139
|
+
entry.failureStreak += 1;
|
|
140
|
+
if (entry.failureStreak < MAX_RETRY_ATTEMPTS) {
|
|
141
|
+
const delay = RETRY_BACKOFF_MS[entry.failureStreak - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1] ?? 9_000;
|
|
142
|
+
// Keep `loading: true` so consumers don't flash the error chrome
|
|
143
|
+
// between attempts. The error surfaces only when we run out of
|
|
144
|
+
// retries.
|
|
145
|
+
updateState(entry, {
|
|
146
|
+
loading: true,
|
|
147
|
+
error: null
|
|
148
|
+
});
|
|
149
|
+
if (entry.retryTimer) clearTimeout(entry.retryTimer);
|
|
150
|
+
entry.retryTimer = setTimeout(()=>{
|
|
151
|
+
entry.retryTimer = null;
|
|
152
|
+
if (entry.refCount === 0) return;
|
|
153
|
+
void fetchOnce(basePath, range, key);
|
|
154
|
+
}, delay);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
updateState(entry, {
|
|
158
|
+
loading: false,
|
|
159
|
+
error: message
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function subscribeToTranslationHubUsageSummary(basePath, range, listener) {
|
|
164
|
+
const key = buildKey(basePath, range);
|
|
165
|
+
const entry = getEntry(key);
|
|
166
|
+
entry.listeners.add(listener);
|
|
167
|
+
entry.refCount += 1;
|
|
168
|
+
// ROUND3-2: a pending eviction means we were within the TTL window
|
|
169
|
+
// when this new subscriber arrived. Cancel the timer so the cached
|
|
170
|
+
// payload survives and the subscriber gets a warm hit on the next
|
|
171
|
+
// `getSnapshot` call. No new request fires.
|
|
172
|
+
if (entry.evictTimer) {
|
|
173
|
+
clearTimeout(entry.evictTimer);
|
|
174
|
+
entry.evictTimer = null;
|
|
175
|
+
}
|
|
176
|
+
// First subscriber on this key fires the request. Subsequent
|
|
177
|
+
// subscribers re-use the same cached entry — that's the doubling
|
|
178
|
+
// fix.
|
|
179
|
+
if (!entry.hasFetched) {
|
|
180
|
+
entry.hasFetched = true;
|
|
181
|
+
entry.forceFetch = ()=>{
|
|
182
|
+
// Explicit user-driven refetch: reset failure streak so the
|
|
183
|
+
// backoff window starts fresh.
|
|
184
|
+
entry.failureStreak = 0;
|
|
185
|
+
if (entry.retryTimer) {
|
|
186
|
+
clearTimeout(entry.retryTimer);
|
|
187
|
+
entry.retryTimer = null;
|
|
188
|
+
}
|
|
189
|
+
updateState(entry, {
|
|
190
|
+
loading: true,
|
|
191
|
+
error: null
|
|
192
|
+
});
|
|
193
|
+
void fetchOnce(basePath, range, key);
|
|
194
|
+
};
|
|
195
|
+
void fetchOnce(basePath, range, key);
|
|
196
|
+
}
|
|
197
|
+
return ()=>{
|
|
198
|
+
entry.listeners.delete(listener);
|
|
199
|
+
entry.refCount -= 1;
|
|
200
|
+
if (entry.refCount <= 0) {
|
|
201
|
+
// ROUND3-2: defer eviction. Keep the cached entry alive for
|
|
202
|
+
// CACHE_TTL_MS so a quick tab-switch (Overview → Audit → Overview)
|
|
203
|
+
// or range-flip (7d → 30d → 7d) re-uses the warm payload instead
|
|
204
|
+
// of paying the SQL aggregation cost again. Any new subscriber
|
|
205
|
+
// arriving within the window cancels this timer above.
|
|
206
|
+
if (entry.evictTimer) clearTimeout(entry.evictTimer);
|
|
207
|
+
entry.evictTimer = setTimeout(()=>{
|
|
208
|
+
// Double-check refCount in case a subscriber raced in. If a new
|
|
209
|
+
// one is here, leave the entry alone.
|
|
210
|
+
const current = registry.get(key);
|
|
211
|
+
if (!current || current.refCount > 0) return;
|
|
212
|
+
if (current.retryTimer) clearTimeout(current.retryTimer);
|
|
213
|
+
registry.delete(key);
|
|
214
|
+
}, CACHE_TTL_MS);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export function getTranslationHubUsageSummarySnapshot(basePath, range) {
|
|
219
|
+
return getEntry(buildKey(basePath, range)).state;
|
|
220
|
+
}
|
|
221
|
+
/** Test-only: reset between tests. */ export function __resetTranslationHubUsageSummaryRegistryForTests() {
|
|
222
|
+
for (const entry of registry.values()){
|
|
223
|
+
if (entry.evictTimer) clearTimeout(entry.evictTimer);
|
|
224
|
+
if (entry.retryTimer) clearTimeout(entry.retryTimer);
|
|
225
|
+
}
|
|
226
|
+
registry.clear();
|
|
227
|
+
}
|
|
228
|
+
/** Test-only: snapshot of refcounts. */ export function __getTranslationHubUsageSummaryRefCountsForTests() {
|
|
229
|
+
const out = {};
|
|
230
|
+
for (const [key, entry] of registry){
|
|
231
|
+
out[key] = entry.refCount;
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
export function useTranslationHubUsageSummary(basePath, range) {
|
|
236
|
+
// `range` may be a fresh object each render — stringify into a stable
|
|
237
|
+
// key the hook subscriptions can dedupe on.
|
|
238
|
+
const key = buildKey(basePath, range);
|
|
239
|
+
const subscribe = useCallback((listener)=>subscribeToTranslationHubUsageSummary(basePath, range, listener), // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
+
[
|
|
241
|
+
basePath,
|
|
242
|
+
key
|
|
243
|
+
]);
|
|
244
|
+
const getSnapshot = useCallback(()=>getEntry(key).state, [
|
|
245
|
+
key
|
|
246
|
+
]);
|
|
247
|
+
// SSR snapshot — Payload's admin shell hydrates from server-rendered
|
|
248
|
+
// markup. Returning a stable "loading" state matches what the
|
|
249
|
+
// client-side render will show on first paint.
|
|
250
|
+
const getServerSnapshot = useCallback(()=>({
|
|
251
|
+
data: null,
|
|
252
|
+
loading: true,
|
|
253
|
+
error: null
|
|
254
|
+
}), []);
|
|
255
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
256
|
+
const refetch = useCallback(()=>{
|
|
257
|
+
getEntry(key).forceFetch();
|
|
258
|
+
}, [
|
|
259
|
+
key
|
|
260
|
+
]);
|
|
261
|
+
return {
|
|
262
|
+
data: state.data,
|
|
263
|
+
loading: state.loading,
|
|
264
|
+
error: state.error,
|
|
265
|
+
refetch
|
|
266
|
+
};
|
|
267
|
+
}
|