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"