Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate scheduled tasks to React #6506

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/apps/dashboard/features/scheduledtasks/api/useStartTask.ts
Original file line number Diff line number Diff line change
@@ -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 ]
});
}
});
};
23 changes: 23 additions & 0 deletions src/apps/dashboard/features/scheduledtasks/api/useStopTask.ts
Original file line number Diff line number Diff line change
@@ -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 ]
});
}
});
};
35 changes: 35 additions & 0 deletions src/apps/dashboard/features/scheduledtasks/api/useTasks.ts
Original file line number Diff line number Diff line change
@@ -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
});
};
67 changes: 67 additions & 0 deletions src/apps/dashboard/features/scheduledtasks/components/Task.tsx
Original file line number Diff line number Diff line change
@@ -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<TaskProps> = ({ 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 (
<ListItem
disablePadding
secondaryAction={
<IconButton onClick={task.State == 'Running' ? handleStopTask : handleStartTask}>
{task.State == 'Running' ? <Stop /> : <PlayArrow />}
</IconButton>
}
>
<ListItemButton onClick={navigateTaskEdit}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<AccessTimeIcon sx={{ color: '#fff' }} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={<Typography variant='h3'>{task.Name}</Typography>}
secondary={task.State == 'Running' ? <TaskProgress task={task} /> : <TaskLastRan task={task} />}
disableTypography
/>
</ListItemButton>
</ListItem>
);
};

export default Task;
Original file line number Diff line number Diff line change
@@ -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<TaskProps> = ({ 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 (
<Typography sx={{ lineHeight: '1.2rem', color: 'text.secondary' }} variant='body1'>
{globalize.translate('LabelScheduledTaskLastRan', lastRan, timeTaken)}

{lastResultStatus == 'Failed' && <Typography display='inline' color='error'>{` (${globalize.translate('LabelFailed')})`}</Typography>}
{lastResultStatus == 'Cancelled' && <Typography display='inline' color='blue'>{` (${globalize.translate('LabelCancelled')})`}</Typography>}
{lastResultStatus == 'Aborted' && <Typography display='inline' color='error'>{` (${globalize.translate('LabelAbortedByServerShutdown')})`}</Typography>}
</Typography>
);
}
} else {
return (
<Typography sx={{ color: 'text.secondary' }}>{globalize.translate('LabelStopping')}</Typography>
);
}
};

export default TaskLastRan;
Original file line number Diff line number Diff line change
@@ -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<TaskProps> = ({ task }: TaskProps) => {
const progress = task.CurrentProgressPercentage;

return (
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
{progress != null ? (
<>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress variant='determinate' value={progress} />
</Box>
<Box>
<Typography
variant='body1'
>{`${Math.round(progress)}%`}</Typography>
</Box>
</>
) : (
<Box sx={{ width: '100%' }}>
<LinearProgress />
</Box>
)}
</Box>
);
};

export default TaskProgress;
29 changes: 29 additions & 0 deletions src/apps/dashboard/features/scheduledtasks/components/Tasks.tsx
Original file line number Diff line number Diff line change
@@ -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<TasksProps> = ({ category, tasks }: TasksProps) => {
return (
<Stack spacing={2}>
<Typography variant='h2'>{category}</Typography>
<List sx={{ bgcolor: 'background.paper' }}>
{tasks.map(task => {
return <Task
key={task.Id}
task={task}
/>;
})}
</List>
</Stack>
);
};

export default Tasks;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';

export type TaskProps = {
task: TaskInfo;
};
27 changes: 27 additions & 0 deletions src/apps/dashboard/features/scheduledtasks/utils/tasks.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
}
1 change: 1 addition & 0 deletions src/apps/dashboard/routes/_asyncRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
7 changes: 0 additions & 7 deletions src/apps/dashboard/routes/_legacyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
74 changes: 74 additions & 0 deletions src/apps/dashboard/routes/scheduledtasks/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <Loading />;
}

const categories = getCategories(tasks);

return (
<Page
id='scheduledTasksPage'
title={globalize.translate('TabScheduledTasks')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Box className='readOnlyContent'>
<Stack spacing={3} mt={2}>
{categories.map(category => {
return <Tasks
key={category}
category={category}
tasks={getTasksByCategory(tasks, category)}
/>;
})}
</Stack>
</Box>
</Box>
</Page>
);
};

export default ScheduledTasks;
Loading
Loading