diff --git a/.changeset/short-lizards-find.md b/.changeset/short-lizards-find.md new file mode 100644 index 0000000000..f5659b13c8 --- /dev/null +++ b/.changeset/short-lizards-find.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-defaults': minor +'@backstage/plugin-devtools-backend': minor +'@backstage/plugin-devtools-common': minor +'@backstage/plugin-devtools': minor +--- + +Added scheduled tasks UI feature for the DevTools plugin diff --git a/app-config.yaml b/app-config.yaml index c1a894dcc7..08c97c1172 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -311,3 +311,8 @@ auth: permission: enabled: true + +devTools: + scheduledTasks: + plugins: + - catalog diff --git a/packages/app/src/components/devtools/CustomDevToolsPage.tsx b/packages/app/src/components/devtools/CustomDevToolsPage.tsx index d1c68d9884..0fe17b14c3 100644 --- a/packages/app/src/components/devtools/CustomDevToolsPage.tsx +++ b/packages/app/src/components/devtools/CustomDevToolsPage.tsx @@ -18,6 +18,7 @@ import { ConfigContent, ExternalDependenciesContent, InfoContent, + ScheduledTasksContent, } from '@backstage/plugin-devtools'; import { DevToolsLayout } from '@backstage/plugin-devtools'; import { UnprocessedEntitiesContent } from '@backstage/plugin-catalog-unprocessed-entities'; @@ -31,6 +32,9 @@ const DevToolsPage = () => { + + + JSX_2.Element; @@ -43,6 +44,16 @@ export const ExternalDependenciesContent: () => JSX_2.Element; // @public (undocumented) export const InfoContent: () => JSX_2.Element; +// @public (undocumented) +export const ScheduledTaskDetailPanel: ({ + rowData, +}: { + rowData: TaskApiTasksResponse; +}) => JSX_2.Element; + +// @public (undocumented) +export const ScheduledTasksContent: () => JSX_2.Element; + // @public (undocumented) export type SubRoute = { path: string; diff --git a/plugins/devtools/src/api/DevToolsApi.ts b/plugins/devtools/src/api/DevToolsApi.ts index ff58a4ea16..4f58f013d4 100644 --- a/plugins/devtools/src/api/DevToolsApi.ts +++ b/plugins/devtools/src/api/DevToolsApi.ts @@ -19,6 +19,8 @@ import { ConfigInfo, DevToolsInfo, ExternalDependency, + ScheduledTasks, + TriggerScheduledTask, } from '@backstage/plugin-devtools-common'; export const devToolsApiRef = createApiRef({ @@ -29,4 +31,9 @@ export interface DevToolsApi { getConfig(): Promise; getExternalDependencies(): Promise; getInfo(): Promise; + getScheduledTasksByPlugin(plugin: string): Promise; + triggerScheduledTask( + plugin: string, + taskId: string, + ): Promise; } diff --git a/plugins/devtools/src/api/DevToolsClient.ts b/plugins/devtools/src/api/DevToolsClient.ts index e0b387a6ca..5e13202099 100644 --- a/plugins/devtools/src/api/DevToolsClient.ts +++ b/plugins/devtools/src/api/DevToolsClient.ts @@ -19,6 +19,8 @@ import { ConfigInfo, DevToolsInfo, ExternalDependency, + ScheduledTasks, + TriggerScheduledTask, } from '@backstage/plugin-devtools-common'; import { ResponseError } from '@backstage/errors'; import { DevToolsApi } from './DevToolsApi'; @@ -42,6 +44,45 @@ export class DevToolsClient implements DevToolsApi { return configInfo; } + public async getScheduledTasksByPlugin( + plugin: string, + ): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl(plugin)}/`; + const url = new URL('.backstage/scheduler/v1/tasks', baseUrl); + + const response = await this.fetchApi.fetch(url.toString()); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + const scheduledTasks = await response.json(); + return { + scheduledTasks: scheduledTasks.tasks, + }; + } + + public async triggerScheduledTask( + plugin: string, + taskId: string, + ): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl(plugin)}/`; + const url = new URL( + `.backstage/scheduler/v1/tasks/${taskId}/trigger`, + baseUrl, + ); + + const response = await this.fetchApi.fetch(url.toString(), { + method: 'POST', + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json() as Promise; + } + public async getExternalDependencies(): Promise< ExternalDependency[] | undefined > { diff --git a/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTaskDetailedPanel.tsx b/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTaskDetailedPanel.tsx new file mode 100644 index 0000000000..b26abca221 --- /dev/null +++ b/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTaskDetailedPanel.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TaskApiTasksResponse } from '@backstage/plugin-devtools-common'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import Box from '@material-ui/core/Box'; +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import Alert from '@material-ui/lab/Alert'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + detailPanel: { + padding: theme.spacing(2), + backgroundColor: theme.palette.background.default, + }, + detailLabel: { + fontWeight: 'bold', + marginRight: theme.spacing(1), + }, + errorIcon: { + color: theme.palette.error.main, + marginRight: theme.spacing(1), + fontSize: '1.2rem', + }, + detailPanelAlert: { + marginBottom: theme.spacing(2), + }, + }), +); + +/** @public */ +export const ScheduledTaskDetailPanel = ({ + rowData, +}: { + rowData: TaskApiTasksResponse; +}) => { + const classes = useStyles(); + const lastRunError = rowData.taskState?.lastRunError; + + const DetailItem = ({ title, value }: { title: string; value: any }) => ( + <> + + + {title}: + + + + + {value || 'N/A'} + + + + ); + + return ( + + {lastRunError && ( + + Last Run Error: {lastRunError} + + )} + + + + + + + + + + ); +}; diff --git a/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx b/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx new file mode 100644 index 0000000000..29822b90f9 --- /dev/null +++ b/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx @@ -0,0 +1,285 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import Box from '@material-ui/core/Box'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; +import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; +import { Progress, Table, TableColumn } from '@backstage/core-components'; +import Alert from '@material-ui/lab/Alert'; +import { useScheduledTasks, useTriggerScheduledTask } from '../../../hooks'; +import { TaskApiTasksResponse } from '@backstage/plugin-devtools-common'; +import { alertApiRef, configApiRef, useApi } from '@backstage/core-plugin-api'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import NightsStay from '@material-ui/icons/NightsStay'; +import Error from '@material-ui/icons/Error'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import { ScheduledTaskDetailPanel } from './ScheduledTaskDetailedPanel'; +import { RequirePermission } from '@backstage/plugin-permission-react'; +import { devToolsTaskSchedulerCreatePermission } from '@backstage/plugin-devtools-common'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + paperStyle: { + display: 'flex', + marginBottom: theme.spacing(2), + }, + flexContainer: { + display: 'flex', + flexDirection: 'row', + padding: 0, + }, + formControl: { + minWidth: 240, + marginBottom: theme.spacing(2), + }, + detailPanel: { + padding: theme.spacing(2), + backgroundColor: theme.palette.background.default, + }, + detailLabel: { + fontWeight: 'bold', + marginRight: theme.spacing(1), + }, + errorIcon: { + color: theme.palette.error.main, + marginRight: theme.spacing(1), + fontSize: '1.2rem', + }, + detailPanelAlert: { + marginBottom: theme.spacing(2), + }, + }), +); + +const StatusDisplay = ({ + icon, + text, +}: { + icon: React.ReactNode; + text: string; +}) => ( + + {icon} + + {text} + + +); + +/** @public */ +export const ScheduledTasksContent = () => { + const classes = useStyles(); + const configApi = useApi(configApiRef); + const alertApi = useApi(alertApiRef); + const plugins = + configApi.getOptionalStringArray('devTools.scheduledTasks.plugins') || []; + const [selectedPlugin, setSelectedPlugin] = useState(plugins[0] || ''); + const { scheduledTasks, loading, error } = useScheduledTasks(selectedPlugin); + const { triggerTask, isTriggering, triggerError } = useTriggerScheduledTask(); + + const [inputValue, setInputValue] = useState(''); + + const handleAutocompleteChange = (_event: any, newValue: string | null) => { + setSelectedPlugin(newValue || ''); + }; + + const handleCommitChange = () => { + if (inputValue !== selectedPlugin) { + setSelectedPlugin(inputValue); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleCommitChange(); + // Prevent Autocomplete's default behavior (which might select a filtered item) + event.preventDefault(); + event.stopPropagation(); + } + }; + + if (!plugins || plugins.length === 0) { + return ( + + No plugins configured for scheduled tasks. Please configure + `devTools.scheduledTasks.plugins` in app-config.yaml. + + ); + } + + const columns: TableColumn[] = [ + { + title: 'Task ID', + field: 'taskId', + width: '35%', + render: (rowData: TaskApiTasksResponse) => { + const errorIconStyle: React.CSSProperties = { + color: '#f44336', + marginRight: '8px', + fontSize: '1.2rem', + verticalAlign: 'middle', + }; + + return ( + + {rowData.taskState?.lastRunError && ( + + )} + {rowData.taskId} + + ); + }, + }, + { + title: 'Status', + field: 'taskState.status', + width: '15%', + render: (rowData: TaskApiTasksResponse) => { + const status = rowData.taskState?.status; + + if (status === 'idle') { + return ( + } text="Idle" /> + ); + } + + if (status === 'running') { + return ( + } + text="Running" + /> + ); + } + + return {status || 'N/A'}; + }, + }, + { + title: 'Last Run', + field: 'taskState.lastRunEndedAt', + width: '25%', + render: (rowData: TaskApiTasksResponse) => + rowData.taskState?.lastRunEndedAt + ? new Date(rowData.taskState.lastRunEndedAt).toLocaleString() + : 'N/A', + }, + { + title: 'Next Run', + width: '15%', + render: (rowData: TaskApiTasksResponse) => + rowData.taskState?.status === 'idle' && rowData.taskState.startsAt + ? new Date(rowData.taskState.startsAt).toLocaleString() + : 'N/A', + }, + { + title: 'Actions', + render: (rowData: TaskApiTasksResponse) => ( + + + { + triggerTask(selectedPlugin, rowData.taskId); + if (isTriggering) { + ; + } + if (triggerError) { + alertApi.post({ + message: `Error triggering task ${rowData.taskId}: ${error}`, + severity: 'error', + }); + } else { + alertApi.post({ + message: `Successfully triggered task ${rowData.taskId}`, + severity: 'success', + }); + } + }} + > + + + + + ), + sorting: false, + width: '10%', + }, + ]; + + return ( + + { + setInputValue(newInputValue); + }} + renderInput={params => ( + + )} + /> + + {loading && } + + {error && ( + + The plugin ID "{selectedPlugin}" doesn't have any scheduled tasks or + may contain a typo. Please verify the plugin ID is correct and that + the plugin has registered scheduled tasks. + + )} + + {!loading && !error && ( + + No scheduled tasks found for {selectedPlugin}. + + } + detailPanel={({ rowData }) => { + return ; + }} + /> + )} + + ); +}; diff --git a/plugins/devtools/src/components/Content/ScheduledTasksContent/fixtures/scheduledTasksErrors.json b/plugins/devtools/src/components/Content/ScheduledTasksContent/fixtures/scheduledTasksErrors.json new file mode 100644 index 0000000000..89f81b740d --- /dev/null +++ b/plugins/devtools/src/components/Content/ScheduledTasksContent/fixtures/scheduledTasksErrors.json @@ -0,0 +1,205 @@ +{ + "tasks": [ + { + "taskId": "cool-provider", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT60M" + }, + "taskState": { + "status": "running", + "startedAt": "2025-10-31T21:02:36.461+00:00", + "timesOutAt": "2025-10-31T22:02:36.461+00:00", + "lastRunEndedAt": "2025-10-31T19:57:15.674+00:00", + "lastRunError": "{\"name\":\"error\",\"message\":\"insert into \\\"refresh_state\\\" (\\\"entity_id\\\", \\\"entity_ref\\\", \\\"errors\\\", \\\"last_discovery_at\\\", \\\"location_key\\\", \\\"next_update_at\\\", \\\"unprocessed_entity\\\", \\\"unprocessed_hash\\\") values ($1, $2, $3, CURRENT_TIMESTAMP, $4, CURRENT_TIMESTAMP, $5, $6), ($7, $8, $9, CURRENT_TIMESTAMP, $10, CURRENT_TIMESTAMP, $11, $12), ($13, $14, $15, CURRENT_TIMESTAMP, $16, CURRENT_TIMESTAMP, $17, $18), ($19, $20, $21, CURRENT_TIMESTAMP, $22, CURRENT_TIMESTAMP, $23, $24), ($25, $26, $27, CURRENT_TIMESTAMP, $28, CURRENT_TIMESTAMP, $29, $30), ($31, $32, $33, CURRENT_TIMESTAMP, $34, CURRENT_TIMESTAMP, $35, $36), ($37, $38, $39, CURRENT_TIMESTAMP, $40, CURRENT_TIMESTAMP, $41, $42), ($43, $44, $45, CURRENT_TIMESTAMP, $46, CURRENT_TIMESTAMP, $47, $48), ($49, $50, $51, CURRENT_TIMESTAMP, $52, CURRENT_TIMESTAMP, $53, $54), ($55, $56, $57, CURRENT_TIMESTAMP, $58, CURRENT_TIMESTAMP, $59, $60), ($61, $62, $63, CURRENT_TIMESTAMP, $64, CURRENT_TIMESTAMP, $65, $66), ($67, $68, $69, CURRENT_TIMESTAMP, $70, CURRENT_TIMESTAMP, $71, $72), ($73, $74, $75, CURRENT_TIMESTAMP, $76, CURRENT_TIMESTAMP, $77, $78), ($79, $80, $81, CURRENT_TIMESTAMP, $82, CURRENT_TIMESTAMP, $83, $84), ($85, $86, $87, CURRENT_TIMESTAMP, $88, CURRENT_TIMESTAMP, $89, $90), ($91, $92, $93, CURRENT_TIMESTAMP, $94, CURRENT_TIMESTAMP, $95, $96), ($97, $98, $99, CURRENT_TIMESTAMP, $100, CURRENT_TIMESTAMP, $101, $102), ($103, $104, $105, CURRENT_TIMESTAMP, $106, CURRENT_TIMESTAMP, $107, $108), ($109, $110, $111, CURRENT_TIMESTAMP, $112, CURRENT_TIMESTAMP, $113, $114), ($115, $116, $117, CURRENT_TIMESTAMP, $118, CURRENT_TIMESTAMP, $119, $120), ($121, $122, $123, CURRENT_TIMESTAMP, $124, CURRENT_TIMESTAMP, $125, $126), ($127, $128, $129, CURRENT_TIMESTAMP, $130, CURRENT_TIMESTAMP, $131, $132), ($133, $134, $135, CURRENT_TIMESTAMP, $136, CURRENT_TIMESTAMP, $137, $138), ($139, $140, $141, CURRENT_TIMESTAMP, $142, CURRENT_TIMESTAMP, $143, $144), ($145, $146, $147, CURRENT_TIMESTAMP, $148, CURRENT_TIMESTAMP, $149, $150), ($151, $152, $153, CURRENT_TIMESTAMP, $154, CURRENT_TIMESTAMP, $155, $156), ($157, $158, $159, CURRENT_TIMESTAMP, $160, CURRENT_TIMESTAMP, $161, $162), ($163, $164, $165, CURRENT_TIMESTAMP, $166, CURRENT_TIMESTAMP, $167, $168), ($169, $170, $171, CURRENT_TIMESTAMP, $172, CURRENT_TIMESTAMP, $173, $174), ($175, $176, $177, CURRENT_TIMESTAMP, $178, CURRENT_TIMESTAMP, $179, $180), ($181, $182, $183, CURRENT_TIMESTAMP, $184, CURRENT_TIMESTAMP, $185, $186), ($187, $188, $189, CURRENT_TIMESTAMP, $190, CURRENT_TIMESTAMP, $191, $192), ($193, $194, $195, CURRENT_TIMESTAMP, $196, CURRENT_TIMESTAMP, $197, $198), ($199, $200, $201, CURRENT_TIMESTAMP, $202, CURRENT_TIMESTAMP, $203, $204), ($205, $206, $207, CURRENT_TIMESTAMP, $208, CURRENT_TIMESTAMP, $209, $210), ($211, $212, $213, CURRENT_TIMESTAMP, $214, CURRENT_TIMESTAMP, $215, $216), ($217, $218, $219, CURRENT_TIMESTAMP, $220, CURRENT_TIMESTAMP, $221, $222), ($223, $224, $225, CURRENT_TIMESTAMP, $226, CURRENT_TIMESTAMP, $227, $228), ($229, $230, $231, CURRENT_TIMESTAMP, $232, CURRENT_TIMESTAMP, $233, $234), ($235, $236, $237, CURRENT_TIMESTAMP, $238, CURRENT_TIMESTAMP, $239, $240), ($241, $242, $243, CURRENT_TIMESTAMP, $244, CURRENT_TIMESTAMP, $245, $246), ($247, $248, $249, CURRENT_TIMESTAMP, $250, CURRENT_TIMESTAMP, $251, $252), ($253, $254, $255, CURRENT_TIMESTAMP, $256, CURRENT_TIMESTAMP, $257, $258), ($259, $260, $261, CURRENT_TIMESTAMP, $262, CURRENT_TIMESTAMP, $263, $264), ($265, $266, $267, CURRENT_TIMESTAMP, $268, CURRENT_TIMESTAMP, $269, $270), ($271, $272, $273, CURRENT_TIMESTAMP, $274, CURRENT_TIMESTAMP, $275, $276), ($277, $278, $279, CURRENT_TIMESTAMP, $280, CURRENT_TIMESTAMP, $281, $282), ($283, $284, $285, CURRENT_TIMESTAMP, $286, CURRENT_TIMESTAMP, $287, $288), ($289, $290, $291, CURRENT_TIMESTAMP, $292, CURRENT_TIMESTAMP, $293, $294), ($295, $296, $297, CURRENT_TIMESTAMP, $298, CURRENT_TIMESTAMP, $299, $300) - value too long for type character varying(255)\",\"length\":99,\"severity\":\"ERROR\",\"code\":\"22001\",\"file\":\"varchar.c\",\"line\":\"637\",\"routine\":\"varchar\"}" + }, + "workerState": { + "status": "running" + } + }, + { + "taskId": "some-provider", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT24H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-11-01T21:02:34.850+00:00", + "lastRunEndedAt": "2025-10-31T21:02:35.556+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "three-provider", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT30M", + "timeoutAfterDuration": "PT2M" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-10-31T21:16:04.145+00:00", + "lastRunEndedAt": "2025-10-31T20:46:09.184+00:00", + "lastRunError": "{\"name\":\"TypeError\",\"message\":\"String.prototype.replaceAll called with a non-global RegExp argument\"}" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "a-provider", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT60M", + "timeoutAfterDuration": "PT24H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-10-31T21:16:04.152+00:00", + "lastRunEndedAt": "2025-10-31T20:16:08.713+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "github-provider:provider123:refresh", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT1H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-11-01T18:16:04.153+00:00", + "lastRunEndedAt": "2025-10-31T18:18:56.176+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "github-provider:provider123:refresh", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT1H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-11-01T18:16:04.156+00:00", + "lastRunEndedAt": "2025-10-31T18:16:04.264+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "github-provider:provider234:refresh", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT1H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-11-01T18:16:04.157+00:00", + "lastRunEndedAt": "2025-10-31T18:16:08.564+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "github-provider:provider567:refresh", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT1H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-11-01T18:16:04.154+00:00", + "lastRunEndedAt": "2025-10-31T18:16:06.978+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "github-provider:provider910:refresh", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT1H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-11-01T18:16:04.160+00:00", + "lastRunEndedAt": "2025-10-31T18:16:08.573+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "github-provider:provider000:refresh", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT24H", + "timeoutAfterDuration": "PT1H" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-11-01T18:16:04.161+00:00", + "lastRunEndedAt": "2025-10-31T18:18:54.339+00:00" + }, + "workerState": { + "status": "idle" + } + }, + { + "taskId": "catalog_orphan_cleanup", + "pluginId": "catalog", + "scope": "global", + "settings": { + "version": 2, + "cadence": "PT30S", + "timeoutAfterDuration": "PT24S" + }, + "taskState": { + "status": "idle", + "startsAt": "2025-10-31T21:06:35.672+00:00", + "lastRunEndedAt": "2025-10-31T21:06:09.106+00:00" + }, + "workerState": { + "status": "idle" + } + } + ] +} diff --git a/plugins/devtools/src/components/Content/ScheduledTasksContent/index.ts b/plugins/devtools/src/components/Content/ScheduledTasksContent/index.ts new file mode 100644 index 0000000000..dff8bcf653 --- /dev/null +++ b/plugins/devtools/src/components/Content/ScheduledTasksContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { ScheduledTasksContent } from './ScheduledTasksContent'; +export { ScheduledTaskDetailPanel } from './ScheduledTaskDetailedPanel'; diff --git a/plugins/devtools/src/components/Content/index.ts b/plugins/devtools/src/components/Content/index.ts index f5bd64f7c0..964e7116d9 100644 --- a/plugins/devtools/src/components/Content/index.ts +++ b/plugins/devtools/src/components/Content/index.ts @@ -17,3 +17,4 @@ export * from './ConfigContent'; export * from './InfoContent'; export * from './ExternalDependenciesContent'; +export * from './ScheduledTasksContent'; diff --git a/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx b/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx index b4e5d3db5b..11cba0213b 100644 --- a/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx +++ b/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx @@ -17,12 +17,14 @@ import { devToolsConfigReadPermission, devToolsInfoReadPermission, + devToolsTaskSchedulerReadPermission, } from '@backstage/plugin-devtools-common'; import { ConfigContent } from '../Content/ConfigContent'; import { DevToolsLayout } from '../DevToolsLayout'; import { InfoContent } from '../Content/InfoContent'; import { RequirePermission } from '@backstage/plugin-permission-react'; +import { ScheduledTasksContent } from '../Content/ScheduledTasksContent'; /** @public */ export const DefaultDevToolsPage = () => ( @@ -37,5 +39,10 @@ export const DefaultDevToolsPage = () => ( + + + + + ); diff --git a/plugins/devtools/src/hooks/index.ts b/plugins/devtools/src/hooks/index.ts index bb2e83d06f..105d03fb68 100644 --- a/plugins/devtools/src/hooks/index.ts +++ b/plugins/devtools/src/hooks/index.ts @@ -17,3 +17,5 @@ export { useConfig } from './useConfig'; export { useExternalDependencies } from './useExternalDependencies'; export { useInfo } from './useInfo'; +export { useScheduledTasks } from './useScheduledTasks'; +export { useTriggerScheduledTask } from './useTriggerScheduledTask'; diff --git a/plugins/devtools/src/hooks/useScheduledTasks.ts b/plugins/devtools/src/hooks/useScheduledTasks.ts new file mode 100644 index 0000000000..2797526dec --- /dev/null +++ b/plugins/devtools/src/hooks/useScheduledTasks.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { devToolsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/esm/useAsync'; + +export const useScheduledTasks = (plugin: string) => { + const api = useApi(devToolsApiRef); + + const { + value, + loading, + error: asyncError, + } = useAsync(async () => { + return api.getScheduledTasksByPlugin(plugin); + }, [api, plugin]); + + if (asyncError) { + return { + scheduledTasks: undefined, + loading: false, + error: asyncError.message, + }; + } + + return { + scheduledTasks: value?.scheduledTasks, + loading, + error: value?.error, + }; +}; diff --git a/plugins/devtools/src/hooks/useTriggerScheduledTask.ts b/plugins/devtools/src/hooks/useTriggerScheduledTask.ts new file mode 100644 index 0000000000..d26b7a8025 --- /dev/null +++ b/plugins/devtools/src/hooks/useTriggerScheduledTask.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useState, useCallback } from 'react'; +import { devToolsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; + +export const useTriggerScheduledTask = () => { + const api = useApi(devToolsApiRef); + const [isTriggering, setIsTriggering] = useState(false); + const [error, setError] = useState(); + + const triggerTask = useCallback( + async (plugin: string, taskId: string) => { + setIsTriggering(true); + setError(undefined); + + try { + await api.triggerScheduledTask(plugin, taskId); + } catch (e) { + setError(e); + } finally { + setIsTriggering(false); + } + }, + [api], + ); + + return { + triggerTask, + isTriggering, + triggerError: error?.message, + }; +}; diff --git a/yarn.lock b/yarn.lock index 3502df3388..ba24848237 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5473,6 +5473,7 @@ __metadata: resolution: "@backstage/plugin-devtools@workspace:plugins/devtools" dependencies: "@backstage/cli": "workspace:^" + "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" @@ -5484,7 +5485,9 @@ __metadata: "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:^4.0.0-alpha.57" "@testing-library/jest-dom": "npm:^6.0.0" + "@types/lodash": "npm:^4.14.151" "@types/react": "npm:^18.0.0" + lodash: "npm:^4.17.21" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" react-json-view: "npm:^1.21.3"