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
+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,
};
};