diff --git a/src/apps/dashboard/features/scheduledtasks/api/useStartTask.ts b/src/apps/dashboard/features/scheduledtasks/api/useStartTask.ts new file mode 100644 index 00000000000..6775ae3cf91 --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/api/useStartTask.ts @@ -0,0 +1,23 @@ +import { ScheduledTasksApiStartTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api'; +import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; +import { QUERY_KEY } from './useTasks'; + +export const useStartTask = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: ScheduledTasksApiStartTaskRequest) => ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + getScheduledTasksApi(api!) + .startTask(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/scheduledtasks/api/useStopTask.ts b/src/apps/dashboard/features/scheduledtasks/api/useStopTask.ts new file mode 100644 index 00000000000..9edc866245a --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/api/useStopTask.ts @@ -0,0 +1,23 @@ +import { ScheduledTasksApiStartTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api'; +import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; +import { QUERY_KEY } from './useTasks'; + +export const useStopTask = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: ScheduledTasksApiStartTaskRequest) => ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + getScheduledTasksApi(api!) + .stopTask(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/scheduledtasks/api/useTasks.ts b/src/apps/dashboard/features/scheduledtasks/api/useTasks.ts new file mode 100644 index 00000000000..9ac9851e9c9 --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/api/useTasks.ts @@ -0,0 +1,35 @@ +import type { ScheduledTasksApiGetTasksRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api'; +import type { AxiosRequestConfig } from 'axios'; +import type { Api } from '@jellyfin/sdk'; +import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api'; +import { useQuery } from '@tanstack/react-query'; + +import { useApi } from 'hooks/useApi'; + +export const QUERY_KEY = 'Tasks'; + +const fetchTasks = async ( + api?: Api, + params?: ScheduledTasksApiGetTasksRequest, + options?: AxiosRequestConfig +) => { + if (!api) { + console.warn('[fetchTasks] No API instance available'); + return; + } + + const response = await getScheduledTasksApi(api).getTasks(params, options); + + return response.data; +}; + +export const useTasks = (params?: ScheduledTasksApiGetTasksRequest) => { + const { api } = useApi(); + + return useQuery({ + queryKey: [QUERY_KEY], + queryFn: ({ signal }) => + fetchTasks(api, params, { signal }), + enabled: !!api + }); +}; diff --git a/src/apps/dashboard/features/scheduledtasks/components/Task.tsx b/src/apps/dashboard/features/scheduledtasks/components/Task.tsx new file mode 100644 index 00000000000..0c5fda280b3 --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/components/Task.tsx @@ -0,0 +1,67 @@ +import React, { FunctionComponent, useCallback } from 'react'; +import ListItem from '@mui/material/ListItem'; +import Avatar from '@mui/material/Avatar'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import Dashboard from 'utils/dashboard'; +import { TaskProps } from '../types/taskProps'; +import TaskProgress from './TaskProgress'; +import TaskLastRan from './TaskLastRan'; +import IconButton from '@mui/material/IconButton'; +import PlayArrow from '@mui/icons-material/PlayArrow'; +import Stop from '@mui/icons-material/Stop'; +import { useStartTask } from '../api/useStartTask'; +import { useStopTask } from '../api/useStopTask'; + +const Task: FunctionComponent = ({ task }: TaskProps) => { + const startTask = useStartTask(); + const stopTask = useStopTask(); + + const navigateTaskEdit = useCallback(() => { + Dashboard.navigate(`/dashboard/tasks/edit?id=${task.Id}`) + .catch(err => { + console.error('[Task] failed to navigate to task edit page', err); + }); + }, [task]); + + const handleStartTask = useCallback(() => { + if (task.Id) { + startTask.mutate({ taskId: task.Id }); + } + }, [task, startTask]); + + const handleStopTask = useCallback(() => { + if (task.Id) { + stopTask.mutate({ taskId: task.Id }); + } + }, [task, stopTask]); + + return ( + + {task.State == 'Running' ? : } + + } + > + + + + + + + {task.Name}} + secondary={task.State == 'Running' ? : } + disableTypography + /> + + + ); +}; + +export default Task; diff --git a/src/apps/dashboard/features/scheduledtasks/components/TaskLastRan.tsx b/src/apps/dashboard/features/scheduledtasks/components/TaskLastRan.tsx new file mode 100644 index 00000000000..b968779d102 --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/components/TaskLastRan.tsx @@ -0,0 +1,38 @@ +import React, { FunctionComponent } from 'react'; +import { TaskProps } from '../types/taskProps'; +import { useLocale } from 'hooks/useLocale'; +import { formatDistance, formatDistanceToNow, parseISO } from 'date-fns'; +import Typography from '@mui/material/Typography'; +import globalize from 'lib/globalize'; + +const TaskLastRan: FunctionComponent = ({ task }: TaskProps) => { + const { dateFnsLocale } = useLocale(); + + if (task.State == 'Idle') { + if (task.LastExecutionResult?.StartTimeUtc && task.LastExecutionResult?.EndTimeUtc) { + const endTime = parseISO(task.LastExecutionResult.EndTimeUtc); + const startTime = parseISO(task.LastExecutionResult.StartTimeUtc); + + const lastRan = formatDistanceToNow(endTime, { locale: dateFnsLocale, addSuffix: true }); + const timeTaken = formatDistance(startTime, endTime, { locale: dateFnsLocale }); + + const lastResultStatus = task.LastExecutionResult.Status; + + return ( + + {globalize.translate('LabelScheduledTaskLastRan', lastRan, timeTaken)} + + {lastResultStatus == 'Failed' && {` (${globalize.translate('LabelFailed')})`}} + {lastResultStatus == 'Cancelled' && {` (${globalize.translate('LabelCancelled')})`}} + {lastResultStatus == 'Aborted' && {` (${globalize.translate('LabelAbortedByServerShutdown')})`}} + + ); + } + } else { + return ( + {globalize.translate('LabelStopping')} + ); + } +}; + +export default TaskLastRan; diff --git a/src/apps/dashboard/features/scheduledtasks/components/TaskProgress.tsx b/src/apps/dashboard/features/scheduledtasks/components/TaskProgress.tsx new file mode 100644 index 00000000000..6482dd57fe2 --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/components/TaskProgress.tsx @@ -0,0 +1,32 @@ +import React, { FunctionComponent } from 'react'; +import { TaskProps } from '../types/taskProps'; +import Box from '@mui/material/Box'; +import LinearProgress from '@mui/material/LinearProgress'; +import Typography from '@mui/material/Typography'; + +const TaskProgress: FunctionComponent = ({ task }: TaskProps) => { + const progress = task.CurrentProgressPercentage; + + return ( + + {progress != null ? ( + <> + + + + + {`${Math.round(progress)}%`} + + + ) : ( + + + + )} + + ); +}; + +export default TaskProgress; diff --git a/src/apps/dashboard/features/scheduledtasks/components/Tasks.tsx b/src/apps/dashboard/features/scheduledtasks/components/Tasks.tsx new file mode 100644 index 00000000000..6635ce0b3af --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/components/Tasks.tsx @@ -0,0 +1,29 @@ +import React, { FunctionComponent } from 'react'; +import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info'; +import List from '@mui/material/List'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import Task from './Task'; + +type TasksProps = { + category: string; + tasks: TaskInfo[]; +}; + +const Tasks: FunctionComponent = ({ category, tasks }: TasksProps) => { + return ( + + {category} + + {tasks.map(task => { + return ; + })} + + + ); +}; + +export default Tasks; diff --git a/src/apps/dashboard/features/scheduledtasks/types/taskProps.ts b/src/apps/dashboard/features/scheduledtasks/types/taskProps.ts new file mode 100644 index 00000000000..31683422ea2 --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/types/taskProps.ts @@ -0,0 +1,5 @@ +import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info'; + +export type TaskProps = { + task: TaskInfo; +}; diff --git a/src/apps/dashboard/features/scheduledtasks/utils/tasks.ts b/src/apps/dashboard/features/scheduledtasks/utils/tasks.ts new file mode 100644 index 00000000000..b0bc1aa5b12 --- /dev/null +++ b/src/apps/dashboard/features/scheduledtasks/utils/tasks.ts @@ -0,0 +1,27 @@ +import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info'; + +export function getCategories(tasks: TaskInfo[] | undefined) { + if (!tasks) return []; + + const categories: string[] = []; + + for (const task of tasks) { + if (task.Category && !categories.includes(task.Category)) { + categories.push(task.Category); + } + } + + return categories.sort((a, b) => a.localeCompare(b)); +} + +export function getTasksByCategory(tasks: TaskInfo[] | undefined, category: string) { + if (!tasks) return []; + + return tasks.filter(task => task.Category == category).sort((a, b) => { + if (a.Name && b.Name) { + return a.Name?.localeCompare(b.Name); + } else { + return 0; + } + }); +} diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index eb42010cf3a..a06e211a466 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -8,6 +8,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'logs', type: AppType.Dashboard }, { path: 'playback/trickplay', type: AppType.Dashboard }, { path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard }, + { path: 'tasks', page: 'scheduledtasks', type: AppType.Dashboard }, { path: 'users', type: AppType.Dashboard }, { path: 'users/access', type: AppType.Dashboard }, { path: 'users/add', type: AppType.Dashboard }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index 56e19ccc110..0c822d92cc5 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -135,13 +135,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'dashboard/scheduledtasks/scheduledtask', view: 'dashboard/scheduledtasks/scheduledtask.html' } - }, { - path: 'tasks', - pageProps: { - appType: AppType.Dashboard, - controller: 'dashboard/scheduledtasks/scheduledtasks', - view: 'dashboard/scheduledtasks/scheduledtasks.html' - } }, { path: 'playback/streaming', pageProps: { diff --git a/src/apps/dashboard/routes/scheduledtasks/index.tsx b/src/apps/dashboard/routes/scheduledtasks/index.tsx new file mode 100644 index 00000000000..65f794690e8 --- /dev/null +++ b/src/apps/dashboard/routes/scheduledtasks/index.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import Page from 'components/Page'; +import globalize from 'lib/globalize'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import { QUERY_KEY, useTasks } from '../../features/scheduledtasks/api/useTasks'; +import { getCategories, getTasksByCategory } from '../../features/scheduledtasks/utils/tasks'; +import Loading from 'components/loading/LoadingComponent'; +import Tasks from '../../features/scheduledtasks/components/Tasks'; +import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info'; +import serverNotifications from 'scripts/serverNotifications'; +import Events, { Event } from 'utils/events'; +import { ApiClient } from 'jellyfin-apiclient'; +import { useApi } from 'hooks/useApi'; +import { queryClient } from '../../../../utils/query/queryClient'; + +const ScheduledTasks = () => { + const { __legacyApiClient__ } = useApi(); + const { data: tasks, isLoading } = useTasks({ isHidden: false }); + + // TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK. + useEffect(() => { + const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => { + queryClient.setQueryData([QUERY_KEY], info); + }; + + const fallbackInterval = setInterval(() => { + if (!__legacyApiClient__?.isMessageChannelOpen()) { + void queryClient.invalidateQueries({ + queryKey: [QUERY_KEY] + }); + } + }, 1e4); + + __legacyApiClient__?.sendMessage('ScheduledTasksInfoStart', '1000,1000'); + Events.on(serverNotifications, 'ScheduledTasksInfo', onScheduledTasksUpdate); + + return () => { + clearInterval(fallbackInterval); + __legacyApiClient__?.sendMessage('ScheduledTasksInfoStop', null); + Events.off(serverNotifications, 'ScheduledTasksInfo', onScheduledTasksUpdate); + }; + }, [__legacyApiClient__]); + + if (isLoading || !tasks) { + return ; + } + + const categories = getCategories(tasks); + + return ( + + + + + {categories.map(category => { + return ; + })} + + + + + ); +}; + +export default ScheduledTasks; diff --git a/src/controllers/dashboard/scheduledtasks/scheduledtasks.html b/src/controllers/dashboard/scheduledtasks/scheduledtasks.html deleted file mode 100644 index 299bed531f1..00000000000 --- a/src/controllers/dashboard/scheduledtasks/scheduledtasks.html +++ /dev/null @@ -1,20 +0,0 @@ -
- -
-
-
-
-
-
diff --git a/src/controllers/dashboard/scheduledtasks/scheduledtasks.js b/src/controllers/dashboard/scheduledtasks/scheduledtasks.js deleted file mode 100644 index 3b96d8c3a1a..00000000000 --- a/src/controllers/dashboard/scheduledtasks/scheduledtasks.js +++ /dev/null @@ -1,196 +0,0 @@ -import 'jquery'; -import loading from '../../../components/loading/loading'; -import globalize from '../../../lib/globalize'; -import serverNotifications from '../../../scripts/serverNotifications'; -import { formatDistance, formatDistanceToNow } from 'date-fns'; -import { getLocale, getLocaleWithSuffix } from '../../../utils/dateFnsLocale.ts'; -import Events from '../../../utils/events.ts'; - -import '../../../components/listview/listview.scss'; -import '../../../elements/emby-button/emby-button'; -import dom from 'scripts/dom'; - -function reloadList(page) { - ApiClient.getScheduledTasks({ - isHidden: false - }).then(function(tasks) { - populateList(page, tasks); - loading.hide(); - }); -} - -function populateList(page, tasks) { - tasks = tasks.sort(function(a, b) { - a = a.Category + ' ' + a.Name; - b = b.Category + ' ' + b.Name; - if (a > b) { - return 1; - } else if (a < b) { - return -1; - } else { - return 0; - } - }); - - let currentCategory; - let html = ''; - for (const task of tasks) { - if (task.Category != currentCategory) { - currentCategory = task.Category; - if (currentCategory) { - html += ''; - html += ''; - } - html += '
'; - html += '
'; - html += '

'; - html += currentCategory; - html += '

'; - html += '
'; - html += '
'; - } - html += '
'; - html += ""; - html += ''; - html += ''; - html += '
'; - const textAlignStyle = globalize.getIsRTL() ? 'right' : 'left'; - html += ""; - html += "

" + task.Name + '

'; - html += "
" + getTaskProgressHtml(task) + '
'; - html += '
'; - html += '
'; - if (task.State === 'Running') { - html += ''; - } else if (task.State === 'Idle') { - html += ''; - } - html += '
'; - } - if (tasks.length) { - html += '
'; - html += '
'; - } - page.querySelector('.divScheduledTasks').innerHTML = html; -} - -function getTaskProgressHtml(task) { - let html = ''; - if (task.State === 'Idle') { - if (task.LastExecutionResult) { - const endtime = Date.parse(task.LastExecutionResult.EndTimeUtc); - const starttime = Date.parse(task.LastExecutionResult.StartTimeUtc); - html += globalize.translate('LabelScheduledTaskLastRan', formatDistanceToNow(endtime, getLocaleWithSuffix()), - formatDistance(starttime, endtime, { locale: getLocale() })); - if (task.LastExecutionResult.Status === 'Failed') { - html += " (" + globalize.translate('LabelFailed') + ')'; - } else if (task.LastExecutionResult.Status === 'Cancelled') { - html += " (" + globalize.translate('LabelCancelled') + ')'; - } else if (task.LastExecutionResult.Status === 'Aborted') { - html += " " + globalize.translate('LabelAbortedByServerShutdown') + ''; - } - } - } else if (task.State === 'Running') { - const progress = (task.CurrentProgressPercentage || 0).toFixed(1); - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += "" + progress + '%'; - html += '
'; - } else { - html += "" + globalize.translate('LabelStopping') + ''; - } - return html; -} - -function setTaskButtonIcon(button, icon) { - const inner = button.querySelector('.material-icons'); - inner.classList.remove('stop', 'play_arrow'); - inner.classList.add(icon); -} - -function updateTaskButton(elem, state) { - if (state === 'Running') { - elem.classList.remove('btnStartTask'); - elem.classList.add('btnStopTask'); - setTaskButtonIcon(elem, 'stop'); - elem.title = globalize.translate('ButtonStop'); - } else if (state === 'Idle') { - elem.classList.add('btnStartTask'); - elem.classList.remove('btnStopTask'); - setTaskButtonIcon(elem, 'play_arrow'); - elem.title = globalize.translate('ButtonStart'); - } - dom.parentWithClass(elem, 'listItem').setAttribute('data-status', state); -} - -export default function(view) { - function updateTasks(tasks) { - for (const task of tasks) { - const taskProgress = view.querySelector(`#taskProgress${task.Id}`); - if (taskProgress) taskProgress.innerHTML = getTaskProgressHtml(task); - - const taskButton = view.querySelector(`#btnTask${task.Id}`); - if (taskButton) updateTaskButton(taskButton, task.State); - } - } - - function onPollIntervalFired() { - if (!ApiClient.isMessageChannelOpen()) { - reloadList(view); - } - } - - function onScheduledTasksUpdate(e, apiClient, info) { - if (apiClient.serverId() === serverId) { - updateTasks(info); - } - } - - function startInterval() { - ApiClient.sendMessage('ScheduledTasksInfoStart', '1000,1000'); - pollInterval && clearInterval(pollInterval); - pollInterval = setInterval(onPollIntervalFired, 1e4); - } - - function stopInterval() { - ApiClient.sendMessage('ScheduledTasksInfoStop'); - pollInterval && clearInterval(pollInterval); - } - - let pollInterval; - const serverId = ApiClient.serverId(); - - $('.divScheduledTasks', view).on('click', '.btnStartTask', function() { - const button = this; - const id = button.getAttribute('data-taskid'); - ApiClient.startScheduledTask(id).then(function() { - updateTaskButton(button, 'Running'); - reloadList(view); - }); - }); - - $('.divScheduledTasks', view).on('click', '.btnStopTask', function() { - const button = this; - const id = button.getAttribute('data-taskid'); - ApiClient.stopScheduledTask(id).then(function() { - updateTaskButton(button, ''); - reloadList(view); - }); - }); - - view.addEventListener('viewbeforehide', function() { - Events.off(serverNotifications, 'ScheduledTasksInfo', onScheduledTasksUpdate); - stopInterval(); - }); - - view.addEventListener('viewshow', function() { - loading.show(); - startInterval(); - reloadList(view); - Events.on(serverNotifications, 'ScheduledTasksInfo', onScheduledTasksUpdate); - }); -} -