feat: add scheduled tasks UI to devtools plugin

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

remove circular dependency

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

remove another unused backend defaults dependency

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

revert package.json

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

revert the other package.json

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

modify yarn lock file

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

fix api report for type

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

fix bulid api reports

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback and fixes

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback and fixes

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

add changeset

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

rebase yarn.lock

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

fix import for task response type

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

fix lint

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

fix debounce import

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

add lodash

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

remove debounce logic

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

remove unused auth

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

readd back changeset

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

remove example app from changeset

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

Update plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx

Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com>
Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

Update .changeset/short-lizards-find.md

Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com>
Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>

address feedback

Signed-off-by: williamwu-mongodb <william.t.wu@mongodb.com>
This commit is contained in:
williamwu-mongodb
2025-11-05 10:19:18 -08:00
parent fe7dd15bdd
commit 291bf9df13
23 changed files with 974 additions and 3 deletions
+8
View File
@@ -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
+5
View File
@@ -311,3 +311,8 @@ auth:
permission:
enabled: true
devTools:
scheduledTasks:
plugins:
- catalog
@@ -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 = () => {
<DevToolsLayout.Route path="config" title="Config">
<ConfigContent />
</DevToolsLayout.Route>
<DevToolsLayout.Route path="scheduled-tasks" title="Scheduled Tasks">
<ScheduledTasksContent />
</DevToolsLayout.Route>
<DevToolsLayout.Route
path="external-dependencies"
title="External Dependencies"
+60
View File
@@ -4,6 +4,7 @@
```ts
import { BasicPermission } from '@backstage/plugin-permission-common';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
// @public (undocumented)
@@ -44,6 +45,12 @@ export const devToolsInfoReadPermission: BasicPermission;
// @public
export const devToolsPermissions: BasicPermission[];
// @public (undocumented)
export const devToolsTaskSchedulerCreatePermission: BasicPermission;
// @public (undocumented)
export const devToolsTaskSchedulerReadPermission: BasicPermission;
// @public (undocumented)
export type Endpoint = {
name: string;
@@ -83,4 +90,57 @@ export type PackageDependency = {
name: string;
versions: string;
};
// @public (undocumented)
export type ScheduledTasks = {
scheduledTasks?: TaskApiTasksResponse[];
error?: string;
};
// @public
export interface TaskApiTasksResponse {
// (undocumented)
pluginId: string;
// (undocumented)
scope: 'global' | 'local';
// (undocumented)
settings: {
version: number;
} & JsonObject;
// (undocumented)
taskId: string;
// (undocumented)
taskState:
| {
status: 'running';
startedAt: string;
timesOutAt?: string;
lastRunError?: string;
lastRunEndedAt?: string;
}
| {
status: 'idle';
startsAt?: string;
lastRunError?: string;
lastRunEndedAt?: string;
}
| null;
// (undocumented)
workerState:
| {
status: 'initial-wait';
}
| {
status: 'idle';
}
| {
status: 'running';
}
| null;
}
// @public (undocumented)
export type TriggerScheduledTask = {
error?: string;
};
```
@@ -48,6 +48,22 @@ export const devToolsExternalDependenciesReadPermission = createPermission({
attributes: { action: 'read' },
});
/**
* @public
*/
export const devToolsTaskSchedulerReadPermission = createPermission({
name: 'devtools.task-scheduler',
attributes: { action: 'read' },
});
/**
* @public
*/
export const devToolsTaskSchedulerCreatePermission = createPermission({
name: 'devtools.task-scheduler',
attributes: { action: 'create' },
});
/**
* List of all Devtools permissions
*
@@ -58,4 +74,6 @@ export const devToolsPermissions = [
devToolsInfoReadPermission,
devToolsConfigReadPermission,
devToolsExternalDependenciesReadPermission,
devToolsTaskSchedulerReadPermission,
devToolsTaskSchedulerCreatePermission,
];
+52 -1
View File
@@ -16,7 +16,7 @@
/* We want to maintain the same information as an enum, so we disable the redeclaration warning */
/* eslint-disable @typescript-eslint/no-redeclare */
import { JsonValue } from '@backstage/types';
import { JsonObject, JsonValue } from '@backstage/types';
/** @public */
export type Endpoint = {
@@ -82,3 +82,54 @@ export type ConfigError = {
messages?: string[];
stack?: string;
};
/**
* The shape of a task definition as returned by the service's REST API.
* This is a duplication of the below:
* @see https://github.com/backstage/backstage/blob/master/packages/backend-defaults/src/entrypoints/scheduler/lib/types.ts
*
* @public
*/
export interface TaskApiTasksResponse {
taskId: string;
pluginId: string;
scope: 'global' | 'local';
settings: { version: number } & JsonObject;
taskState:
| {
status: 'running';
startedAt: string;
timesOutAt?: string;
lastRunError?: string;
lastRunEndedAt?: string;
}
| {
status: 'idle';
startsAt?: string;
lastRunError?: string;
lastRunEndedAt?: string;
}
| null;
workerState:
| {
status: 'initial-wait';
}
| {
status: 'idle';
}
| {
status: 'running';
}
| null;
}
/** @public */
export type ScheduledTasks = {
scheduledTasks?: TaskApiTasksResponse[];
error?: string;
};
/** @public */
export type TriggerScheduledTask = {
error?: string;
};
+17
View File
@@ -34,6 +34,12 @@ Lists the configuration being used by your current running Backstage instance.
![Example of Config tab](./docs/devtools-config-tab.png)
### Scheduled Tasks
Scheduled tasks can be viewed and triggered under the `Scheduled Tasks` tab. [See below to configure](#scheduled-tasks-configuration).
![Example of Scheduled Tasks tab](./docs/devtools-scheduled-tasks-tab.png)
## Optional Features
The DevTools plugin can be setup with other tabs with additional helpful features.
@@ -456,3 +462,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && \
apt-get install -y ... iputils-ping
```
### Scheduled Tasks Configuration
Scheduled tasks can be viewed and triggered under the `Scheduled Tasks` tab. You first must add the list of plugins for scheduled tasks to your config:
```yaml
devTools:
scheduledTasks:
plugins:
- catalog
```
+30
View File
@@ -0,0 +1,30 @@
/*
* 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 interface Config {
devTools: {
/**
* Scheduled tasks configuration
* @visibility frontend
*/
scheduledTasks: {
/**
* A list of plugin IDs to select from, e.g. ['catalog', 'scaffolder']
* @visibility frontend
*/
plugins: string[];
};
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

+7 -2
View File
@@ -39,7 +39,8 @@
}
},
"files": [
"dist"
"dist",
"config.d.ts"
],
"scripts": {
"build": "backstage-cli package build",
@@ -51,6 +52,7 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/core-compat-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
@@ -60,6 +62,7 @@
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.57",
"lodash": "^4.17.21",
"react-json-view": "^1.21.3",
"react-use": "^17.2.4"
},
@@ -67,6 +70,7 @@
"@backstage/cli": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@types/lodash": "^4.14.151",
"@types/react": "^18.0.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
@@ -82,5 +86,6 @@
"@types/react": {
"optional": true
}
}
},
"configSchema": "config.d.ts"
}
+11
View File
@@ -9,6 +9,7 @@ import { JSX as JSX_2 } from 'react/jsx-runtime';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { TabProps } from '@material-ui/core/Tab';
import { TaskApiTasksResponse } from '@backstage/plugin-devtools-common';
// @public (undocumented)
export const ConfigContent: () => 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;
+7
View File
@@ -19,6 +19,8 @@ import {
ConfigInfo,
DevToolsInfo,
ExternalDependency,
ScheduledTasks,
TriggerScheduledTask,
} from '@backstage/plugin-devtools-common';
export const devToolsApiRef = createApiRef<DevToolsApi>({
@@ -29,4 +31,9 @@ export interface DevToolsApi {
getConfig(): Promise<ConfigInfo | undefined>;
getExternalDependencies(): Promise<ExternalDependency[] | undefined>;
getInfo(): Promise<DevToolsInfo | undefined>;
getScheduledTasksByPlugin(plugin: string): Promise<ScheduledTasks>;
triggerScheduledTask(
plugin: string,
taskId: string,
): Promise<TriggerScheduledTask>;
}
@@ -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<ScheduledTasks> {
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<TriggerScheduledTask> {
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<TriggerScheduledTask>;
}
public async getExternalDependencies(): Promise<
ExternalDependency[] | undefined
> {
@@ -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 }) => (
<>
<Grid item xs={3}>
<Typography variant="subtitle2" className={classes.detailLabel}>
{title}:
</Typography>
</Grid>
<Grid item xs={9}>
<Typography variant="body2" component="code">
{value || 'N/A'}
</Typography>
</Grid>
</>
);
return (
<Box className={classes.detailPanel}>
{lastRunError && (
<Alert severity="error" className={classes.detailPanelAlert}>
<strong>Last Run Error:</strong> {lastRunError}
</Alert>
)}
<Grid container spacing={1}>
<DetailItem title="Worker State" value={rowData.workerState?.status} />
<DetailItem
title="Frequency (Cadence)"
value={rowData.settings?.cadence?.toString()}
/>
<DetailItem title="Scope" value={rowData.scope} />
<DetailItem
title="Started At"
value={
rowData.taskState?.status === 'running' &&
rowData.taskState.startedAt
? new Date(rowData.taskState.startedAt).toLocaleString()
: 'N/A'
}
/>
<DetailItem
title="Times Out At"
value={
rowData.taskState?.status === 'running' &&
rowData.taskState.timesOutAt
? new Date(rowData.taskState.timesOutAt).toLocaleString()
: 'N/A'
}
/>
</Grid>
</Box>
);
};
@@ -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;
}) => (
<Box display="flex" alignItems="center">
{icon}
<Typography variant="body2" style={{ marginLeft: 8 }}>
{text}
</Typography>
</Box>
);
/** @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 (
<Alert severity="info">
No plugins configured for scheduled tasks. Please configure
`devTools.scheduledTasks.plugins` in app-config.yaml.
</Alert>
);
}
const columns: TableColumn<TaskApiTasksResponse>[] = [
{
title: 'Task ID',
field: 'taskId',
width: '35%',
render: (rowData: TaskApiTasksResponse) => {
const errorIconStyle: React.CSSProperties = {
color: '#f44336',
marginRight: '8px',
fontSize: '1.2rem',
verticalAlign: 'middle',
};
return (
<Box display="flex" alignItems="center">
{rowData.taskState?.lastRunError && (
<Error style={errorIconStyle} />
)}
<Typography>{rowData.taskId}</Typography>
</Box>
);
},
},
{
title: 'Status',
field: 'taskState.status',
width: '15%',
render: (rowData: TaskApiTasksResponse) => {
const status = rowData.taskState?.status;
if (status === 'idle') {
return (
<StatusDisplay icon={<NightsStay fontSize="small" />} text="Idle" />
);
}
if (status === 'running') {
return (
<StatusDisplay
icon={<CircularProgress color="inherit" size="30px" />}
text="Running"
/>
);
}
return <Typography variant="body2">{status || 'N/A'}</Typography>;
},
},
{
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) => (
<RequirePermission permission={devToolsTaskSchedulerCreatePermission}>
<Tooltip title="Run Task">
<IconButton
aria-label="Trigger"
onClick={() => {
triggerTask(selectedPlugin, rowData.taskId);
if (isTriggering) {
<CircularProgress color="inherit" size="30px" />;
}
if (triggerError) {
alertApi.post({
message: `Error triggering task ${rowData.taskId}: ${error}`,
severity: 'error',
});
} else {
alertApi.post({
message: `Successfully triggered task ${rowData.taskId}`,
severity: 'success',
});
}
}}
>
<RefreshIcon />
</IconButton>
</Tooltip>
</RequirePermission>
),
sorting: false,
width: '10%',
},
];
return (
<Box>
<Autocomplete
className={classes.formControl}
classes={{ root: classes.formControl }}
freeSolo
options={plugins}
value={selectedPlugin}
inputValue={inputValue}
onChange={handleAutocompleteChange}
onInputChange={(_event, newInputValue) => {
setInputValue(newInputValue);
}}
renderInput={params => (
<TextField
{...params}
label="Select Plugin"
variant="outlined"
onKeyDown={handleKeyDown}
onBlur={handleCommitChange}
/>
)}
/>
{loading && <Progress />}
{error && (
<Alert severity="warning">
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.
</Alert>
)}
{!loading && !error && (
<Table
title={`Scheduled Tasks (${selectedPlugin})`}
options={{
paging: true,
search: true,
sorting: true,
searchFieldAlignment: 'right',
}}
columns={columns}
data={scheduledTasks || []}
emptyContent={
<Alert severity="info">
No scheduled tasks found for {selectedPlugin}.
</Alert>
}
detailPanel={({ rowData }) => {
return <ScheduledTaskDetailPanel rowData={rowData} />;
}}
/>
)}
</Box>
);
};
@@ -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"
}
}
]
}
@@ -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';
@@ -17,3 +17,4 @@
export * from './ConfigContent';
export * from './InfoContent';
export * from './ExternalDependenciesContent';
export * from './ScheduledTasksContent';
@@ -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 = () => (
<ConfigContent />
</RequirePermission>
</DevToolsLayout.Route>
<DevToolsLayout.Route path="scheduled-tasks" title="Scheduled Tasks">
<RequirePermission permission={devToolsTaskSchedulerReadPermission}>
<ScheduledTasksContent />
</RequirePermission>
</DevToolsLayout.Route>
</DevToolsLayout>
);
+2
View File
@@ -17,3 +17,5 @@
export { useConfig } from './useConfig';
export { useExternalDependencies } from './useExternalDependencies';
export { useInfo } from './useInfo';
export { useScheduledTasks } from './useScheduledTasks';
export { useTriggerScheduledTask } from './useTriggerScheduledTask';
@@ -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,
};
};
@@ -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<Error | undefined>();
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,
};
};
+3
View File
@@ -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"