Add Scenario Mode with workflow editor - backend and frontend implementation

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-11-23 05:32:56 +00:00
co-authored by whyour
parent a4712f2b96
commit af88062219
13 changed files with 1532 additions and 3 deletions
+2
View File
@@ -11,6 +11,7 @@ import system from './system';
import subscription from './subscription';
import update from './update';
import health from './health';
import scenario from './scenario';
export default () => {
const app = Router();
@@ -26,6 +27,7 @@ export default () => {
subscription(app);
update(app);
health(app);
scenario(app);
return app;
};
+144
View File
@@ -0,0 +1,144 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import ScenarioService from '../services/scenario';
import { celebrate, Joi } from 'celebrate';
const route = Router();
export default (app: Router) => {
app.use('/scenarios', route);
route.get(
'/',
celebrate({
query: Joi.object({
searchValue: Joi.string().optional().allow(''),
page: Joi.number().optional(),
size: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const { searchValue, page, size } = req.query as any;
const result = await scenarioService.list(
searchValue,
page ? parseInt(page) : undefined,
size ? parseInt(size) : undefined,
);
return res.send({ code: 200, data: result });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
celebrate({
body: Joi.object({
name: Joi.string().required(),
description: Joi.string().optional().allow(''),
workflowGraph: Joi.object().optional(),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const data = await scenarioService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
id: Joi.number().required(),
name: Joi.string().required(),
description: Joi.string().optional().allow(''),
workflowGraph: Joi.object().optional(),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const data = await scenarioService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
await scenarioService.remove(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
await scenarioService.disabled(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
await scenarioService.enabled(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const data = await scenarioService.getDb({ id: parseInt(req.params.id) });
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
};
+89
View File
@@ -0,0 +1,89 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
interface WorkflowNode {
id: string;
type: 'http' | 'script' | 'condition' | 'delay' | 'loop';
label: string;
x?: number;
y?: number;
config: {
// HTTP Request node
url?: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: string;
// Script node
scriptId?: number;
scriptPath?: string;
scriptContent?: string;
// Condition node
condition?: string;
trueNext?: string;
falseNext?: string;
// Delay node
delayMs?: number;
// Loop node
iterations?: number;
loopBody?: string[];
};
next?: string | string[]; // ID(s) of next node(s)
}
interface WorkflowGraph {
nodes: WorkflowNode[];
startNode?: string;
}
export class Scenario {
name?: string;
description?: string;
id?: number;
status?: 0 | 1; // 0: disabled, 1: enabled
workflowGraph?: WorkflowGraph;
createdAt?: Date;
updatedAt?: Date;
constructor(options: Scenario) {
this.name = options.name;
this.description = options.description;
this.id = options.id;
this.status = options.status || 0;
this.workflowGraph = options.workflowGraph;
this.createdAt = options.createdAt;
this.updatedAt = options.updatedAt;
}
}
export interface ScenarioInstance
extends Model<Scenario, Scenario>,
Scenario {}
export const ScenarioModel = sequelize.define<ScenarioInstance>(
'Scenario',
{
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
workflowGraph: {
type: DataTypes.JSON,
allowNull: true,
},
},
{
timestamps: true,
},
);
+81
View File
@@ -0,0 +1,81 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { Scenario, ScenarioModel } from '../data/scenario';
import { FindOptions, Op } from 'sequelize';
@Service()
export default class ScenarioService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public async create(payload: Scenario): Promise<Scenario> {
const scenario = new Scenario(payload);
const doc = await this.insert(scenario);
return doc;
}
public async insert(payload: Scenario): Promise<Scenario> {
const result = await ScenarioModel.create(payload, { returning: true });
return result.get({ plain: true });
}
public async update(payload: Scenario): Promise<Scenario> {
const doc = await this.getDb({ id: payload.id });
const scenario = new Scenario({ ...doc, ...payload });
const newDoc = await this.updateDb(scenario);
return newDoc;
}
public async updateDb(payload: Scenario): Promise<Scenario> {
await ScenarioModel.update(payload, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[]) {
await ScenarioModel.destroy({ where: { id: ids } });
}
public async list(
searchText?: string,
page?: number,
size?: number,
): Promise<{ data: Scenario[]; total: number }> {
const where: any = {};
if (searchText) {
where[Op.or] = [
{ name: { [Op.like]: `%${searchText}%` } },
{ description: { [Op.like]: `%${searchText}%` } },
];
}
const count = await ScenarioModel.count({ where });
const data = await ScenarioModel.findAll({
where,
order: [['createdAt', 'DESC']],
limit: size,
offset: page && size ? (page - 1) * size : undefined,
});
return {
data: data.map((item) => item.get({ plain: true })),
total: count,
};
}
public async getDb(
query: FindOptions<Scenario>['where'],
): Promise<Scenario> {
const doc: any = await ScenarioModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`Scenario ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async disabled(ids: number[]) {
await ScenarioModel.update({ status: 0 }, { where: { id: ids } });
}
public async enabled(ids: number[]) {
await ScenarioModel.update({ status: 1 }, { where: { id: ids } });
}
}