diff --git a/packages/api/typings/app.ts b/packages/api/typings/app.ts index 94ffb62a..1cd1ca76 100644 --- a/packages/api/typings/app.ts +++ b/packages/api/typings/app.ts @@ -199,6 +199,7 @@ export type UIConfig = Partial<{ boardLogo: { path: string; width?: number | string; height?: number | string }; miscLinks: Array; favIcon: FavIcon; + locale: { lng?: string }; }>; export type FavIcon = { diff --git a/packages/ui/package.json b/packages/ui/package.json index d10c6ebc..28a6f702 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -58,6 +58,9 @@ "fork-ts-checker-webpack-plugin": "^7.2.7", "highlight.js": "^11.5.1", "html-webpack-plugin": "^5.5.0", + "i18next": "^23.7.7", + "i18next-hmr": "^3.0.3", + "i18next-http-backend": "^2.4.2", "mini-css-extract-plugin": "^2.6.0", "nanoid": "^4.0.1", "postcss": "^8.4.12", @@ -66,6 +69,7 @@ "pretty-bytes": "^6.0.0", "react": "^17.0.0", "react-dom": "^17.0.0", + "react-i18next": "^13.5.0", "react-paginate": "^8.1.3", "react-refresh": "^0.12.0", "react-router-dom": "^5.3.1", diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index 130d996a..674ce9ca 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -6,7 +6,7 @@ cursor: pointer; outline: none; white-space: nowrap; - padding: .65em .92857143em; + padding: 0.65em 0.92857143em; color: inherit; font-family: inherit; vertical-align: baseline; @@ -18,7 +18,7 @@ } .button + .button { - margin-inline-start: 0.25em; + margin-inline-start: 0.5rem; } .button.default > svg { @@ -81,4 +81,3 @@ .button.primary:focus { background-color: hsl(216, 15%, 37%); } - diff --git a/packages/ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/ui/src/components/ConfirmModal/ConfirmModal.tsx index 27b6bcca..83cf8844 100644 --- a/packages/ui/src/components/ConfirmModal/ConfirmModal.tsx +++ b/packages/ui/src/components/ConfirmModal/ConfirmModal.tsx @@ -10,6 +10,7 @@ import { } from '@radix-ui/react-alert-dialog'; import cn from 'clsx'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import s from './ConfirmModal.module.css'; import modalStyles from '../Modal/Modal.module.css'; import { Button } from '../Button/Button'; @@ -23,6 +24,7 @@ export interface ConfirmProps { } export const ConfirmModal = ({ open, onConfirm, title, onCancel, description }: ConfirmProps) => { + const { t } = useTranslation(); const closeOnOpenChange = (open: boolean) => { if (!open) { onCancel(); @@ -44,12 +46,12 @@ export const ConfirmModal = ({ open, onConfirm, title, onCancel, description }:
diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index ccf8f3d7..de007b85 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -12,7 +12,7 @@ export const Header = ({ children }: PropsWithChildren) => { return (
- + {!!logoPath && ( { const { tabs, selectedTab } = useDetailsTabs(status, job.isFailed); + const { t } = useTranslation(); if (tabs.length === 0) { return null; @@ -24,7 +26,7 @@ export const Details = ({ status, job, actions }: DetailsProps) => { {tabs.map((tab) => (
  • ))} diff --git a/packages/ui/src/components/JobCard/Details/DetailsContent/DetailsContent.tsx b/packages/ui/src/components/JobCard/Details/DetailsContent/DetailsContent.tsx index 0eb50741..b26632ee 100644 --- a/packages/ui/src/components/JobCard/Details/DetailsContent/DetailsContent.tsx +++ b/packages/ui/src/components/JobCard/Details/DetailsContent/DetailsContent.tsx @@ -1,5 +1,6 @@ import { AppJob } from '@bull-board/api/typings/app'; import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { TabsType } from '../../../../hooks/useDetailsTabs'; import { useSettingsStore } from '../../../../hooks/useSettings'; import { Highlight } from '../../../Highlight/Highlight'; @@ -16,6 +17,7 @@ interface DetailsContentProps { } export const DetailsContent = ({ selectedTab, job, actions }: DetailsContentProps) => { + const { t } = useTranslation(); const { collapseJobData, collapseJobOptions, collapseJobError } = useSettingsStore(); const [collapseState, setCollapse] = useState({ data: false, options: false, error: false }); const { stacktrace, data, returnValue, opts, failedReason } = job; @@ -24,7 +26,7 @@ export const DetailsContent = ({ selectedTab, job, actions }: DetailsContentProp case 'Data': return collapseJobData && !collapseState.data ? ( ) : ( {JSON.stringify({ data, returnValue }, null, 2)} @@ -32,19 +34,19 @@ export const DetailsContent = ({ selectedTab, job, actions }: DetailsContentProp case 'Options': return collapseJobOptions && !collapseState.options ? ( ) : ( {JSON.stringify(opts, null, 2)} ); case 'Error': if (stacktrace.length === 0) { - return
    {!!failedReason ? failedReason : 'NA'}
    ; + return
    {!!failedReason ? failedReason : t('JOB.NA')}
    ; } return collapseJobError && !collapseState.error ? ( ) : ( diff --git a/packages/ui/src/components/JobCard/Details/DetailsContent/JobLogs/JobLogs.tsx b/packages/ui/src/components/JobCard/Details/DetailsContent/JobLogs/JobLogs.tsx index a22f6314..9e3f5957 100644 --- a/packages/ui/src/components/JobCard/Details/DetailsContent/JobLogs/JobLogs.tsx +++ b/packages/ui/src/components/JobCard/Details/DetailsContent/JobLogs/JobLogs.tsx @@ -1,5 +1,6 @@ import { AppJob } from '@bull-board/api/typings/app'; import React, { SyntheticEvent, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useInterval } from '../../../../../hooks/useInterval'; import { InputField } from '../../../../Form/InputField/InputField'; import { FullscreenIcon } from '../../../../Icons/Fullscreen'; @@ -39,6 +40,7 @@ function formatLogs(logs: string[]) { } export const JobLogs = ({ actions, job }: JobLogsProps) => { + const { t } = useTranslation(); const [logs, setLogs] = useState([]); const [liveLogs, setLiveLogs] = useState(false); const [keyword, setKeyword] = useState(''); @@ -96,7 +98,7 @@ export const JobLogs = ({ actions, job }: JobLogsProps) => { className={s.searchBar} name="searchQuery" type="search" - placeholder="Filters" + placeholder={t('JOB.LOGS.FILTER_PLACEHOLDER')} onChange={onSearch} /> diff --git a/packages/ui/src/components/JobCard/JobActions/JobActions.tsx b/packages/ui/src/components/JobCard/JobActions/JobActions.tsx index 3e853645..d5cb8346 100644 --- a/packages/ui/src/components/JobCard/JobActions/JobActions.tsx +++ b/packages/ui/src/components/JobCard/JobActions/JobActions.tsx @@ -1,6 +1,7 @@ import { STATUSES } from '@bull-board/api/src/constants/statuses'; import { Status } from '@bull-board/api/typings/app'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from '../../Button/Button'; import { PromoteIcon } from '../../Icons/Promote'; import { RetryIcon } from '../../Icons/Retry'; @@ -39,6 +40,7 @@ const statusToButtonsMap: Record = { export const JobActions = ({ actions, status, allowRetries }: JobActionsProps) => { let buttons = statusToButtonsMap[status]; + const { t } = useTranslation(); if (!buttons) { return null; } @@ -51,7 +53,7 @@ export const JobActions = ({ actions, status, allowRetries }: JobActionsProps) =
      {buttons.map((type) => (
    • - + diff --git a/packages/ui/src/components/JobCard/JobCard.tsx b/packages/ui/src/components/JobCard/JobCard.tsx index cb60de67..f4baa8aa 100644 --- a/packages/ui/src/components/JobCard/JobCard.tsx +++ b/packages/ui/src/components/JobCard/JobCard.tsx @@ -1,6 +1,7 @@ import { STATUSES } from '@bull-board/api/src/constants/statuses'; import { AppJob, Status } from '@bull-board/api/typings/app'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Card } from '../Card/Card'; import { Details } from './Details/Details'; @@ -32,46 +33,51 @@ export const JobCard = ({ readOnlyMode, allowRetries, jobUrl, -}: JobCardProps) => ( - -
      - {jobUrl ? ( - +}: JobCardProps) => { + const { t } = useTranslation(); + return ( + +
      + {jobUrl ? ( + + #{job.id} + + ) : ( #{job.id} - - ) : ( - #{job.id} - )} - -
      -
      -
      -

      - {job.name} - {job.attempts > 1 && attempt #{job.attempts}} - {!!job.opts?.repeat?.count && ( - - repeat {job.opts?.repeat?.count} - {!!job.opts?.repeat?.limit && ` / ${job.opts?.repeat?.limit}`} - - )} -

      - {!readOnlyMode && ( - )} +
      -
      -
      - {typeof job.progress === 'number' && ( - - )} +
      +
      +

      + {job.name} + {job.attempts > 1 && {t('JOB.ATTEMPTS', { attempts: job.attempts })}} + {!!job.opts?.repeat?.count && ( + + {t(`JOB.REPEAT${!!job.opts?.repeat?.limit ? '_WITH_LIMIT' : ''}`, { + count: job.opts.repeat.count, + limit: job.opts?.repeat?.limit, + })} + + )} +

      + {!readOnlyMode && ( + + )} +
      +
      +
      + {typeof job.progress === 'number' && ( + + )} +
      -
      - -); + + ); +}; diff --git a/packages/ui/src/components/JobCard/Timeline/Timeline.tsx b/packages/ui/src/components/JobCard/Timeline/Timeline.tsx index 9be94a98..2320c87b 100644 --- a/packages/ui/src/components/JobCard/Timeline/Timeline.tsx +++ b/packages/ui/src/components/JobCard/Timeline/Timeline.tsx @@ -1,65 +1,92 @@ -import { format, formatDistance, getYear, isToday, differenceInMilliseconds } from 'date-fns'; +import { formatDistance, getYear, isToday, differenceInMilliseconds } from 'date-fns'; +import enLocale from 'date-fns/locale/en-US'; +import { TFunction } from 'i18next'; import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { dateFnsLocale } from '../../../services/i18n'; import s from './Timeline.module.css'; import { AppJob, Status } from '@bull-board/api/typings/app'; import { STATUSES } from '@bull-board/api/src/constants/statuses'; type TimeStamp = number | Date; -const formatDate = (ts: TimeStamp) => { +const formatDate = (ts: TimeStamp, locale: string) => { + let options: Intl.DateTimeFormatOptions; if (isToday(ts)) { - return format(ts, 'HH:mm:ss'); + options = { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }; + } else if (getYear(ts) === getYear(new Date())) { + options = { + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }; + } else { + options = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }; } - return getYear(ts) === getYear(new Date()) - ? format(ts, 'MM/dd HH:mm:ss') - : format(ts, 'MM/dd/yyyy HH:mm:ss'); + return new Intl.DateTimeFormat(locale, options).format(ts); }; -const formatDuration = (finishedTs: TimeStamp, processedTs: TimeStamp) => { + +const formatDuration = (finishedTs: TimeStamp, processedTs: TimeStamp, t: TFunction) => { const durationInMs = differenceInMilliseconds(finishedTs, processedTs); const durationInSeconds = durationInMs / 1000; if (durationInSeconds > 5) { return formatDistance(finishedTs, processedTs, { includeSeconds: true, + locale: dateFnsLocale || enLocale, }); } if (durationInSeconds >= 1) { - return `${durationInSeconds.toFixed(2)} seconds`; + return t('JOB.DURATION.SECS', { duration: durationInSeconds.toFixed(2) }); } - return `${durationInMs} milliseconds`; -} + return t('JOB.DURATION.MILLI_SECS', { duration: durationInMs }); +}; export const Timeline = function Timeline({ job, status }: { job: AppJob; status: Status }) { + const { t, i18n } = useTranslation(); return (
      • - Added at - + {t('JOB.ADDED_AT')} +
      • {!!job.delay && job.delay > 0 && status === STATUSES.delayed && (
      • - Will run at - + {t('JOB.WILL_RUN_AT')} +
      • )} {!!job.processedOn && (
      • - {job.delay && job.delay > 0 ? 'delayed for ' : ''} - {formatDuration(job.processedOn, job.timestamp || 0)} + {!!job.delay && job.delay > 0 && t('JOB.DELAYED_FOR') + ' '} + {formatDuration(job.processedOn, job.timestamp || 0, t)} - Process started at - + {t('JOB.PROCESS_STARTED_AT')} +
      • )} {!!job.finishedOn && (
      • + {formatDuration(job.finishedOn, job.processedOn || 0, t)} - {formatDuration(job.finishedOn, job.processedOn || 0)} + {t(job.isFailed && status !== STATUSES.active ? `JOB.FAILED_AT` : 'JOB.FINISHED_AT')} - {job.isFailed && status !== STATUSES.active ? 'Failed' : 'Finished'} at - +
      • )}
      diff --git a/packages/ui/src/components/Loader/Loader.tsx b/packages/ui/src/components/Loader/Loader.tsx index 45f44141..e5e0558b 100644 --- a/packages/ui/src/components/Loader/Loader.tsx +++ b/packages/ui/src/components/Loader/Loader.tsx @@ -1,3 +1,7 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; -export const Loader = () =>
      Loading...
      ; +export const Loader = () => { + const { t } = useTranslation(); + return
      {t('LOADING')}
      ; +}; diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index 3e0e4748..6f2a0fc0 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -1,6 +1,7 @@ import { AppQueue } from '@bull-board/api/typings/app'; import cn from 'clsx'; import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { NavLink } from 'react-router-dom'; import { useSelectedStatuses } from '../../hooks/useSelectedStatuses'; import { links } from '../../utils/links'; @@ -8,12 +9,13 @@ import { SearchIcon } from '../Icons/Search'; import s from './Menu.module.css'; export const Menu = ({ queues }: { queues: AppQueue[] | null }) => { + const { t } = useTranslation(); const selectedStatuses = useSelectedStatuses(); const [searchTerm, setSearchTerm] = useState(''); return (
    • ))} diff --git a/packages/ui/src/components/Modal/Modal.tsx b/packages/ui/src/components/Modal/Modal.tsx index 8b154be0..7d8cd36d 100644 --- a/packages/ui/src/components/Modal/Modal.tsx +++ b/packages/ui/src/components/Modal/Modal.tsx @@ -1,6 +1,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import cn from 'clsx'; import React, { PropsWithChildren } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from '../Button/Button'; import s from './Modal.module.css'; @@ -20,6 +21,7 @@ export const Modal = ({ width, actionButton, }: PropsWithChildren) => { + const { t } = useTranslation(); const closeOnOpenChange = (open: boolean) => { if (!open) { onClose(); @@ -39,7 +41,7 @@ export const Modal = ({
      {actionButton} - +
      diff --git a/packages/ui/src/components/QueueActions/QueueActions.tsx b/packages/ui/src/components/QueueActions/QueueActions.tsx index 5cd51c64..73435b82 100644 --- a/packages/ui/src/components/QueueActions/QueueActions.tsx +++ b/packages/ui/src/components/QueueActions/QueueActions.tsx @@ -1,6 +1,7 @@ import { STATUSES } from '@bull-board/api/src/constants/statuses'; import { AppQueue, JobCleanStatus, JobRetryStatus, Status } from '@bull-board/api/typings/app'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { QueueActions as QueueActionsType } from '../../../typings/app'; import { Button } from '../Button/Button'; import { PromoteIcon } from '../Icons/Promote'; @@ -32,6 +33,7 @@ function isPromoteAllStatus(status: any): status is JobRetryStatus { } export const QueueActions = ({ status, actions, queue, allowRetries }: QueueActionProps) => { + const { t } = useTranslation(); if (!isStatusActionable(status)) { return null; } @@ -42,7 +44,7 @@ export const QueueActions = ({ status, actions, queue, allowRetries }: QueueActi
    • )} @@ -50,7 +52,7 @@ export const QueueActions = ({ status, actions, queue, allowRetries }: QueueActi
    • )} @@ -58,7 +60,7 @@ export const QueueActions = ({ status, actions, queue, allowRetries }: QueueActi
    • )} diff --git a/packages/ui/src/components/QueueCard/QueueStats/QueueStats.tsx b/packages/ui/src/components/QueueCard/QueueStats/QueueStats.tsx index 5058132d..7a66153b 100644 --- a/packages/ui/src/components/QueueCard/QueueStats/QueueStats.tsx +++ b/packages/ui/src/components/QueueCard/QueueStats/QueueStats.tsx @@ -1,5 +1,6 @@ import { AppQueue } from '@bull-board/api/typings/app'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { toCamelCase } from '../../../utils/toCamelCase'; import s from './QueueStats.module.css'; @@ -8,6 +9,7 @@ interface IQueueStatsProps { } export const QueueStats = ({ queue }: IQueueStatsProps) => { + const { t } = useTranslation(); const total = queue.statuses.reduce((result, status) => result + (queue.counts[status] || 0), 0); return ( @@ -33,7 +35,7 @@ export const QueueStats = ({ queue }: IQueueStatsProps) => { ); })} -
      {total} Jobs
      +
      {t('DASHBOARD.JOBS_COUNT', { count: total })}
      ); }; diff --git a/packages/ui/src/components/QueueDropdownActions/QueueDropdownActions.tsx b/packages/ui/src/components/QueueDropdownActions/QueueDropdownActions.tsx index b5efb561..e3597f20 100644 --- a/packages/ui/src/components/QueueDropdownActions/QueueDropdownActions.tsx +++ b/packages/ui/src/components/QueueDropdownActions/QueueDropdownActions.tsx @@ -1,6 +1,7 @@ import { AppQueue } from '@bull-board/api/typings/app'; import { Item, Portal, Root, Trigger } from '@radix-ui/react-dropdown-menu'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { QueueActions } from '../../../typings/app'; import { Button } from '../Button/Button'; import { DropdownContent } from '../DropdownContent/DropdownContent'; @@ -16,38 +17,41 @@ export const QueueDropdownActions = ({ }: { queue: AppQueue; actions: QueueActions; -}) => ( - - - - +}) => { + const { t } = useTranslation(); + return ( + + + + - - - - {queue.isPaused ? ( - <> - - Resume - - ) : ( - <> - - Pause - - )} - - - - Empty - - - - -); + + + + {queue.isPaused ? ( + <> + + {t('QUEUE.ACTIONS.RESUME')} + + ) : ( + <> + + {t('QUEUE.ACTIONS.PAUSE')} + + )} + + + + {t('QUEUE.ACTIONS.EMPTY')} + + + + + ); +}; diff --git a/packages/ui/src/components/RedisStatsModal/RedisStatsModal.tsx b/packages/ui/src/components/RedisStatsModal/RedisStatsModal.tsx index 98eb0272..0c41bcd1 100644 --- a/packages/ui/src/components/RedisStatsModal/RedisStatsModal.tsx +++ b/packages/ui/src/components/RedisStatsModal/RedisStatsModal.tsx @@ -1,6 +1,7 @@ import { RedisStats } from '@bull-board/api/typings/app'; import formatBytes from 'pretty-bytes'; import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useApi } from '../../hooks/useApi'; import { useInterval } from '../../hooks/useInterval'; import { Modal } from '../Modal/Modal'; @@ -28,6 +29,7 @@ export interface RedisStatsModalProps { } export const RedisStatsModal = ({ open, onClose }: RedisStatsModalProps) => { + const { t } = useTranslation(); const [stats, setStats] = useState(null as any); const api = useApi(); @@ -39,7 +41,7 @@ export const RedisStatsModal = ({ open, onClose }: RedisStatsModalProps) => { const items = [ { - title: 'Memory usage', + title: t('REDIS.MEMORY_USAGE'), value: ( <> {stats.memory.total && stats.memory.used ? ( @@ -47,24 +49,24 @@ export const RedisStatsModal = ({ open, onClose }: RedisStatsModalProps) => { {formatBytes(stats.memory.used)} of {formatBytes(stats.memory.total)} ) : ( - Could not retrieve memory stats + {t('REDIS.ERROR.MEMORY_USAGE')} )} {getMemoryUsage(stats.memory.used, stats.memory.total)} ), }, - { title: 'Peak memory usage', value: formatBytes(stats.memory.peak) }, - { title: 'Fragmentation ratio', value: stats.memory.fragmentationRatio }, - { title: 'Connected clients', value: stats.clients.connected }, - { title: 'Blocked clients', value: stats.clients.blocked }, - { title: 'Version', value: stats.version }, - { title: 'Mode', value: stats.mode }, - { title: 'OS', value: stats.os }, - { title: 'Up time', value: stats.uptime }, + { title: t('REDIS.PEEK_MEMORY'), value: formatBytes(stats.memory.peak) }, + { title: t('REDIS.FRAGMENTATION_RATIO'), value: stats.memory.fragmentationRatio }, + { title: t('REDIS.CONNECTED_CLIENTS'), value: stats.clients.connected }, + { title: t('REDIS.BLOCKED_CLIENTS'), value: stats.clients.blocked }, + { title: t('REDIS.VERSION'), value: stats.version }, + { title: t('REDIS.MODE'), value: stats.mode }, + { title: t('REDIS.OS'), value: stats.os }, + { title: t('REDIS.UP_TIME'), value: stats.uptime }, ]; return ( - +
        {items.map((item, i) => (
      • diff --git a/packages/ui/src/components/SettingsModal/SettingsModal.tsx b/packages/ui/src/components/SettingsModal/SettingsModal.tsx index 3eb789bf..c2d7ceff 100644 --- a/packages/ui/src/components/SettingsModal/SettingsModal.tsx +++ b/packages/ui/src/components/SettingsModal/SettingsModal.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useSettingsStore } from '../../hooks/useSettings'; import { InputField } from '../Form/InputField/InputField'; import { SelectField } from '../Form/SelectField/InputField'; @@ -11,15 +12,7 @@ export interface SettingsModalProps { onClose(): void; } -const pollingIntervals = [-1, 3, 5, 10, 20, 60, 60 * 5, 60 * 15].map((interval) => ({ - text: - interval < 0 - ? 'Off' - : Math.floor(interval / 60) === 0 - ? `${interval} seconds` - : `${interval / 60} minutes`, - value: `${interval}`, -})); +const pollingIntervals = [-1, 3, 5, 10, 20, 60, 60 * 5, 60 * 15]; export const SettingsModal = ({ open, onClose }: SettingsModalProps) => { const { @@ -32,18 +25,27 @@ export const SettingsModal = ({ open, onClose }: SettingsModalProps) => { collapseJobError, setSettings, } = useSettingsStore((state) => state); + const { t } = useTranslation(); return ( - + ({ + text: + interval < 0 + ? t('SETTINGS.POLLING_OPTIONS.OFF') + : Math.floor(interval / 60) === 0 + ? t('SETTINGS.POLLING_OPTIONS.SECS', { count: interval }) + : t('SETTINGS.POLLING_OPTIONS.MINS', { count: interval / 60 }), + value: `${interval}`, + }))} value={`${pollingInterval}`} onChange={(event) => setSettings({ pollingInterval: +event.target.value })} /> { }} /> setSettings({ confirmQueueActions: checked })} /> setSettings({ confirmJobActions: checked })} /> setSettings({ collapseJobData: checked })} /> setSettings({ collapseJobOptions: checked })} /> setSettings({ collapseJobError: checked })} diff --git a/packages/ui/src/components/StatusLegend/StatusLegend.tsx b/packages/ui/src/components/StatusLegend/StatusLegend.tsx index b77caca8..5a4e6dde 100644 --- a/packages/ui/src/components/StatusLegend/StatusLegend.tsx +++ b/packages/ui/src/components/StatusLegend/StatusLegend.tsx @@ -1,14 +1,18 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { queueStatsStatusList } from '../../constants/queue-stats-status'; import { toCamelCase } from '../../utils/toCamelCase'; import s from './StatusLegend.module.css'; -export const StatusLegend = () => ( -
          - {queueStatsStatusList.map((status) => ( -
        • - {status} -
        • - ))} -
        -); +export const StatusLegend = () => { + const { t } = useTranslation(); + return ( +
          + {queueStatsStatusList.map((status) => ( +
        • + {t(`QUEUE.STATUS.${status.toUpperCase()}`)} +
        • + ))} +
        + ); +}; diff --git a/packages/ui/src/components/StatusMenu/StatusMenu.tsx b/packages/ui/src/components/StatusMenu/StatusMenu.tsx index bbbb3fc2..c3ff5abf 100644 --- a/packages/ui/src/components/StatusMenu/StatusMenu.tsx +++ b/packages/ui/src/components/StatusMenu/StatusMenu.tsx @@ -1,17 +1,19 @@ import { AppQueue } from '@bull-board/api/typings/app'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { NavLink, useRouteMatch } from 'react-router-dom'; import { QueueDropdownActions } from '../QueueDropdownActions/QueueDropdownActions'; import s from './StatusMenu.module.css'; export const StatusMenu = ({ queue, actions }: { queue: AppQueue; actions: any }) => { const { url } = useRouteMatch(); + const { t } = useTranslation(); return (
        {queue.statuses.map((status) => { const isLatest = status === 'latest'; - const displayStatus = status.toLocaleUpperCase(); + const displayStatus = t(`QUEUE.STATUS.${status.toUpperCase()}`).toLocaleUpperCase(); return ( ((set) => ({ })); export function useConfirm(): ConfirmApi { + const { t } = useTranslation(); const { promise, opts, setState } = useConfirmStore((state) => state); return { confirmProps: { open: !!promise, - title: opts?.title || 'Are you sure?', + title: opts?.title || t('CONFIRM.DEFAULT_TITLE'), description: opts?.description || '', onCancel: function onCancel() { setState({ diff --git a/packages/ui/src/hooks/useQueues.ts b/packages/ui/src/hooks/useQueues.ts index 0dba3441..dda34fdd 100644 --- a/packages/ui/src/hooks/useQueues.ts +++ b/packages/ui/src/hooks/useQueues.ts @@ -1,7 +1,7 @@ -import { STATUSES } from '@bull-board/api/src/constants/statuses'; import { JobCleanStatus, JobRetryStatus } from '@bull-board/api/typings/app'; import { GetQueuesResponse } from '@bull-board/api/typings/responses'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { create } from 'zustand'; import { QueueActions } from '../../typings/app'; import { getConfirmFor } from '../utils/getConfirmFor'; @@ -27,6 +27,7 @@ const useQueuesStore = create((set) => ({ export function useQueues(): Omit & { actions: QueueActions } { const query = useQuery(); + const { t } = useTranslation(); const api = useApi(); const activeQueueName = useActiveQueueName(); const selectedStatuses = useSelectedStatuses(); @@ -68,42 +69,42 @@ export function useQueues(): Omit & { actions: Queu const retryAll = (queueName: string, status: JobRetryStatus) => withConfirmAndUpdate( () => api.retryAll(queueName, status), - `Are you sure that you want to retry all ${status} jobs?`, + t('QUEUE.ACTIONS.RETRY_ALL_CONFIRM_MSG', { status }), confirmQueueActions ); const promoteAll = (queueName: string) => withConfirmAndUpdate( () => api.promoteAll(queueName), - `Are you sure that you want to promote all ${STATUSES.delayed} jobs?`, + t('QUEUE.ACTIONS.PROMOTE_ALL_CONFIRM_MSG'), confirmQueueActions ); const cleanAll = (queueName: string, status: JobCleanStatus) => withConfirmAndUpdate( () => api.cleanAll(queueName, status), - `Are you sure that you want to clean all ${status} jobs?`, + t('QUEUE.ACTIONS.CLEAN_ALL_CONFIRM_MSG', { status }), confirmQueueActions ); const pauseQueue = (queueName: string) => withConfirmAndUpdate( () => api.pauseQueue(queueName), - 'Are you sure that you want to pause queue processing?', + t('QUEUE.ACTIONS.PAUSE_QUEUE_CONFIRM_MSG'), confirmQueueActions ); const resumeQueue = (queueName: string) => withConfirmAndUpdate( () => api.resumeQueue(queueName), - 'Are you sure that you want to resume queue processing?', + t('QUEUE.ACTIONS.RESUME_QUEUE_CONFIRM_MSG'), confirmQueueActions ); const emptyQueue = (queueName: string) => withConfirmAndUpdate( () => api.emptyQueue(queueName), - 'Are you sure that you want to empty the queue?', + t('QUEUE.ACTIONS.EMPTY_QUEUE_CONFIRM_MSG'), confirmQueueActions ); diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 6fd920ab..94cf0193 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -1,3 +1,4 @@ +import { UIConfig } from '@bull-board/api/typings/app'; import React from 'react'; import { render } from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; @@ -8,19 +9,24 @@ import { UIConfigContext } from './hooks/useUIConfig'; import { Api } from './services/Api'; import './theme.css'; import './toastify.css'; +import { initI18n } from './services/i18n'; const basePath = ((window as any).__basePath__ = document.head.querySelector('base')?.getAttribute('href') || ''); const api = new Api({ basePath }); -const uiConfig = JSON.parse(document.getElementById('__UI_CONFIG__')?.textContent || '{}'); +const uiConfig = JSON.parse( + document.getElementById('__UI_CONFIG__')?.textContent || '{}' +) as UIConfig; -render( - - - - - - - , - document.getElementById('root') -); +initI18n({ lng: uiConfig.locale?.lng || 'en', basePath }).then(() => { + render( + + + + + + + , + document.getElementById('root') + ); +}); diff --git a/packages/ui/src/pages/JobPage/JobPage.tsx b/packages/ui/src/pages/JobPage/JobPage.tsx index 658b4b4b..c0e6cb88 100644 --- a/packages/ui/src/pages/JobPage/JobPage.tsx +++ b/packages/ui/src/pages/JobPage/JobPage.tsx @@ -1,6 +1,7 @@ import { AppQueue, JobRetryStatus } from '@bull-board/api/typings/app'; import cn from 'clsx'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Link, useHistory } from 'react-router-dom'; import { ArrowLeftIcon } from '../../components/Icons/ArrowLeft'; import { JobCard } from '../../components/JobCard/JobCard'; @@ -11,6 +12,7 @@ import { links } from '../../utils/links'; import buttonS from '../../components/Button/Button.module.css'; export const JobPage = ({ queue }: { queue: AppQueue | null }) => { + const { t } = useTranslation(); const history = useHistory(); const { job, status, actions } = useJob(); const selectedStatuses = useSelectedStatuses(); @@ -18,11 +20,11 @@ export const JobPage = ({ queue }: { queue: AppQueue | null }) => { actions.pollJob(); if (!queue) { - return
        Queue Not found
        ; + return
        {t('QUEUE.NOT_FOUND')}
        ; } if (!job) { - return
        Job Not found
        ; + return
        {t('JOB.NOT_FOUND')}
        ; } const cleanJob = async () => { @@ -41,7 +43,7 @@ export const JobPage = ({ queue }: { queue: AppQueue | null }) => { > -
        Status: {status.toLocaleUpperCase()}
        +
        {t('JOB.STATUS', { status: status.toLocaleUpperCase() })}
        } /> diff --git a/packages/ui/src/pages/QueuePage/QueuePage.tsx b/packages/ui/src/pages/QueuePage/QueuePage.tsx index 0fce1375..60a2d9f0 100644 --- a/packages/ui/src/pages/QueuePage/QueuePage.tsx +++ b/packages/ui/src/pages/QueuePage/QueuePage.tsx @@ -1,6 +1,7 @@ import { STATUSES } from '@bull-board/api/src/constants/statuses'; import { JobRetryStatus } from '@bull-board/api/typings/app'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { JobCard } from '../../components/JobCard/JobCard'; import { Pagination } from '../../components/Pagination/Pagination'; import { QueueActions } from '../../components/QueueActions/QueueActions'; @@ -13,6 +14,7 @@ import { useSelectedStatuses } from '../../hooks/useSelectedStatuses'; import { links } from '../../utils/links'; export const QueuePage = () => { + const { t } = useTranslation(); const selectedStatus = useSelectedStatuses(); const { actions, queues } = useQueues(); const { actions: jobActions } = useJob(); @@ -20,7 +22,7 @@ export const QueuePage = () => { actions.pollQueues(); if (!queue) { - return
        Queue Not found
        ; + return
        {t('QUEUE.NOT_FOUND')}
        ; } const status = selectedStatus[queue.name]; diff --git a/packages/ui/src/services/i18n.ts b/packages/ui/src/services/i18n.ts new file mode 100644 index 00000000..12de3d2a --- /dev/null +++ b/packages/ui/src/services/i18n.ts @@ -0,0 +1,34 @@ +import i18n from 'i18next'; +import HttpBackend from 'i18next-http-backend'; +import * as process from 'process'; +import { initReactI18next } from 'react-i18next'; + +export let dateFnsLocale = undefined; + +export async function initI18n({ lng, basePath }: { lng: string; basePath: string }) { + const i18nextInstance = i18n + .use(initReactI18next) // passes i18n down to react-i18next + .use(HttpBackend); + + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { HMRPlugin } = require('i18next-hmr/plugin'); + i18nextInstance.use(new HMRPlugin({ webpack: { client: true } })); + (window as any).testI18n = (lng = 'cimode') => i18nextInstance.changeLanguage(lng); + } + const locale = lng === 'en' ? 'en-US' : lng; + dateFnsLocale = await import(`date-fns/locale/${locale}/index.js`).catch((e) => console.error(e)); + return i18nextInstance.init({ + lng, + fallbackLng: 'en', + defaultNS: 'messages', + ns: 'messages', + backend: { + loadPath: `${basePath}static/locales/{{lng}}/{{ns}}.json`, + queryParams: { v: process.env.APP_VERSION }, + }, + interpolation: { + escapeValue: false, // react already safes from xss + }, + }); +} diff --git a/packages/ui/src/static/locales/en/messages.json b/packages/ui/src/static/locales/en/messages.json new file mode 100644 index 00000000..c99ec1f9 --- /dev/null +++ b/packages/ui/src/static/locales/en/messages.json @@ -0,0 +1,115 @@ +{ + "LOADING": "Loading...", + "MENU": { + "QUEUES": "QUEUES", + "SEARCH_INPUT_PLACEHOLDER": "Filter queues", + "PAUSED": "Paused" + }, + "DASHBOARD": { + "JOBS_COUNT_one": "{{count}} Job", + "JOBS_COUNT": "{{count}} Jobs" + }, + "JOB": { + "NOT_FOUND": "Job Not found", + "STATUS": "Status: {{status}}", + "ADDED_AT": "Added at", + "WILL_RUN_AT": "Will run at", + "DELAYED_FOR": "delayed for", + "PROCESS_STARTED_AT": "Process started at", + "FAILED_AT": "Failed at", + "FINISHED_AT": "Finished at", + "ATTEMPTS": "attempt #{{attempts}}", + "REPEAT": "repeat {{count}}", + "REPEAT_WITH_LIMIT": "$t(JOB.REPEAT) / {{limit}}", + "DURATION": { + "SECS": "{{duration}} seconds", + "MILLI_SECS": "{{duration}} milliseconds" + }, + "SHOW_DATA_BTN": "Show data", + "SHOW_OPTIONS_BTN": "Show options", + "SHOW_ERRORS_BTN": "Show errors", + "NA": "NA", + "LOGS": { + "FILTER_PLACEHOLDER": "Filters" + }, + "ACTIONS": { + "PROMOTE": "Promote", + "CLEAN": "Clean", + "RETRY": "Retry" + }, + "TABS": { + "DATA": "Data", + "OPTIONS": "Options", + "LOGS": "Logs", + "ERROR": "Error" + } + }, + "QUEUE": { + "NOT_FOUND": "Queue Not found", + "ACTIONS": { + "MODAL_TITLE": "", + "RETRY_ALL": "Retry all", + "PROMOTE_ALL": "Promote all", + "CLEAN_ALL": "Clean all", + "RESUME": "Resume", + "PAUSE": "Pause", + "EMPTY": "Empty", + "RETRY_ALL_CONFIRM_MSG": "Are you sure that you want to retry all {{status}} jobs?", + "CLEAN_ALL_CONFIRM_MSG": "Are you sure that you want to clean all ${status} jobs?", + "PROMOTE_ALL_CONFIRM_MSG": "Are you sure that you want to promote all delayed jobs?", + "PAUSE_QUEUE_CONFIRM_MSG": "Are you sure that you want to pause queue processing?", + "EMPTY_QUEUE_CONFIRM_MSG": "Are you sure that you want to empty the queue?", + "RESUME_QUEUE_CONFIRM_MSG": "Are you sure that you want to resume queue processing?" + }, + "STATUS": { + "LATEST": "Latest", + "ACTIVE": "Active", + "WAITING": "Waiting", + "WAITING-CHILDREN": "Waiting Children", + "PRIORITIZED": "Prioritized", + "COMPLETED": "Completed", + "FAILED": "Failed", + "DELAYED": "Delayed", + "PAUSED": "Paused" + } + }, + "CONFIRM": { + "DEFAULT_TITLE": "Are you sure?", + "CONFIRM_BTN": "Confirm", + "CANCEL_BTN": "Cancel" + }, + "MODAL": { + "CLOSE_BTN": "Close" + }, + "REDIS": { + "TITLE": "Redis details", + "MEMORY_USAGE": "Memory usage", + "PEEK_MEMORY": "Peak memory usage", + "FRAGMENTATION_RATIO": "Fragmentation ratio", + "CONNECTED_CLIENTS": "Connected clients", + "BLOCKED_CLIENTS": "Blocked clients", + "VERSION": "Version", + "MODE": "Mode", + "OS": "OS", + "UP_TIME": "Up time", + "ERROR": { + "MEMORY_USAGE": "Could not retrieve memory stats" + } + }, + "SETTINGS": { + "TITLE": "Settings", + "POLLING_INTERVAL": "Polling interval", + "POLLING_OPTIONS": { + "OFF": "Off", + "SECS": "{{count}} seconds", + "MINS": "{{count}} minutes", + "MINS_one": "{{count}} minute" + }, + "JOBS_PER_PAGE": "Jobs per page (1-50)", + "CONFIRM_QUEUE_ACTIONS": "Confirm queue actions", + "CONFIRM_JOB_ACTIONS": "Confirm job actions", + "COLLAPSE_JOB_DATA": "Collapse job data", + "COLLAPSE_JOB_OPTIONS": "Collapse job options", + "COLLAPSE_JOB_ERROR": "Collapse job error" + } +} diff --git a/packages/ui/webpack.config.js b/packages/ui/webpack.config.js index 3eb0b667..785a57a5 100644 --- a/packages/ui/webpack.config.js +++ b/packages/ui/webpack.config.js @@ -7,6 +7,7 @@ const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); +const { I18NextHMRPlugin } = require('i18next-hmr/webpack'); const isProd = process.env.NODE_ENV === 'production'; const devServerPort = 9000; @@ -110,6 +111,7 @@ module.exports = { }), new ForkTsCheckerWebpackPlugin(), !isProd && new ReactRefreshWebpackPlugin(), + !isProd && new I18NextHMRPlugin({ localesDir: path.join(__dirname, 'src/static/locales') }), ].filter(Boolean), devServer: { proxy: { diff --git a/yarn.lock b/yarn.lock index f6ded59b..a70a305c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1120,6 +1120,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2": + version "7.23.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.4.tgz#36fa1d2b36db873d25ec631dcc4923fdc1cf2e2e" + integrity sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.15", "@babel/template@^7.22.5", "@babel/template@^7.3.3": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" @@ -6044,6 +6051,13 @@ cron-parser@^4.2.1, cron-parser@^4.6.0: dependencies: luxon "^3.2.1" +cross-fetch@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" + integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -8151,6 +8165,13 @@ html-minifier-terser@^6.0.2: relateurl "^0.2.7" terser "^5.10.0" +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + html-webpack-plugin@^5.5.0: version "5.5.3" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.5.3.tgz#72270f4a78e222b5825b296e5e3e1328ad525a3e" @@ -8314,6 +8335,25 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" +i18next-hmr@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/i18next-hmr/-/i18next-hmr-3.0.3.tgz#78e103a65e2dcf8293c16067e0700a61ab4b90cf" + integrity sha512-s2uRbAXRMbBmxRz39Kg6ezjahnfdXYzO2O3NE3t7GjDUVdt1PH8nZaZarRzmEv+T5JW/ajZFpM9EXCQjhweIRA== + +i18next-http-backend@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.4.2.tgz#bd53cacaed671e9f38bdcfd46ac9d1763a898186" + integrity sha512-wKrgGcaFQ4EPjfzBTjzMU0rbFTYpa0S5gv9N/d8WBmWS64+IgJb7cHddMvV+tUkse7vUfco3eVs2lB+nJhPo3w== + dependencies: + cross-fetch "4.0.0" + +i18next@^23.7.7: + version "23.7.7" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.7.7.tgz#e650ee962417186c5ba78bdaea3979abd31d3bfc" + integrity sha512-peTvdT+Lma+o0LfLFD7IC2M37N9DJ04dH0IJYOyOHRhDfLo6nK36v7LkrQH35C2l8NHiiXZqGirhKESlEb/5PA== + dependencies: + "@babel/runtime" "^7.23.2" + iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -10560,6 +10600,13 @@ node-fetch@^2.6.1, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-forge@^1: version "1.3.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" @@ -12278,6 +12325,14 @@ react-dom@^17.0.0: object-assign "^4.1.1" scheduler "^0.20.2" +react-i18next@^13.5.0: + version "13.5.0" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-13.5.0.tgz#44198f747628267a115c565f0c736a50a76b1ab0" + integrity sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA== + dependencies: + "@babel/runtime" "^7.22.5" + html-parse-stringify "^3.0.1" + react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -14433,6 +14488,11 @@ vm2@^3.9.19: acorn "^8.7.0" acorn-walk "^8.2.0" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + walk-sync@^2.0.2: version "2.2.0" resolved "https://registry.yarnpkg.com/walk-sync/-/walk-sync-2.2.0.tgz#80786b0657fcc8c0e1c0b1a042a09eae2966387a"