From 269f25d6cd097964c066c5b9e477b642d9d1d585 Mon Sep 17 00:00:00 2001 From: sigmanor Date: Mon, 17 Aug 2026 20:13:40 +0300 Subject: [PATCH 1/6] fix(language): validate language codes against a bundled list The validator fetched the ISO 639-1 list from a raw gist that GitHub now rate-limits, so validation silently degraded to a format-only check on every input. The list is a static standard, so it ships with the app instead. Enables resolveJsonModule so the list can be imported directly. --- src/utils/language-codes.json | 185 +++++++++++++++++++++++++++++++ src/utils/language-validation.ts | 113 ++++++------------- tsconfig.app.json | 1 + 3 files changed, 218 insertions(+), 81 deletions(-) create mode 100644 src/utils/language-codes.json diff --git a/src/utils/language-codes.json b/src/utils/language-codes.json new file mode 100644 index 0000000..61e7f09 --- /dev/null +++ b/src/utils/language-codes.json @@ -0,0 +1,185 @@ +{ + "aa": "Afar", + "ab": "Abkhazian", + "ae": "Avestan", + "af": "Afrikaans", + "ak": "Akan", + "am": "Amharic", + "an": "Aragonese", + "ar": "Arabic", + "as": "Assamese", + "av": "Avaric", + "ay": "Aymara", + "az": "Azerbaijani", + "ba": "Bashkir", + "be": "Belarusian", + "bg": "Bulgarian", + "bi": "Bislama", + "bm": "Bambara", + "bn": "Bengali", + "bo": "Tibetan", + "br": "Breton", + "bs": "Bosnian", + "ca": "Catalan", + "ce": "Chechen", + "ch": "Chamorro", + "co": "Corsican", + "cr": "Cree", + "cs": "Czech", + "cu": "Church Slavic", + "cv": "Chuvash", + "cy": "Welsh", + "da": "Danish", + "de": "German", + "dv": "Divehi", + "dz": "Dzongkha", + "ee": "Ewe", + "el": "Greek", + "en": "English", + "eo": "Esperanto", + "es": "Spanish", + "et": "Estonian", + "eu": "Basque", + "fa": "Persian", + "ff": "Fulah", + "fi": "Finnish", + "fj": "Fijian", + "fo": "Faroese", + "fr": "French", + "fy": "Western Frisian", + "ga": "Irish", + "gd": "Gaelic", + "gl": "Galician", + "gn": "Guarani", + "gu": "Gujarati", + "gv": "Manx", + "ha": "Hausa", + "he": "Hebrew", + "hi": "Hindi", + "ho": "Hiri Motu", + "hr": "Croatian", + "ht": "Haitian", + "hu": "Hungarian", + "hy": "Armenian", + "hz": "Herero", + "ia": "Interlingua", + "id": "Indonesian", + "ie": "Interlingue", + "ig": "Igbo", + "ii": "Sichuan Yi", + "ik": "Inupiaq", + "io": "Ido", + "is": "Icelandic", + "it": "Italian", + "iu": "Inuktitut", + "ja": "Japanese", + "jv": "Javanese", + "ka": "Georgian", + "kg": "Kongo", + "ki": "Kikuyu", + "kj": "Kuanyama", + "kk": "Kazakh", + "kl": "Kalaallisut", + "km": "Central Khmer", + "kn": "Kannada", + "ko": "Korean", + "kr": "Kanuri", + "ks": "Kashmiri", + "ku": "Kurdish", + "kv": "Komi", + "kw": "Cornish", + "ky": "Kirghiz", + "la": "Latin", + "lb": "Luxembourgish", + "lg": "Ganda", + "li": "Limburgan", + "ln": "Lingala", + "lo": "Lao", + "lt": "Lithuanian", + "lu": "Luba-Katanga", + "lv": "Latvian", + "mg": "Malagasy", + "mh": "Marshallese", + "mi": "Maori", + "mk": "Macedonian", + "ml": "Malayalam", + "mn": "Mongolian", + "mr": "Marathi", + "ms": "Malay", + "mt": "Maltese", + "my": "Burmese", + "na": "Nauru", + "nb": "Bokmål, Norwegian", + "nd": "Ndebele, North", + "ne": "Nepali", + "ng": "Ndonga", + "nl": "Dutch", + "nn": "Norwegian Nynorsk", + "no": "Norwegian", + "nr": "Ndebele, South", + "nv": "Navajo", + "ny": "Chichewa", + "oc": "Occitan", + "oj": "Ojibwa", + "om": "Oromo", + "or": "Oriya", + "os": "Ossetian", + "pa": "Panjabi", + "pi": "Pali", + "pl": "Polish", + "ps": "Pushto", + "pt": "Portuguese", + "qu": "Quechua", + "rm": "Romansh", + "rn": "Rundi", + "ro": "Romanian", + "ru": "Russian", + "rw": "Kinyarwanda", + "sa": "Sanskrit", + "sc": "Sardinian", + "sd": "Sindhi", + "se": "Northern Sami", + "sg": "Sango", + "si": "Sinhala", + "sk": "Slovak", + "sl": "Slovenian", + "sm": "Samoan", + "sn": "Shona", + "so": "Somali", + "sq": "Albanian", + "sr": "Serbian", + "ss": "Swati", + "st": "Sotho, Southern", + "su": "Sundanese", + "sv": "Swedish", + "sw": "Swahili", + "ta": "Tamil", + "te": "Telugu", + "tg": "Tajik", + "th": "Thai", + "ti": "Tigrinya", + "tk": "Turkmen", + "tl": "Tagalog", + "tn": "Tswana", + "to": "Tonga", + "tr": "Turkish", + "ts": "Tsonga", + "tt": "Tatar", + "tw": "Twi", + "ty": "Tahitian", + "ug": "Uighur", + "uk": "Ukrainian", + "ur": "Urdu", + "uz": "Uzbek", + "ve": "Venda", + "vi": "Vietnamese", + "vo": "Volapük", + "wa": "Walloon", + "wo": "Wolof", + "xh": "Xhosa", + "yi": "Yiddish", + "yo": "Yoruba", + "za": "Zhuang", + "zh": "Chinese", + "zu": "Zulu" +} diff --git a/src/utils/language-validation.ts b/src/utils/language-validation.ts index 1fff7cb..ff840ac 100644 --- a/src/utils/language-validation.ts +++ b/src/utils/language-validation.ts @@ -1,3 +1,5 @@ +import languageCodes from './language-codes.json'; + interface LanguageCodesMap { [key: string]: string; } @@ -9,49 +11,19 @@ interface ValidationResult { message?: string; } +/** + * The ISO 639-1 list is bundled rather than fetched. It never changes, and the + * remote copy this used to read is rate-limited by its host — the same + * dependency that made Content Alchemist reject every language-aware request. + */ class LanguageValidator { - private languageCodes: LanguageCodesMap | null = null; - private cacheExpiry: number = 0; - private readonly CACHE_DURATION = 60 * 60 * 1000; // 1 hour - private readonly API_URL = 'https://gist.githubusercontent.com/Josantonius/b455e315bc7f790d14b136d61d9ae469/raw/language-codes.json'; - - private async fetchLanguageCodes(): Promise { - const now = Date.now(); - - // Return cached data if still valid - if (this.languageCodes && now < this.cacheExpiry) { - return this.languageCodes; - } - - try { - const response = await fetch(this.API_URL); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - this.languageCodes = data; - this.cacheExpiry = now + this.CACHE_DURATION; - - return data; - } catch (error) { - console.error('Failed to fetch language codes:', error); - - // If we have cached data, use it even if expired - if (this.languageCodes) { - return this.languageCodes; - } - - // Fallback to basic validation without API - throw new Error('Unable to fetch language codes for validation'); - } - } + private readonly languageCodes: LanguageCodesMap = languageCodes; private parseLanguageCodes(input: string): string[] { if (!input.trim()) { return []; } - + return input .split(',') .map(code => code.trim().toLowerCase()) @@ -66,7 +38,7 @@ class LanguageValidator { async validateLanguageCodes(input: string): Promise { const codes = this.parseLanguageCodes(input); - + // If empty, it's valid (optional field) if (codes.length === 0) { return { @@ -87,53 +59,32 @@ class LanguageValidator { }; } - try { - const languageCodes = await this.fetchLanguageCodes(); - const validCodes: string[] = []; - const invalidCodes: string[] = []; - - codes.forEach(code => { - if (languageCodes[code]) { - validCodes.push(code); - } else { - invalidCodes.push(code); - } - }); + const validCodes: string[] = []; + const invalidCodes: string[] = []; - const isValid = invalidCodes.length === 0; - let message: string | undefined; - - if (!isValid) { - message = `Invalid language codes: ${invalidCodes.join(', ')}. Please use valid ISO 639-1 language codes.`; + codes.forEach(code => { + if (this.languageCodes[code]) { + validCodes.push(code); + } else { + invalidCodes.push(code); } - - return { - isValid, - validCodes, - invalidCodes, - message - }; - } catch (error) { - // Fallback to basic format validation if API fails - console.warn('Language validation API unavailable, using basic validation:', error); - - return { - isValid: true, - validCodes: codes, - invalidCodes: [], - message: 'Language codes validation unavailable. Basic format validation passed.' - }; - } + }); + + const isValid = invalidCodes.length === 0; + + return { + isValid, + validCodes, + invalidCodes, + message: isValid + ? undefined + : `Invalid language codes: ${invalidCodes.join(', ')}. Please use valid ISO 639-1 language codes.` + }; } // Get available language codes for suggestions (optional feature) - async getAvailableLanguages(): Promise { - try { - return await this.fetchLanguageCodes(); - } catch (error) { - console.error('Failed to get available languages:', error); - return null; - } + async getAvailableLanguages(): Promise { + return this.languageCodes; } } @@ -141,4 +92,4 @@ class LanguageValidator { export const languageValidator = new LanguageValidator(); // Export types for use in components -export type { ValidationResult, LanguageCodesMap }; \ No newline at end of file +export type { ValidationResult, LanguageCodesMap }; diff --git a/tsconfig.app.json b/tsconfig.app.json index 5e1feb4..451e157 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, From 9456d202b245e3a744271e0876a68e0dbb1f0325 Mon Sep 17 00:00:00 2001 From: sigmanor Date: Mon, 17 Aug 2026 20:13:41 +0300 Subject: [PATCH 2/6] fix(errors): show what the API actually reported Every failed call was reported as "Failed to connect to Content Alchemist API", whatever went wrong. That hid a real rejection - content-alchemist answering 400 because it could not validate a language code - behind a connection error, and a rejected request needs a different fix than an unreachable service. The thrown message is now surfaced, prefixed with the service name, and the generic wording is kept only for errors that carry no message. --- src/hooks/useCronJobs.ts | 5 +++-- src/hooks/useGenerateHandlers.ts | 9 +++++---- src/hooks/usePreviews.ts | 5 +++-- src/hooks/useRepositories.ts | 3 ++- src/utils/api-error.ts | 21 +++++++++++++++++++++ 5 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 src/utils/api-error.ts diff --git a/src/hooks/useCronJobs.ts b/src/hooks/useCronJobs.ts index f2cc62b..09d4bd7 100644 --- a/src/hooks/useCronJobs.ts +++ b/src/hooks/useCronJobs.ts @@ -3,6 +3,7 @@ import { getCronJobs, type CronJob } from '../api/index'; import { saveCronJobsToCache, getCronJobsFromCache } from '../utils/cache-utils'; import { compareCronJobs } from '../utils/data-comparison'; import { isApiConfigured } from '../utils/api-settings'; +import { maestroErrorMessage } from '../utils/api-error'; interface CronJobsState { cronJobs: CronJob[]; @@ -93,11 +94,11 @@ export const useCronJobs = ({ isCacheBust, setErrorWithScroll }: UseCronJobsProp if (!isBackgroundFetch || isCacheBust) { setState(prev => ({ ...prev, stale: false })); } - } catch { + } catch (error) { if (!isApiConfigured()) { setState(prev => ({ ...prev, loading: false })); } else { - setErrorWithScroll('Failed to connect to Content Maestro API', 'content-maestro-error'); + setErrorWithScroll(maestroErrorMessage(error), 'content-maestro-error'); setState(prev => ({ ...prev, loading: false })); } } diff --git a/src/hooks/useGenerateHandlers.ts b/src/hooks/useGenerateHandlers.ts index 42fd56d..1ceb7fa 100644 --- a/src/hooks/useGenerateHandlers.ts +++ b/src/hooks/useGenerateHandlers.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { manualGenerate, autoGenerate, ManualGenerateResponse } from '../api'; +import { alchemistErrorMessage } from '../utils/api-error'; import type { RepositorySortBy, RepositorySortOrder } from '../types'; import { DEFAULT_REPOSITORY_SORT_BY, @@ -61,8 +62,8 @@ export const useGenerateHandlers = ({ fetchRepositories, setErrorWithScroll }: U } } return response; - } catch { - setErrorWithScroll('Failed to connect to Content Alchemist API', 'content-alchemist-error'); + } catch (error) { + setErrorWithScroll(alchemistErrorMessage(error), 'content-alchemist-error'); return { status: 'error' }; } }, [fetchRepositories, setErrorWithScroll]); @@ -101,8 +102,8 @@ export const useGenerateHandlers = ({ fetchRepositories, setErrorWithScroll }: U ); } return response; - } catch { - setErrorWithScroll('Failed to connect to Content Alchemist API', 'content-alchemist-error'); + } catch (error) { + setErrorWithScroll(alchemistErrorMessage(error), 'content-alchemist-error'); return { status: 'error', added: [], dont_added: [] }; } }, [fetchRepositories, setErrorWithScroll]); diff --git a/src/hooks/usePreviews.ts b/src/hooks/usePreviews.ts index 944a6cd..50e0d14 100644 --- a/src/hooks/usePreviews.ts +++ b/src/hooks/usePreviews.ts @@ -4,6 +4,7 @@ import type { Repository } from '../types'; import { savePreviewsToCache, getPreviewsFromCache } from '../utils/cache-utils'; import { comparePreviews } from '../utils/data-comparison'; import { isApiConfigured } from '../utils/api-settings'; +import { alchemistErrorMessage } from '../utils/api-error'; const DEBUG_DELAY = import.meta.env.DEV ? Number(import.meta.env.VITE_DEBUG_DELAY) || 0 : 0; @@ -107,11 +108,11 @@ export const usePreviews = ({ isCacheBust, setErrorWithScroll }: UsePreviewsProp } await fetchPreviewsFromAPI(isBackgroundFetch); - } catch { + } catch (error) { if (!isApiConfigured()) { setState(prev => ({ ...prev, loading: false })); } else { - setErrorWithScroll('Failed to connect to Content Alchemist API', 'content-alchemist-error'); + setErrorWithScroll(alchemistErrorMessage(error), 'content-alchemist-error'); setState(prev => ({ ...prev, loading: false })); } } diff --git a/src/hooks/useRepositories.ts b/src/hooks/useRepositories.ts index 9c05c9b..135a9aa 100644 --- a/src/hooks/useRepositories.ts +++ b/src/hooks/useRepositories.ts @@ -4,6 +4,7 @@ import type { Repository, RepositorySortBy, RepositorySortOrder } from '../types import { saveRepositoriesToCache, getRepositoriesFromCache } from '../utils/cache-utils'; import { compareRepositories } from '../utils/data-comparison'; import { isApiConfigured, getApiSettings } from "../utils/api-settings"; +import { alchemistErrorMessage } from "../utils/api-error"; import { useRepositoryLocalStorage } from './useRepositoryLocalStorage'; import { DEFAULT_REPOSITORY_SORT_BY, @@ -311,7 +312,7 @@ export const useRepositories = ({ isCacheBust, setErrorWithScroll }: UseReposito // Don't show error toast for language errors - fallback should handle them setState((prev) => ({ ...prev, loading: false })); } else { - setErrorWithScroll("Failed to connect to Content Alchemist API", "content-alchemist-error"); + setErrorWithScroll(alchemistErrorMessage(error), "content-alchemist-error"); setState((prev) => ({ ...prev, loading: false })); } } diff --git a/src/utils/api-error.ts b/src/utils/api-error.ts new file mode 100644 index 0000000..0f30f82 --- /dev/null +++ b/src/utils/api-error.ts @@ -0,0 +1,21 @@ +/** + * Surfaces what the API actually said instead of a blanket "failed to connect". + * A rejected request and an unreachable service need different fixes, and the + * generic wording hid a real 400 (an invalid language code) behind a connection + * error for days. + */ +const apiErrorMessage = (error: unknown, service: string): string => { + const detail = error instanceof Error ? error.message.trim() : ''; + + if (!detail) { + return `Failed to connect to ${service} API`; + } + + return `${service}: ${detail}`; +}; + +export const alchemistErrorMessage = (error: unknown): string => + apiErrorMessage(error, 'Content Alchemist'); + +export const maestroErrorMessage = (error: unknown): string => + apiErrorMessage(error, 'Content Maestro'); From 75a099b47ae6d99d59ce27a888340d206e141a5b Mon Sep 17 00:00:00 2001 From: sigmanor Date: Mon, 17 Aug 2026 20:13:41 +0300 Subject: [PATCH 3/6] feat(cron): add a retry button for partial publications A message run marks its repository as posted as soon as one integration succeeds, so the integrations that failed can never recover the item on a later run and the dashboard offered no way to finish the job by hand. Failed and partial message runs now carry a retry action that re-sends the item to the integrations named in the run details, with a confirm dialog, a loading toast that resolves into the per-integration result, and a history refresh afterwards. Runs recorded before Content Maestro tracked those details fall back to parsing their output, and the dialog then names the repository that would be used so an intervening cron run cannot come as a surprise. --- src/App.tsx | 4 +- src/api/index.ts | 60 ++++++ .../ui/business/cron-job-history.tsx | 171 +++++++++++++++++- .../ui/layout/dashboard-content.tsx | 3 + src/hooks/useCronJobHistory.ts | 14 ++ src/utils/message-retry.ts | 63 +++++++ 6 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 src/utils/message-retry.ts diff --git a/src/App.tsx b/src/App.tsx index 864fdc5..7f8e1fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -119,7 +119,8 @@ function App() { setEndDate: cronJobHistorySetEndDate, resetFilters: cronJobHistoryResetFilters, setPageSize: cronJobHistorySetPageSize, - setPage: cronJobHistorySetPage + setPage: cronJobHistorySetPage, + refresh: refreshCronJobHistory } = useCronJobHistory({ isCacheBust, setErrorWithScroll }); const { @@ -303,6 +304,7 @@ function App() { cronJobHistoryTotalPages={cronJobHistoryTotalPages} cronJobHistoryCurrentPage={cronJobHistoryCurrentPage} cronJobHistorySetPage={cronJobHistorySetPage} + cronJobHistoryOnRetryComplete={refreshCronJobHistory} overviewTimeRange={overviewTimeRange} setOverviewTimeRange={setOverviewTimeRange} overviewHistoryData={overviewHistoryData} diff --git a/src/api/index.ts b/src/api/index.ts index f8f94b2..1c20d4e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,11 +7,35 @@ export interface CronJob { updated_at: string; } +export interface MessageRunDetails { + url?: string; + sent?: string[]; + failed?: string[]; + manual?: boolean; +} + export interface CronJobHistory { name: string; timestamp: string; status: number; output?: string; + /** Only present on message runs recorded after the details column was added. */ + details?: MessageRunDetails; +} + +export interface RetryMessageOutcome { + api_name: string; + success: boolean; + error?: string; +} + +export interface RetryMessageResult { + url: string; + status: number; + message: string; + succeeded?: string[] | null; + failed?: string[] | null; + outcomes?: RetryMessageOutcome[] | null; } export interface CronJobHistoryResponse { @@ -143,6 +167,42 @@ export const updateCronSchedule = async (name: string, schedule: string): Promis } }; +/** + * Re-sends a repository to the integrations that missed it. Omitting `url` lets + * Content Maestro fall back to the most recently published repository, which is + * what a partial run has just consumed. + */ +export const retryMessagePost = async ( + apis: string[], + url?: string +): Promise => { + const settings = getApiSettings(); + + if (!isApiConfigured()) { + throw new Error("API not configured. Check the Content Maestro settings."); + } + + const response = await fetch(`${settings.contentMaestro.apiBaseUrl}/api/message/retry`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.contentMaestro.apiBearerToken}`, + }, + body: JSON.stringify(url ? { apis, url } : { apis }), + }); + + if (!response.ok) { + if (response.status === 429) { + throw new Error("Rate limit exceeded. Please try again later."); + } + // Content Maestro answers errors as plain text. + const detail = (await response.text()).trim(); + throw new Error(detail || `Failed to retry the publication: ${response.status}`); + } + + return response.json(); +}; + export const updateCronStatus = async (name: string, is_active: boolean): Promise => { const settings = getApiSettings(); const isConfigured = isApiConfigured(); diff --git a/src/components/ui/business/cron-job-history.tsx b/src/components/ui/business/cron-job-history.tsx index 535c25d..8b8296b 100644 --- a/src/components/ui/business/cron-job-history.tsx +++ b/src/components/ui/business/cron-job-history.tsx @@ -1,9 +1,19 @@ import { useState, useEffect } from 'react'; import { formatDate, formatDateOnly } from '@/utils/date-format'; -import { getCronJobs } from '@/api/index'; -import { Filter, ChevronDown, Clock, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'; +import { getCronJobs, retryMessagePost } from '@/api/index'; +import { getLatestPostedRepository } from '@/api'; +import { Filter, ChevronDown, Clock, ChevronLeft, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'; import type { CronJobHistory as CronJobHistoryType } from '@/api/index'; +import { getHistoryEntryKey, getRetryTarget, type RetryTarget } from '@/utils/message-retry'; import { TruncatedText } from '@/components/ui/common/truncated-text'; +import { ConfirmDialog } from '@/components/ui/common/confirm-dialog'; +import { toast } from '@/components/ui/common/toast-config'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/base/tooltip"; import { Table, @@ -49,6 +59,8 @@ interface CronJobHistoryProps { endDate?: string; setStartDate?: (startDate?: string) => void; setEndDate?: (endDate?: string) => void; + /** Called after a manual retry so the caller can refresh the history. */ + onRetryComplete?: () => void | Promise; } export const CronJobHistory = ({ @@ -71,7 +83,8 @@ export const CronJobHistory = ({ startDate, endDate, setStartDate, - setEndDate + setEndDate, + onRetryComplete }: CronJobHistoryProps) => { const [cronJobs, setCronJobs] = useState([]); const [selectedJob, setSelectedJob] = useState( @@ -90,6 +103,108 @@ export const CronJobHistory = ({ const saved = localStorage.getItem('cronHistoryShowFilters'); return saved === 'true'; }); + const [retryingKey, setRetryingKey] = useState(null); + const [retryConfirm, setRetryConfirm] = useState<{ + key: string; + target: RetryTarget; + resolvedUrl?: string; + } | null>(null); + + const openRetryConfirm = async (entry: CronJobHistoryType, target: RetryTarget) => { + const key = getHistoryEntryKey(entry); + + if (target.url) { + setRetryConfirm({ key, target }); + return; + } + + // Runs recorded before the details field carry no url, so Content Maestro + // will fall back to the latest published repository. Show which one that is: + // a cron run in between would have moved the target. + let resolvedUrl: string | undefined; + try { + const response = await getLatestPostedRepository(); + resolvedUrl = response.data?.items?.[0]?.url; + } catch { + resolvedUrl = undefined; + } + + setRetryConfirm({ key, target, resolvedUrl }); + }; + + const handleRetry = async (key: string, target: RetryTarget) => { + const toastId = `retry-${key}`; + setRetryingKey(key); + toast.loading(`Re-sending to ${target.failed.join(', ')}...`, { id: toastId }); + + try { + const result = await retryMessagePost(target.failed, target.url); + const succeeded = result.succeeded ?? []; + const failed = result.failed ?? []; + + if (failed.length === 0) { + toast.success(`Sent to ${succeeded.join(', ')}`, { id: toastId }); + } else { + const reasons = (result.outcomes ?? []) + .filter(outcome => !outcome.success) + .map(outcome => `${outcome.api_name}: ${outcome.error ?? 'unknown error'}`) + .join('; '); + toast.error(reasons || `Failed to send to ${failed.join(', ')}`, { id: toastId }); + } + + if (onRetryComplete) { + await onRetryComplete(); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to Content Maestro API'; + toast.error(message, { id: toastId }); + } finally { + setRetryingKey(null); + } + }; + + const renderRetryButton = (entry: CronJobHistoryType, variant: 'desktop' | 'mobile') => { + const target = getRetryTarget(entry); + if (!target) { + return null; + } + + const key = getHistoryEntryKey(entry); + const isRetrying = retryingKey === key; + const label = `Publish to ${target.failed.join(', ')}`; + + const button = ( + + ); + + if (variant === 'mobile') { + return button; + } + + return ( + + + + {button} + + {label} + + + ); + }; useEffect(() => { const fetchJobs = async () => { @@ -417,14 +532,15 @@ export const CronJobHistory = ({ Name - Date + Date Status Output + - +
Data could not be loaded because API keys are not configured
@@ -448,9 +564,10 @@ export const CronJobHistory = ({ Name - Date + Date Status Output + @@ -491,9 +608,10 @@ export const CronJobHistory = ({ Name - Date + Date Status Output + @@ -520,13 +638,18 @@ export const CronJobHistory = ({
+ +
+ {renderRetryButton(entry, 'desktop')} +
+
))}
) : ( - +
No data available
@@ -572,6 +695,8 @@ export const CronJobHistory = ({ + + {renderRetryButton(entry, 'mobile')} )) @@ -724,6 +849,36 @@ export const CronJobHistory = ({ )} + + { + if (retryConfirm) { + handleRetry(retryConfirm.key, retryConfirm.target); + } + setRetryConfirm(null); + }} + onCancel={() => setRetryConfirm(null)} + /> ); +}; + +const buildRetryConfirmMessage = (target: RetryTarget, resolvedUrl?: string): string => { + const integrations = target.failed.join(', '); + + if (target.url) { + return `Send ${target.url} to ${integrations}?`; + } + + if (resolvedUrl) { + return `This run did not record which repository it published, so the latest published one will be used: ${resolvedUrl}. Send it to ${integrations}?`; + } + + return `This run did not record which repository it published, so the latest published one will be used. Send it to ${integrations}?`; }; \ No newline at end of file diff --git a/src/components/ui/layout/dashboard-content.tsx b/src/components/ui/layout/dashboard-content.tsx index 1a700e2..83673fa 100644 --- a/src/components/ui/layout/dashboard-content.tsx +++ b/src/components/ui/layout/dashboard-content.tsx @@ -87,6 +87,7 @@ interface DashboardContentProps { cronJobHistoryTotalPages?: number; cronJobHistoryCurrentPage?: number; cronJobHistorySetPage?: (page: number) => void; + cronJobHistoryOnRetryComplete?: () => void | Promise; overviewTimeRange: TimeRange; setOverviewTimeRange: (range: TimeRange) => void; overviewHistoryData: CronJobHistoryType[]; @@ -144,6 +145,7 @@ export const DashboardContent = ({ cronJobHistoryTotalPages, cronJobHistoryCurrentPage, cronJobHistorySetPage, + cronJobHistoryOnRetryComplete, overviewTimeRange, setOverviewTimeRange, overviewHistoryData, @@ -470,6 +472,7 @@ export const DashboardContent = ({ totalPages={cronJobHistoryTotalPages} currentPage={cronJobHistoryCurrentPage} setPage={cronJobHistorySetPage} + onRetryComplete={cronJobHistoryOnRetryComplete} isApiReady={isApiReady} /> )} diff --git a/src/hooks/useCronJobHistory.ts b/src/hooks/useCronJobHistory.ts index ca2b894..5cff943 100644 --- a/src/hooks/useCronJobHistory.ts +++ b/src/hooks/useCronJobHistory.ts @@ -398,6 +398,19 @@ export const useCronJobHistory = ({ isCacheBust, setErrorWithScroll }: UseCronJo }); }; + // Re-reads the current page with the active filters, bypassing the cache. + const refresh = () => { + return fetchCronJobHistory(true, false, { + page: state.page, + nameFilter: state.nameFilter, + statusFilter: state.statusFilter, + sortOrder: state.sortOrder, + startDate: state.startDate, + endDate: state.endDate, + pageSize: state.pageSize + }); + }; + return { history: state.history, loading: state.loading, @@ -414,6 +427,7 @@ export const useCronJobHistory = ({ isCacheBust, setErrorWithScroll }: UseCronJo newDataAvailable: state.newDataAvailable, hasMore: state.hasMore, fetchCronJobHistory, + refresh, applyNewData, setNameFilter, setStatusFilter, diff --git a/src/utils/message-retry.ts b/src/utils/message-retry.ts new file mode 100644 index 0000000..71a5e2e --- /dev/null +++ b/src/utils/message-retry.ts @@ -0,0 +1,63 @@ +import type { CronJobHistory } from '@/api/index'; + +export interface RetryTarget { + /** Integrations the run did not reach. */ + failed: string[]; + /** + * Repository to re-send. Undefined for runs recorded before the details field + * existed — Content Maestro then falls back to the latest published item. + */ + url?: string; +} + +/** + * Matches the output of a partial message run, e.g. + * "Message sent to: bluesky. Failed: threads. Errors: ...". + * Only runs recorded before the details field existed need this; newer runs + * carry the integration names as structured data. + */ +const LEGACY_FAILED_PATTERN = /Failed:\s*([^.]+)\./; + +const parseLegacyFailedApis = (output?: string): string[] => { + if (!output) { + return []; + } + + const match = output.match(LEGACY_FAILED_PATTERN); + if (!match) { + return []; + } + + return match[1] + .split(',') + .map(name => name.trim()) + .filter(name => name.length > 0); +}; + +/** + * Returns what a manual retry of this run would publish, or null when the run + * cannot be retried: a message run marks its repository as posted as soon as one + * integration succeeds, so only runs with known failed integrations are + * recoverable. + */ +export const getRetryTarget = (entry: CronJobHistory): RetryTarget | null => { + if (entry.name !== 'message' || entry.status === 1) { + return null; + } + + const failedFromDetails = entry.details?.failed ?? []; + if (failedFromDetails.length > 0) { + return { failed: failedFromDetails, url: entry.details?.url || undefined }; + } + + const failedFromOutput = parseLegacyFailedApis(entry.output); + if (failedFromOutput.length > 0) { + return { failed: failedFromOutput }; + } + + return null; +}; + +/** Stable identity for a history row, which has no id of its own. */ +export const getHistoryEntryKey = (entry: CronJobHistory): string => + `${entry.name}-${entry.timestamp}`; From a4a9a3d0ef9fe3cf91390255811cf63d4180c59c Mon Sep 17 00:00:00 2001 From: sigmanor Date: Mon, 17 Aug 2026 20:53:46 +0300 Subject: [PATCH 4/6] fix(cron): never offer a retry that would publish the wrong repository The retry action appeared on any message run that was not fully successful, but a run can fail without ever resolving an item: an empty queue, or Content Alchemist rejecting every request, makes the job report every integration as failed while recording no repository. Retrying such a run fell back to "the latest published repository" and re-published it to every connector - a public, irreversible duplicate. A run is now retryable only when both halves are known: the integrations that missed the item and the item itself. Legacy runs recorded before the details field are still covered - the older output wording carries the repository, and where it does not the url is resolved and pinned into the request rather than left to the backend, so the confirm dialog names exactly what will be published. Also in this path: - the legacy parser no longer stops at the first period, so integration names containing one survive, the oldest output format is recognised, and "Failed:" appearing inside an error message is no longer read as a list of integrations. - the history refresh moved out of the publish try block: its failure used to overwrite the success toast with a fetch error, inviting a second click. - a row whose retry succeeded stops offering the action, since history rows are immutable and keep looking unhandled. - refresh reads the live filter state through a ref, so a slow retry cannot drag the user back to the page they started on. - the loading skeleton has the same column count as the table it stands in for. --- .../ui/business/cron-job-history.tsx | 116 ++++++++++++------ src/hooks/useCronJobHistory.ts | 24 ++-- src/utils/message-retry.ts | 66 +++++++--- 3 files changed, 143 insertions(+), 63 deletions(-) diff --git a/src/components/ui/business/cron-job-history.tsx b/src/components/ui/business/cron-job-history.tsx index 8b8296b..f03a5be 100644 --- a/src/components/ui/business/cron-job-history.tsx +++ b/src/components/ui/business/cron-job-history.tsx @@ -4,7 +4,7 @@ import { getCronJobs, retryMessagePost } from '@/api/index'; import { getLatestPostedRepository } from '@/api'; import { Filter, ChevronDown, Clock, ChevronLeft, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'; import type { CronJobHistory as CronJobHistoryType } from '@/api/index'; -import { getHistoryEntryKey, getRetryTarget, type RetryTarget } from '@/utils/message-retry'; +import { getHistoryEntryKey, getLegacyFailedApis, getRetryTarget } from '@/utils/message-retry'; import { TruncatedText } from '@/components/ui/common/truncated-text'; import { ConfirmDialog } from '@/components/ui/common/confirm-dialog'; import { toast } from '@/components/ui/common/toast-config'; @@ -104,56 +104,64 @@ export const CronJobHistory = ({ return saved === 'true'; }); const [retryingKey, setRetryingKey] = useState(null); + const [retriedKeys, setRetriedKeys] = useState([]); const [retryConfirm, setRetryConfirm] = useState<{ key: string; - target: RetryTarget; - resolvedUrl?: string; + failed: string[]; + url: string; + /** True when the url was resolved for the run rather than recorded by it. */ + resolved: boolean; } | null>(null); - const openRetryConfirm = async (entry: CronJobHistoryType, target: RetryTarget) => { + const openRetryConfirm = async (entry: CronJobHistoryType, plan: RetryPlan) => { const key = getHistoryEntryKey(entry); - if (target.url) { - setRetryConfirm({ key, target }); + if (plan.url) { + setRetryConfirm({ key, failed: plan.failed, url: plan.url, resolved: false }); return; } - // Runs recorded before the details field carry no url, so Content Maestro - // will fall back to the latest published repository. Show which one that is: - // a cron run in between would have moved the target. - let resolvedUrl: string | undefined; + // Runs recorded before Content Maestro tracked the item carry no url, so it + // has to be resolved here and pinned into the request: leaving that to the + // backend would publish whatever is latest at confirm time, which a cron run + // in between would have changed. + setRetryingKey(key); try { const response = await getLatestPostedRepository(); - resolvedUrl = response.data?.items?.[0]?.url; - } catch { - resolvedUrl = undefined; + const resolvedUrl = response.data?.items?.[0]?.url; + if (!resolvedUrl) { + toast.error('Could not determine which repository to publish', { id: `retry-${key}` }); + return; + } + setRetryConfirm({ key, failed: plan.failed, url: resolvedUrl, resolved: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to Content Alchemist API'; + toast.error(message, { id: `retry-${key}` }); + } finally { + setRetryingKey(null); } - - setRetryConfirm({ key, target, resolvedUrl }); }; - const handleRetry = async (key: string, target: RetryTarget) => { + const handleRetry = async (key: string, failed: string[], url: string) => { const toastId = `retry-${key}`; setRetryingKey(key); - toast.loading(`Re-sending to ${target.failed.join(', ')}...`, { id: toastId }); + toast.loading(`Re-sending to ${failed.join(', ')}...`, { id: toastId }); + let published = false; try { - const result = await retryMessagePost(target.failed, target.url); + const result = await retryMessagePost(failed, url); const succeeded = result.succeeded ?? []; - const failed = result.failed ?? []; + const failures = result.failed ?? []; + published = succeeded.length > 0; - if (failed.length === 0) { + if (failures.length === 0) { toast.success(`Sent to ${succeeded.join(', ')}`, { id: toastId }); } else { const reasons = (result.outcomes ?? []) .filter(outcome => !outcome.success) .map(outcome => `${outcome.api_name}: ${outcome.error ?? 'unknown error'}`) .join('; '); - toast.error(reasons || `Failed to send to ${failed.join(', ')}`, { id: toastId }); - } - - if (onRetryComplete) { - await onRetryComplete(); + toast.error(reasons || `Failed to send to ${failures.join(', ')}`, { id: toastId }); } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to connect to Content Maestro API'; @@ -161,23 +169,39 @@ export const CronJobHistory = ({ } finally { setRetryingKey(null); } + + if (published) { + // Rows are immutable, so the original run keeps looking retryable. Remember + // the ones already handled to keep a second click from publishing twice. + setRetriedKeys(previous => [...previous, key]); + } + + // Refreshing is a separate concern: a failure here must not overwrite the + // result of the publish that just happened. + if (onRetryComplete) { + try { + await onRetryComplete(); + } catch { + // useCronJobHistory already surfaces its own fetch errors. + } + } }; const renderRetryButton = (entry: CronJobHistoryType, variant: 'desktop' | 'mobile') => { - const target = getRetryTarget(entry); - if (!target) { + const plan = getRetryPlan(entry); + const key = getHistoryEntryKey(entry); + if (!plan || retriedKeys.includes(key)) { return null; } - const key = getHistoryEntryKey(entry); const isRetrying = retryingKey === key; - const label = `Publish to ${target.failed.join(', ')}`; + const label = `Publish to ${plan.failed.join(', ')}`; const button = (