mirror of
https://github.com/woodchen-ink/certimate.git
synced 2025-07-18 09:21:56 +08:00
568 lines
15 KiB
TypeScript
568 lines
15 KiB
TypeScript
import dayjs from "dayjs";
|
||
import { produce } from "immer";
|
||
import { nanoid } from "nanoid";
|
||
|
||
import i18n from "@/i18n";
|
||
|
||
export interface WorkflowModel extends BaseModel {
|
||
name: string;
|
||
description?: string;
|
||
trigger: string;
|
||
triggerCron?: string;
|
||
enabled?: boolean;
|
||
content?: WorkflowNode;
|
||
draft?: WorkflowNode;
|
||
hasDraft?: boolean;
|
||
lastRunId?: string;
|
||
lastRunStatus?: string;
|
||
lastRunTime?: string;
|
||
}
|
||
|
||
export const WORKFLOW_TRIGGERS = Object.freeze({
|
||
AUTO: "auto",
|
||
MANUAL: "manual",
|
||
} as const);
|
||
|
||
export type WorkflowTriggerType = (typeof WORKFLOW_TRIGGERS)[keyof typeof WORKFLOW_TRIGGERS];
|
||
|
||
// #region Node
|
||
export enum WorkflowNodeType {
|
||
Start = "start",
|
||
End = "end",
|
||
Apply = "apply",
|
||
Upload = "upload",
|
||
Deploy = "deploy",
|
||
Notify = "notify",
|
||
Branch = "branch",
|
||
Condition = "condition",
|
||
ExecuteResultBranch = "execute_result_branch",
|
||
ExecuteSuccess = "execute_success",
|
||
ExecuteFailure = "execute_failure",
|
||
Custom = "custom",
|
||
}
|
||
|
||
const workflowNodeTypeDefaultNames: Map<WorkflowNodeType, string> = new Map([
|
||
[WorkflowNodeType.Start, i18n.t("workflow_node.start.label")],
|
||
[WorkflowNodeType.End, i18n.t("workflow_node.end.label")],
|
||
[WorkflowNodeType.Apply, i18n.t("workflow_node.apply.label")],
|
||
[WorkflowNodeType.Upload, i18n.t("workflow_node.upload.label")],
|
||
[WorkflowNodeType.Deploy, i18n.t("workflow_node.deploy.label")],
|
||
[WorkflowNodeType.Notify, i18n.t("workflow_node.notify.label")],
|
||
[WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")],
|
||
[WorkflowNodeType.Condition, i18n.t("workflow_node.condition.label")],
|
||
[WorkflowNodeType.ExecuteResultBranch, i18n.t("workflow_node.execute_result_branch.label")],
|
||
[WorkflowNodeType.ExecuteSuccess, i18n.t("workflow_node.execute_success.label")],
|
||
[WorkflowNodeType.ExecuteFailure, i18n.t("workflow_node.execute_failure.label")],
|
||
[WorkflowNodeType.Custom, i18n.t("workflow_node.custom.title")],
|
||
]);
|
||
|
||
const workflowNodeTypeDefaultInputs: Map<WorkflowNodeType, WorkflowNodeIO[]> = new Map([
|
||
[WorkflowNodeType.Apply, []],
|
||
[
|
||
WorkflowNodeType.Deploy,
|
||
[
|
||
{
|
||
name: "certificate",
|
||
type: "certificate",
|
||
required: true,
|
||
label: "证书",
|
||
},
|
||
],
|
||
],
|
||
[WorkflowNodeType.Notify, []],
|
||
]);
|
||
|
||
const workflowNodeTypeDefaultOutputs: Map<WorkflowNodeType, WorkflowNodeIO[]> = new Map([
|
||
[
|
||
WorkflowNodeType.Apply,
|
||
[
|
||
{
|
||
name: "certificate",
|
||
type: "certificate",
|
||
required: true,
|
||
label: "证书",
|
||
},
|
||
],
|
||
],
|
||
[
|
||
WorkflowNodeType.Upload,
|
||
[
|
||
{
|
||
name: "certificate",
|
||
type: "certificate",
|
||
required: true,
|
||
label: "证书",
|
||
},
|
||
],
|
||
],
|
||
[WorkflowNodeType.Deploy, []],
|
||
[WorkflowNodeType.Notify, []],
|
||
]);
|
||
|
||
export type WorkflowNode = {
|
||
id: string;
|
||
name: string;
|
||
type: WorkflowNodeType;
|
||
|
||
config?: Record<string, unknown>;
|
||
inputs?: WorkflowNodeIO[];
|
||
outputs?: WorkflowNodeIO[];
|
||
|
||
next?: WorkflowNode;
|
||
branches?: WorkflowNode[];
|
||
|
||
validated?: boolean;
|
||
};
|
||
|
||
export type WorkflowNodeConfigForStart = {
|
||
trigger: string;
|
||
triggerCron?: string;
|
||
};
|
||
|
||
export type WorkflowNodeConfigForApply = {
|
||
domains: string;
|
||
contactEmail: string;
|
||
challengeType: string;
|
||
provider: string;
|
||
providerAccessId: string;
|
||
providerConfig?: Record<string, unknown>;
|
||
caProvider?: string;
|
||
caProviderAccessId?: string;
|
||
caProviderConfig?: Record<string, unknown>;
|
||
keyAlgorithm: string;
|
||
nameservers?: string;
|
||
dnsPropagationTimeout?: number;
|
||
dnsTTL?: number;
|
||
disableFollowCNAME?: boolean;
|
||
disableARI?: boolean;
|
||
skipBeforeExpiryDays: number;
|
||
};
|
||
|
||
export type WorkflowNodeConfigForUpload = {
|
||
certificateId: string;
|
||
domains: string;
|
||
certificate: string;
|
||
privateKey: string;
|
||
};
|
||
|
||
export type WorkflowNodeConfigForDeploy = {
|
||
certificate: string;
|
||
provider: string;
|
||
providerAccessId?: string;
|
||
providerConfig?: Record<string, unknown>;
|
||
skipOnLastSucceeded: boolean;
|
||
};
|
||
|
||
export type WorkflowNodeConfigForNotify = {
|
||
subject: string;
|
||
message: string;
|
||
/**
|
||
* @deprecated
|
||
*/
|
||
channel?: string;
|
||
provider: string;
|
||
providerAccessId: string;
|
||
providerConfig?: Record<string, unknown>;
|
||
};
|
||
|
||
export type WorkflowNodeConfigForCondition = {
|
||
expression: Expr;
|
||
};
|
||
|
||
export type WorkflowNodeConfigForBranch = never;
|
||
|
||
export type WorkflowNodeConfigForEnd = never;
|
||
|
||
export type WorkflowNodeIO = {
|
||
name: string;
|
||
type: string;
|
||
required: boolean;
|
||
label: string;
|
||
value?: string;
|
||
valueSelector?: WorkflowNodeIOValueSelector;
|
||
};
|
||
|
||
export type WorkflowNodeIOValueSelector = {
|
||
id: string;
|
||
name: string;
|
||
};
|
||
|
||
export const workflowNodeIOOptions = (node: WorkflowNode, io: WorkflowNodeIO) => {
|
||
switch (io.type) {
|
||
case "certificate":
|
||
return [
|
||
{
|
||
label: "是否有效",
|
||
value: "valid",
|
||
},
|
||
{
|
||
label: "剩余有效天数",
|
||
value: "valid",
|
||
},
|
||
];
|
||
default:
|
||
return [
|
||
{
|
||
label: `${node.name} - ${io.label}`,
|
||
value: `${node.id}#${io.name}`,
|
||
},
|
||
];
|
||
}
|
||
};
|
||
|
||
// #endregion
|
||
|
||
// #region Condition expression
|
||
|
||
type Value = string | number | boolean;
|
||
|
||
export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!=";
|
||
|
||
export type LogicalOperator = "and" | "or" | "not";
|
||
|
||
export type ConstExpr = { type: "const"; value: Value };
|
||
export type VarExpr = { type: "var"; selector: WorkflowNodeIOValueSelector };
|
||
export type CompareExpr = { type: "compare"; op: ComparisonOperator; left: Expr; right: Expr };
|
||
export type LogicalExpr = { type: "logical"; op: LogicalOperator; left: Expr; right: Expr };
|
||
export type NotExpr = { type: "not"; expr: Expr };
|
||
|
||
export type Expr = ConstExpr | VarExpr | CompareExpr | LogicalExpr | NotExpr;
|
||
|
||
export const isConstExpr = (expr: Expr): expr is ConstExpr => {
|
||
return expr.type === "const";
|
||
};
|
||
|
||
export const isVarExpr = (expr: Expr): expr is VarExpr => {
|
||
return expr.type === "var";
|
||
};
|
||
|
||
// #endregion
|
||
|
||
const isBranchLike = (node: WorkflowNode) => {
|
||
return node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.ExecuteResultBranch;
|
||
};
|
||
|
||
type InitWorkflowOptions = {
|
||
template?: "standard";
|
||
};
|
||
|
||
export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => {
|
||
const root = newNode(WorkflowNodeType.Start, {}) as WorkflowNode;
|
||
root.config = { trigger: WORKFLOW_TRIGGERS.MANUAL };
|
||
|
||
if (options.template === "standard") {
|
||
let current = root;
|
||
current.next = newNode(WorkflowNodeType.Apply, {});
|
||
|
||
current = current.next;
|
||
current.next = newNode(WorkflowNodeType.Deploy, {});
|
||
|
||
current = current.next;
|
||
current.next = newNode(WorkflowNodeType.ExecuteResultBranch, {});
|
||
|
||
current = current.next!.branches![1];
|
||
current.next = newNode(WorkflowNodeType.Notify, {});
|
||
}
|
||
|
||
return {
|
||
id: null!,
|
||
name: `MyWorkflow-${dayjs().format("YYYYMMDDHHmmss")}`,
|
||
trigger: root.config!.trigger as string,
|
||
triggerCron: root.config!.triggerCron as string,
|
||
enabled: false,
|
||
draft: root,
|
||
hasDraft: true,
|
||
created: new Date().toISOString(),
|
||
updated: new Date().toISOString(),
|
||
};
|
||
};
|
||
|
||
type NewNodeOptions = {
|
||
branchIndex?: number;
|
||
};
|
||
|
||
export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}): WorkflowNode => {
|
||
const nodeTypeName = workflowNodeTypeDefaultNames.get(nodeType) || "";
|
||
const nodeName = options.branchIndex != null ? `${nodeTypeName} ${options.branchIndex + 1}` : nodeTypeName;
|
||
|
||
const node: WorkflowNode = {
|
||
id: nanoid(),
|
||
name: nodeName,
|
||
type: nodeType,
|
||
};
|
||
|
||
switch (nodeType) {
|
||
case WorkflowNodeType.Apply:
|
||
case WorkflowNodeType.Upload:
|
||
case WorkflowNodeType.Deploy:
|
||
{
|
||
node.inputs = workflowNodeTypeDefaultInputs.get(nodeType);
|
||
node.outputs = workflowNodeTypeDefaultOutputs.get(nodeType);
|
||
}
|
||
break;
|
||
|
||
case WorkflowNodeType.Condition:
|
||
{
|
||
node.validated = true;
|
||
}
|
||
break;
|
||
|
||
case WorkflowNodeType.Branch:
|
||
{
|
||
node.branches = [newNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newNode(WorkflowNodeType.Condition, { branchIndex: 1 })];
|
||
}
|
||
break;
|
||
|
||
case WorkflowNodeType.ExecuteResultBranch:
|
||
{
|
||
node.branches = [newNode(WorkflowNodeType.ExecuteSuccess), newNode(WorkflowNodeType.ExecuteFailure)];
|
||
}
|
||
break;
|
||
|
||
case WorkflowNodeType.ExecuteSuccess:
|
||
case WorkflowNodeType.ExecuteFailure:
|
||
{
|
||
node.validated = true;
|
||
}
|
||
break;
|
||
}
|
||
|
||
return node;
|
||
};
|
||
|
||
export const updateNode = (node: WorkflowNode, targetNode: WorkflowNode) => {
|
||
return produce(node, (draft) => {
|
||
let current = draft;
|
||
while (current) {
|
||
if (current.id === targetNode.id) {
|
||
// Object.assign(current, targetNode);
|
||
// TODO: 暂时这么处理,避免 #485 #489,后续再优化
|
||
current.type = targetNode.type;
|
||
current.name = targetNode.name;
|
||
current.config = targetNode.config;
|
||
current.inputs = targetNode.inputs;
|
||
current.outputs = targetNode.outputs;
|
||
current.next = targetNode.next;
|
||
current.branches = targetNode.branches;
|
||
current.validated = targetNode.validated;
|
||
break;
|
||
}
|
||
|
||
if (isBranchLike(current)) {
|
||
current.branches ??= [];
|
||
current.branches = current.branches.map((branch) => updateNode(branch, targetNode));
|
||
}
|
||
|
||
current = current.next as WorkflowNode;
|
||
}
|
||
|
||
return draft;
|
||
});
|
||
};
|
||
|
||
export const addNode = (node: WorkflowNode, previousNodeId: string, targetNode: WorkflowNode) => {
|
||
return produce(node, (draft) => {
|
||
let current = draft;
|
||
while (current) {
|
||
if (current.id === previousNodeId && !isBranchLike(targetNode)) {
|
||
targetNode.next = current.next;
|
||
current.next = targetNode;
|
||
break;
|
||
} else if (current.id === previousNodeId && isBranchLike(targetNode)) {
|
||
targetNode.branches![0].next = current.next;
|
||
current.next = targetNode;
|
||
break;
|
||
}
|
||
|
||
if (isBranchLike(current)) {
|
||
current.branches ??= [];
|
||
current.branches = current.branches.map((branch) => addNode(branch, previousNodeId, targetNode));
|
||
}
|
||
|
||
current = current.next as WorkflowNode;
|
||
}
|
||
|
||
return draft;
|
||
});
|
||
};
|
||
|
||
export const addBranch = (node: WorkflowNode, branchNodeId: string) => {
|
||
return produce(node, (draft) => {
|
||
let current = draft;
|
||
while (current) {
|
||
if (current.id === branchNodeId) {
|
||
if (current.type !== WorkflowNodeType.Branch) {
|
||
return draft;
|
||
}
|
||
|
||
current.branches ??= [];
|
||
current.branches.push(
|
||
newNode(WorkflowNodeType.Condition, {
|
||
branchIndex: current.branches.length,
|
||
})
|
||
);
|
||
break;
|
||
}
|
||
|
||
if (isBranchLike(current)) {
|
||
current.branches ??= [];
|
||
current.branches = current.branches.map((branch) => addBranch(branch, branchNodeId));
|
||
}
|
||
|
||
current = current.next as WorkflowNode;
|
||
}
|
||
|
||
return draft;
|
||
});
|
||
};
|
||
|
||
export const removeNode = (node: WorkflowNode, targetNodeId: string) => {
|
||
return produce(node, (draft) => {
|
||
let current = draft;
|
||
while (current) {
|
||
if (current.next?.id === targetNodeId) {
|
||
current.next = current.next.next;
|
||
break;
|
||
}
|
||
|
||
if (isBranchLike(current)) {
|
||
current.branches ??= [];
|
||
current.branches = current.branches.map((branch) => removeNode(branch, targetNodeId));
|
||
}
|
||
|
||
current = current.next as WorkflowNode;
|
||
}
|
||
|
||
return draft;
|
||
});
|
||
};
|
||
|
||
export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchIndex: number) => {
|
||
return produce(node, (draft) => {
|
||
let current = draft;
|
||
let last: WorkflowNode | undefined = {
|
||
id: "",
|
||
name: "",
|
||
type: WorkflowNodeType.Start,
|
||
next: draft,
|
||
};
|
||
while (current && last) {
|
||
if (current.id === branchNodeId) {
|
||
if (!isBranchLike(current)) {
|
||
return draft;
|
||
}
|
||
|
||
current.branches ??= [];
|
||
current.branches.splice(branchIndex, 1);
|
||
|
||
// 如果仅剩一个分支,删除分支节点,将分支节点的下一个节点挂载到当前节点
|
||
if (current.branches.length === 1) {
|
||
const branch = current.branches[0];
|
||
if (branch.next) {
|
||
last.next = branch.next;
|
||
let lastNode: WorkflowNode | undefined = branch.next;
|
||
while (lastNode?.next) {
|
||
lastNode = lastNode.next;
|
||
}
|
||
lastNode.next = current.next;
|
||
} else {
|
||
last.next = current.next;
|
||
}
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (isBranchLike(current)) {
|
||
current.branches ??= [];
|
||
current.branches = current.branches.map((branch) => removeBranch(branch, branchNodeId, branchIndex));
|
||
}
|
||
|
||
current = current.next as WorkflowNode;
|
||
last = last.next;
|
||
}
|
||
|
||
return draft;
|
||
});
|
||
};
|
||
|
||
const typeEqual = (a: WorkflowNodeIO, t: string) => {
|
||
if (t === "all") {
|
||
return true;
|
||
}
|
||
if (a.type === t) {
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: string = "all"): WorkflowNode[] => {
|
||
// 某个分支的节点,不应该能获取到相邻分支上节点的输出
|
||
const outputs: WorkflowNode[] = [];
|
||
|
||
const traverse = (current: WorkflowNode, output: WorkflowNode[]) => {
|
||
if (!current) {
|
||
return false;
|
||
}
|
||
if (current.id === nodeId) {
|
||
return true;
|
||
}
|
||
|
||
if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => typeEqual(io, type))) {
|
||
output.push({
|
||
...current,
|
||
outputs: current.outputs.filter((io) => typeEqual(io, type)),
|
||
});
|
||
}
|
||
|
||
if (isBranchLike(current)) {
|
||
let currentLength = output.length;
|
||
const latestOutput = output.length > 0 ? output[output.length - 1] : null;
|
||
for (const branch of current.branches!) {
|
||
if (branch.type === WorkflowNodeType.ExecuteFailure) {
|
||
output.splice(output.length - 1);
|
||
currentLength -= 1;
|
||
}
|
||
if (traverse(branch, output)) {
|
||
return true;
|
||
}
|
||
// 如果当前分支没有输出,清空之前的输出
|
||
if (output.length > currentLength) {
|
||
output.splice(currentLength);
|
||
}
|
||
if (latestOutput && branch.type === WorkflowNodeType.ExecuteFailure) {
|
||
output.push(latestOutput);
|
||
currentLength += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
return traverse(current.next as WorkflowNode, output);
|
||
};
|
||
|
||
traverse(root, outputs);
|
||
return outputs;
|
||
};
|
||
|
||
export const isAllNodesValidated = (node: WorkflowNode): boolean => {
|
||
let current = node as typeof node | undefined;
|
||
while (current) {
|
||
if (isBranchLike(current)) {
|
||
for (const branch of current.branches!) {
|
||
if (!isAllNodesValidated(branch)) {
|
||
return false;
|
||
}
|
||
}
|
||
} else {
|
||
if (!current.validated) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
current = current.next;
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|