Merge branch 'feat/new-workflow' of github.com:fudiwei/certimate into next

This commit is contained in:
Yoan.liu
2025-02-12 09:42:00 +08:00
97 changed files with 2288 additions and 1138 deletions

View File

@@ -3,10 +3,14 @@ import { ClientResponseError } from "pocketbase";
import { type CertificateFormatType } from "@/domain/certificate";
import { getPocketBase } from "@/repository/_pocketbase";
type ArchiveRespData = {
fileBytes: string;
};
export const archive = async (certificateId: string, format?: CertificateFormatType) => {
const pb = getPocketBase();
const resp = await pb.send<BaseResponse<string>>(`/api/certificates/${encodeURIComponent(certificateId)}/archive`, {
const resp = await pb.send<BaseResponse<ArchiveRespData>>(`/api/certificates/${encodeURIComponent(certificateId)}/archive`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -24,6 +28,7 @@ export const archive = async (certificateId: string, format?: CertificateFormatT
};
type ValidateCertificateResp = {
isValid: boolean;
domains: string;
};
@@ -46,9 +51,13 @@ export const validateCertificate = async (certificate: string) => {
return resp;
};
type ValidatePrivateKeyResp = {
isValid: boolean;
};
export const validatePrivateKey = async (privateKey: string) => {
const pb = getPocketBase();
const resp = await pb.send<BaseResponse>(`/api/certificates/validate/private-key`, {
const resp = await pb.send<BaseResponse<ValidatePrivateKeyResp>>(`/api/certificates/validate/private-key`, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -22,7 +22,7 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
const handleDownloadClick = async (format: CertificateFormatType) => {
try {
const res = await archiveCertificate(data.id, format);
const bstr = atob(res.data);
const bstr = atob(res.data.fileBytes);
const u8arr = Uint8Array.from(bstr, (ch) => ch.charCodeAt(0));
const blob = new Blob([u8arr], { type: "application/zip" });
saveAs(blob, `${data.id}-${data.subjectAltNames}.zip`);
@@ -38,11 +38,27 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
<Form layout="vertical">
<Form.Item label={t("certificate.props.subject_alt_names")}>
<Input value={data.subjectAltNames} placeholder="" />
<Input value={data.subjectAltNames} variant="filled" placeholder="" />
</Form.Item>
<Form.Item label={t("certificate.props.issuer")}>
<Input value={data.issuer} variant="filled" placeholder="" />
</Form.Item>
<Form.Item label={t("certificate.props.validity")}>
<Input value={`${dayjs(data.effectAt).format("YYYY-MM-DD HH:mm:ss")} ~ ${dayjs(data.expireAt).format("YYYY-MM-DD HH:mm:ss")}`} placeholder="" />
<Input
value={`${dayjs(data.effectAt).format("YYYY-MM-DD HH:mm:ss")} ~ ${dayjs(data.expireAt).format("YYYY-MM-DD HH:mm:ss")}`}
variant="filled"
placeholder=""
/>
</Form.Item>
<Form.Item label={t("certificate.props.serial_number")}>
<Input value={data.serialNumber} variant="filled" placeholder="" />
</Form.Item>
<Form.Item label={t("certificate.props.key_algorithm")}>
<Input value={data.keyAlgorithm} variant="filled" placeholder="" />
</Form.Item>
<Form.Item>
@@ -59,7 +75,7 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
</CopyToClipboard>
</Tooltip>
</div>
<Input.TextArea value={data.certificate} rows={10} autoSize={{ maxRows: 10 }} readOnly />
<Input.TextArea value={data.certificate} variant="filled" rows={5} autoSize={{ maxRows: 5 }} readOnly />
</Form.Item>
<Form.Item>
@@ -76,7 +92,7 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
</CopyToClipboard>
</Tooltip>
</div>
<Input.TextArea value={data.privateKey} rows={10} autoSize={{ maxRows: 10 }} readOnly />
<Input.TextArea value={data.privateKey} variant="filled" rows={5} autoSize={{ maxRows: 5 }} readOnly />
</Form.Item>
</Form>

View File

@@ -0,0 +1,163 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SelectOutlined as SelectOutlinedIcon } from "@ant-design/icons";
import { useRequest } from "ahooks";
import { Alert, Button, Divider, Empty, Table, type TableProps, Tooltip, Typography, notification } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer";
import Show from "@/components/Show";
import { type CertificateModel } from "@/domain/certificate";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { listByWorkflowRunId as listCertificateByWorkflowRunId } from "@/repository/certificate";
import { getErrMsg } from "@/utils/error";
export type WorkflowRunDetailProps = {
className?: string;
style?: React.CSSProperties;
data: WorkflowRunModel;
};
const WorkflowRunDetail = ({ data, ...props }: WorkflowRunDetailProps) => {
const { t } = useTranslation();
return (
<div {...props}>
<Show when={data.status === WORKFLOW_RUN_STATUSES.SUCCEEDED}>
<Alert showIcon type="success" message={<Typography.Text type="success">{t("workflow_run.props.status.succeeded")}</Typography.Text>} />
</Show>
<Show when={data.status === WORKFLOW_RUN_STATUSES.FAILED}>
<Alert showIcon type="error" message={<Typography.Text type="danger">{t("workflow_run.props.status.failed")}</Typography.Text>} />
</Show>
<div className="my-4">
<Typography.Title level={5}>{t("workflow_run.logs")}</Typography.Title>
<div className="rounded-md bg-black p-4 text-stone-200">
<div className="flex flex-col space-y-4">
{data.logs?.map((item, i) => {
return (
<div key={i} className="flex flex-col space-y-2">
<div className="font-semibold">{item.nodeName}</div>
<div className="flex flex-col space-y-1">
{item.records?.map((output, j) => {
return (
<div key={j} className="flex space-x-2 text-sm" style={{ wordBreak: "break-word" }}>
<div className="whitespace-nowrap">[{dayjs(output.time).format("YYYY-MM-DD HH:mm:ss")}]</div>
{output.error ? <div className="text-red-500">{output.error}</div> : <div>{output.content}</div>}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
<Show when={data.status === WORKFLOW_RUN_STATUSES.SUCCEEDED}>
<Divider />
<WorkflowRunArtifacts runId={data.id} />
</Show>
</div>
);
};
const WorkflowRunArtifacts = ({ runId }: { runId: string }) => {
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const tableColumns: TableProps<CertificateModel>["columns"] = [
{
key: "$index",
align: "center",
fixed: "left",
width: 50,
render: (_, __, index) => index + 1,
},
{
key: "type",
title: t("workflow_run_artifact.props.type"),
render: () => t("workflow_run_artifact.props.type.certificate"),
},
{
key: "name",
title: t("workflow_run_artifact.props.name"),
ellipsis: true,
render: (_, record) => {
return (
<Typography.Text delete={!!record.deleted} ellipsis>
{record.subjectAltNames}
</Typography.Text>
);
},
},
{
key: "$action",
align: "end",
width: 120,
render: (_, record) => (
<Button.Group>
<CertificateDetailDrawer
data={record}
trigger={
<Tooltip title={t("certificate.action.view")}>
<Button color="primary" disabled={!!record.deleted} icon={<SelectOutlinedIcon />} variant="text" />
</Tooltip>
}
/>
</Button.Group>
),
},
];
const [tableData, setTableData] = useState<CertificateModel[]>([]);
const { loading: tableLoading } = useRequest(
() => {
return listCertificateByWorkflowRunId(runId);
},
{
refreshDeps: [runId],
onBefore: () => {
setTableData([]);
},
onSuccess: (res) => {
setTableData(res.items);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
return;
}
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
}
);
return (
<>
{NotificationContextHolder}
<Typography.Title level={5}>{t("workflow_run.artifacts")}</Typography.Title>
<Table<CertificateModel>
columns={tableColumns}
dataSource={tableData}
loading={tableLoading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
pagination={false}
rowKey={(record) => record.id}
size="small"
/>
</>
);
};
export default WorkflowRunDetail;

View File

@@ -1,12 +1,12 @@
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { Alert, Drawer, Typography } from "antd";
import dayjs from "dayjs";
import { Drawer } from "antd";
import Show from "@/components/Show";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { type WorkflowRunModel } from "@/domain/workflowRun";
import { useTriggerElement } from "@/hooks";
import WorkflowRunDetail from "./WorkflowRunDetail";
export type WorkflowRunDetailDrawerProps = {
data?: WorkflowRunModel;
loading?: boolean;
@@ -16,8 +16,6 @@ export type WorkflowRunDetailDrawerProps = {
};
const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowRunDetailDrawerProps) => {
const { t } = useTranslation();
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
@@ -30,37 +28,19 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR
<>
{triggerEl}
<Drawer destroyOnClose open={open} loading={loading} placement="right" title={`WorkflowRun #${data?.id}`} width={640} onClose={() => setOpen(false)}>
<Drawer
afterOpenChange={setOpen}
closable
destroyOnClose
open={open}
loading={loading}
placement="right"
title={`WorkflowRun #${data?.id}`}
width={640}
onClose={() => setOpen(false)}
>
<Show when={!!data}>
<Show when={data!.status === WORKFLOW_RUN_STATUSES.SUCCEEDED}>
<Alert showIcon type="success" message={<Typography.Text type="success">{t("workflow_run.props.status.succeeded")}</Typography.Text>} />
</Show>
<Show when={data!.status === WORKFLOW_RUN_STATUSES.FAILED}>
<Alert showIcon type="error" message={<Typography.Text type="danger">{t("workflow_run.props.status.failed")}</Typography.Text>} />
</Show>
<div className="mt-4 rounded-md bg-black p-4 text-stone-200">
<div className="flex flex-col space-y-4">
{data!.logs?.map((item, i) => {
return (
<div key={i} className="flex flex-col space-y-2">
<div className="font-semibold">{item.nodeName}</div>
<div className="flex flex-col space-y-1">
{item.outputs?.map((output, j) => {
return (
<div key={j} className="flex space-x-2 text-sm" style={{ wordBreak: "break-word" }}>
<div className="whitespace-nowrap">[{dayjs(output.time).format("YYYY-MM-DD HH:mm:ss")}]</div>
{output.error ? <div className="text-red-500">{output.error}</div> : <div>{output.content}</div>}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
<WorkflowRunDetail data={data!} />
</Show>
</Drawer>
</>

View File

@@ -1,13 +1,13 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
CheckCircleOutlined as CheckCircleOutlinedIcon,
ClockCircleOutlined as ClockCircleOutlinedIcon,
CloseCircleOutlined as CloseCircleOutlinedIcon,
DeleteOutlined as DeleteOutlinedIcon,
PauseCircleOutlined as PauseCircleOutlinedIcon,
PauseOutlined as PauseOutlinedIcon,
SelectOutlined as SelectOutlinedIcon,
StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
import { useRequest } from "ahooks";
@@ -18,7 +18,12 @@ import { ClientResponseError } from "pocketbase";
import { cancelRun as cancelWorkflowRun } from "@/api/workflows";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { list as listWorkflowRuns, remove as removeWorkflowRun } from "@/repository/workflowRun";
import {
list as listWorkflowRuns,
remove as removeWorkflowRun,
subscribe as subscribeWorkflowRun,
unsubscribe as unsubscribeWorkflowRun,
} from "@/repository/workflowRun";
import { getErrMsg } from "@/utils/error";
import WorkflowRunDetailDrawer from "./WorkflowRunDetailDrawer";
@@ -75,7 +80,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
);
} else if (record.status === WORKFLOW_RUN_STATUSES.CANCELED) {
return (
<Tag icon={<PauseCircleOutlinedIcon />} color="warning">
<Tag icon={<StopOutlinedIcon />} color="warning">
{t("workflow_run.props.status.canceled")}
</Tag>
);
@@ -211,6 +216,31 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
}
);
useEffect(() => {
const items = tableData.filter((e) => e.status === WORKFLOW_RUN_STATUSES.PENDING || e.status === WORKFLOW_RUN_STATUSES.RUNNING);
for (const item of items) {
subscribeWorkflowRun(item.id, (cb) => {
setTableData((prev) => {
const index = prev.findIndex((e) => e.id === item.id);
if (index !== -1) {
prev[index] = cb.record;
}
return [...prev];
});
if (cb.record.status !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.status !== WORKFLOW_RUN_STATUSES.RUNNING) {
unsubscribeWorkflowRun(item.id);
}
});
}
return () => {
for (const item of items) {
unsubscribeWorkflowRun(item.id);
}
};
}, [tableData]);
const handleCancelClick = (workflowRun: WorkflowRunModel) => {
modalApi.confirm({
title: t("workflow_run.action.cancel"),
@@ -275,7 +305,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
setPageSize(pageSize);
},
}}
rowKey={(record: WorkflowRunModel) => record.id}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</div>

View File

@@ -56,6 +56,7 @@ const ApplyNode = ({ node, disabled }: ApplyNodeProps) => {
const newNode = produce(node, (draft) => {
draft.config = {
...newValues,
challengeType: newValues.challengeType || "dns-01", // 默认使用 DNS-01 认证
};
draft.validated = true;
});

View File

@@ -56,6 +56,7 @@ const MULTIPLE_INPUT_DELIMITER = ";";
const initFormModel = (): ApplyNodeConfigFormFieldValues => {
return {
challengeType: "dns-01",
keyAlgorithm: "RSA2048",
skipBeforeExpiryDays: 20,
};
@@ -74,6 +75,7 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
.every((e) => validDomainName(e, { allowWildcard: true }));
}, t("common.errmsg.domain_invalid")),
contactEmail: z.string({ message: t("workflow_node.apply.form.contact_email.placeholder") }).email(t("common.errmsg.email_invalid")),
challengeType: z.string().nullish(),
provider: z.string({ message: t("workflow_node.apply.form.provider.placeholder") }).nonempty(t("workflow_node.apply.form.provider.placeholder")),
providerAccessId: z
.string({ message: t("workflow_node.apply.form.provider_access.placeholder") })
@@ -235,6 +237,16 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
<EmailInput placeholder={t("workflow_node.apply.form.contact_email.placeholder")} />
</Form.Item>
<Form.Item name="challengeType" label={t("workflow_node.apply.form.challenge_type.label")} rules={[formRule]} hidden>
<Select
options={["DNS-01"].map((e) => ({
label: e,
value: e.toLowerCase(),
}))}
placeholder={t("workflow_node.apply.form.challenge_type.placeholder")}
/>
</Form.Item>
<Form.Item name="provider" label={t("workflow_node.apply.form.provider.label")} hidden rules={[formRule]}>
<ApplyDNSProviderSelect
allowClear

View File

@@ -86,7 +86,7 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
</Form.Item>
<Form.Item name="message" label={t("workflow_node.notify.form.message.label")} rules={[formRule]}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
<Input.TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
</Form.Item>
<Form.Item className="mb-0">

View File

@@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next";
import { Flex, Typography } from "antd";
import { produce } from "immer";
import type { WorkflowNodeConfigForUpload } from "@/domain/workflow";
import { WorkflowNodeType } from "@/domain/workflow";
import { type WorkflowNodeConfigForUpload, WorkflowNodeType } from "@/domain/workflow";
import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";

View File

@@ -137,11 +137,11 @@ const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNode
return (
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item name="domains" label={t("workflow_node.upload.form.domains.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.upload.form.domains.placeholder")} readOnly />
<Input variant="filled" placeholder={t("workflow_node.upload.form.domains.placeholder")} readOnly />
</Form.Item>
<Form.Item name="certificate" label={t("workflow_node.upload.form.certificate.label")} rules={[formRule]}>
<Input.TextArea readOnly autoSize={{ minRows: 5, maxRows: 10 }} placeholder={t("workflow_node.upload.form.certificate.placeholder")} />
<Input.TextArea readOnly autoSize={{ minRows: 5, maxRows: 5 }} placeholder={t("workflow_node.upload.form.certificate.placeholder")} />
</Form.Item>
<Form.Item>
@@ -151,7 +151,7 @@ const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNode
</Form.Item>
<Form.Item name="privateKey" label={t("workflow_node.upload.form.private_key.label")} rules={[formRule]}>
<Input.TextArea readOnly autoSize={{ minRows: 5, maxRows: 10 }} placeholder={t("workflow_node.upload.form.private_key.placeholder")} />
<Input.TextArea readOnly autoSize={{ minRows: 5, maxRows: 5 }} placeholder={t("workflow_node.upload.form.private_key.placeholder")} />
</Form.Item>
<Form.Item>

View File

@@ -3,8 +3,11 @@ import { type WorkflowModel } from "./workflow";
export interface CertificateModel extends BaseModel {
source: string;
subjectAltNames: string;
serialNumber: string;
certificate: string;
privateKey: string;
issuer: string;
keyAlgorithm: string;
effectAt: ISO8601String;
expireAt: ISO8601String;
workflowId: string;

View File

@@ -122,6 +122,7 @@ export type WorkflowNodeConfigForStart = {
export type WorkflowNodeConfigForApply = {
domains: string;
contactEmail: string;
challengeType: string;
provider: string;
providerAccessId: string;
providerConfig?: Record<string, unknown>;
@@ -276,21 +277,21 @@ export const updateNode = (node: WorkflowNode, targetNode: WorkflowNode) => {
});
};
export const addNode = (node: WorkflowNode, preId: string, targetNode: WorkflowNode) => {
export const addNode = (node: WorkflowNode, previousNodeId: string, targetNode: WorkflowNode) => {
return produce(node, (draft) => {
let current = draft;
while (current) {
if (current.id === preId && targetNode.type !== WorkflowNodeType.Branch && targetNode.type !== WorkflowNodeType.ExecuteResultBranch) {
if (current.id === previousNodeId && targetNode.type !== WorkflowNodeType.Branch && targetNode.type !== WorkflowNodeType.ExecuteResultBranch) {
targetNode.next = current.next;
current.next = targetNode;
break;
} else if (current.id === preId && (targetNode.type === WorkflowNodeType.Branch || targetNode.type === WorkflowNodeType.ExecuteResultBranch)) {
} else if (current.id === previousNodeId && (targetNode.type === WorkflowNodeType.Branch || targetNode.type === WorkflowNodeType.ExecuteResultBranch)) {
targetNode.branches![0].next = current.next;
current.next = targetNode;
break;
}
if (current.type === WorkflowNodeType.Branch || current.type === WorkflowNodeType.ExecuteResultBranch) {
current.branches = current.branches!.map((branch) => addNode(branch, preId, targetNode));
current.branches = current.branches!.map((branch) => addNode(branch, previousNodeId, targetNode));
}
current = current.next as WorkflowNode;
}
@@ -382,15 +383,15 @@ export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchInd
});
};
// 1 个分支的节点,不应该能获取到相邻分支上节点的输出
export const getWorkflowOutputBeforeId = (node: WorkflowNode, id: string, type: string): WorkflowNode[] => {
export const getWorkflowOutputBeforeId = (root: WorkflowNode, nodeId: string, type: string): WorkflowNode[] => {
// 1 个分支的节点,不应该能获取到相邻分支上节点的输出
const output: WorkflowNode[] = [];
const traverse = (current: WorkflowNode, output: WorkflowNode[]) => {
if (!current) {
return false;
}
if (current.id === id) {
if (current.id === nodeId) {
return true;
}
@@ -422,7 +423,7 @@ export const getWorkflowOutputBeforeId = (node: WorkflowNode, id: string, type:
return traverse(current.next as WorkflowNode, output);
};
traverse(node, output);
traverse(root, output);
return output;
};
@@ -446,21 +447,3 @@ export const isAllNodesValidated = (node: WorkflowNode): boolean => {
return true;
};
/**
* @deprecated
*/
export const getExecuteMethod = (node: WorkflowNode): { trigger: string; triggerCron: string } => {
if (node.type === WorkflowNodeType.Start) {
const config = node.config as WorkflowNodeConfigForStart;
return {
trigger: config.trigger ?? "",
triggerCron: config.triggerCron ?? "",
};
} else {
return {
trigger: "",
triggerCron: "",
};
}
};

View File

@@ -1,4 +1,4 @@
import type { WorkflowModel } from "./workflow";
import { type WorkflowModel } from "./workflow";
export interface WorkflowRunModel extends BaseModel {
workflowId: string;
@@ -16,13 +16,13 @@ export interface WorkflowRunModel extends BaseModel {
export type WorkflowRunLog = {
nodeId: string;
nodeName: string;
outputs?: WorkflowRunLogOutput[];
records?: WorkflowRunLogRecord[];
error?: string;
};
export type WorkflowRunLogOutput = {
export type WorkflowRunLogRecord = {
time: ISO8601String;
title: string;
level: string;
content: string;
error?: string;
};

View File

@@ -15,11 +15,15 @@
"certificate.props.validity.expiration": "Expire on {{date}}",
"certificate.props.validity.filter.expire_soon": "Expire soon",
"certificate.props.validity.filter.expired": "Expired",
"certificate.props.brand": "Brand",
"certificate.props.source": "Source",
"certificate.props.source.workflow": "Workflow",
"certificate.props.source.upload": "Upload",
"certificate.props.certificate": "Certificate chain",
"certificate.props.private_key": "Private key",
"certificate.props.serial_number": "Serial number",
"certificate.props.key_algorithm": "Key algorithm",
"certificate.props.issuer": "Issuer",
"certificate.props.created_at": "Created at",
"certificate.props.updated_at": "Updated at"
}

View File

@@ -18,7 +18,7 @@
"workflow_node.start.form.trigger_cron.label": "Cron expression",
"workflow_node.start.form.trigger_cron.placeholder": "Please enter cron expression",
"workflow_node.start.form.trigger_cron.errmsg.invalid": "Please enter a valid cron expression",
"workflow_node.start.form.trigger_cron.tooltip": "Time zone is based on the server.",
"workflow_node.start.form.trigger_cron.tooltip": "Exactly 5 space separated segments. Time zone is based on the server.",
"workflow_node.start.form.trigger_cron.extra": "Expected execution time for the last 5 times:",
"workflow_node.start.form.trigger_cron.guide": "Tips: If you have multiple workflows, it is recommended to set them to run at multiple times of the day instead of always running at specific times.<br><br>Reference links:<br>1. <a href=\"https://letsencrypt.org/docs/rate-limits/\" target=\"_blank\">Lets Encrypt rate limits</a><br>2. <a href=\"https://letsencrypt.org/docs/faq/#why-should-my-let-s-encrypt-acme-client-run-at-a-random-time\" target=\"_blank\">Why should my Lets Encrypt (ACME) client run at a random time?</a>",

View File

@@ -16,5 +16,12 @@
"workflow_run.props.trigger.auto": "Timing",
"workflow_run.props.trigger.manual": "Manual",
"workflow_run.props.started_at": "Started at",
"workflow_run.props.ended_at": "Ended at"
"workflow_run.props.ended_at": "Ended at",
"workflow_run.logs": "Logs",
"workflow_run.artifacts": "Artifacts",
"workflow_run_artifact.props.type": "Type",
"workflow_run_artifact.props.type.certificate": "Certificate",
"workflow_run_artifact.props.name": "Name"
}

View File

@@ -15,11 +15,15 @@
"certificate.props.validity.expiration": "{{date}} 到期",
"certificate.props.validity.filter.expire_soon": "即将到期",
"certificate.props.validity.filter.expired": "已到期",
"certificate.props.brand": "证书品牌",
"certificate.props.source": "来源",
"certificate.props.source.workflow": "工作流",
"certificate.props.source.upload": "用户上传",
"certificate.props.certificate": "证书内容",
"certificate.props.private_key": "私钥内容",
"certificate.props.serial_number": "证书序列号",
"certificate.props.key_algorithm": "证书算法",
"certificate.props.issuer": "颁发者",
"certificate.props.created_at": "创建时间",
"certificate.props.updated_at": "更新时间"
}

View File

@@ -18,7 +18,7 @@
"workflow_node.start.form.trigger_cron.label": "Cron 表达式",
"workflow_node.start.form.trigger_cron.placeholder": "请输入 Cron 表达式",
"workflow_node.start.form.trigger_cron.errmsg.invalid": "请输入正确的 Cron 表达式",
"workflow_node.start.form.trigger_cron.tooltip": "支持使用任意值(即 <strong>*</strong>)、值列表分隔符(即 <strong>,</strong>)、值的范围(即 <strong>-</strong>)、步骤值(即 <strong>/</strong>)等四种表达式时区以服务器设置为准。",
"workflow_node.start.form.trigger_cron.tooltip": "五段式表达式,支持使用任意值(即 <strong>*</strong>)、值列表分隔符(即 <strong>,</strong>)、值的范围(即 <strong>-</strong>)、步骤值(即 <strong>/</strong>)等四种表达式时区以服务器设置为准。",
"workflow_node.start.form.trigger_cron.extra": "预计最近 5 次执行时间:",
"workflow_node.start.form.trigger_cron.guide": "小贴士:如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。<br><br>参考链接:<br>1. <a href=\"https://letsencrypt.org/zh-cn/docs/rate-limits/\" target=\"_blank\">Lets Encrypt 速率限制</a><br>2. <a href=\"https://letsencrypt.org/zh-cn/docs/faq/#%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E7%9A%84-let-s-encrypt-acme-%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%90%AF%E5%8A%A8%E6%97%B6%E9%97%B4%E5%BA%94%E5%BD%93%E9%9A%8F%E6%9C%BA\" target=\"_blank\">为什么我的 Lets Encrypt (ACME) 客户端启动时间应当随机?</a>",

View File

@@ -16,5 +16,12 @@
"workflow_run.props.trigger.auto": "定时执行",
"workflow_run.props.trigger.manual": "手动执行",
"workflow_run.props.started_at": "开始时间",
"workflow_run.props.ended_at": "完成时间"
"workflow_run.props.ended_at": "完成时间",
"workflow_run.logs": "日志",
"workflow_run.artifacts": "输出产物",
"workflow_run_artifact.props.type": "类型",
"workflow_run_artifact.props.type.certificate": "证书",
"workflow_run_artifact.props.name": "名称"
}

View File

@@ -1,13 +1,8 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
line-height: 1.5;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -15,8 +10,7 @@
body {
margin: 0;
display: flex;
place-items: center;
padding: 0;
min-width: 320px;
min-height: 100vh;
}

View File

@@ -5,6 +5,7 @@ import dayjsUtc from "dayjs/plugin/utc";
import App from "./App";
import "./i18n";
import "./index.css";
import "./global.css";
dayjs.extend(dayjsUtc);

View File

@@ -207,7 +207,7 @@ const AccessList = () => {
setPageSize(pageSize);
},
}}
rowKey={(record: AccessModel) => record.id}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</div>

View File

@@ -107,6 +107,16 @@ const CertificateList = () => {
);
},
},
{
key: "issuer",
title: t("certificate.props.brand"),
render: (_, record) => (
<Space className="max-w-full" direction="vertical" size={4}>
<Typography.Text>{record.issuer}</Typography.Text>
<Typography.Text>{record.keyAlgorithm}</Typography.Text>
</Space>
),
},
{
key: "source",
title: t("certificate.props.source"),
@@ -250,7 +260,7 @@ const CertificateList = () => {
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadedError ? getErrMsg(loadedError) : t("certificate.nodata")} />,
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("certificate.nodata"))} />,
}}
pagination={{
current: page,
@@ -266,7 +276,7 @@ const CertificateList = () => {
setPageSize(pageSize);
},
}}
rowKey={(record: CertificateModel) => record.id}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</div>

View File

@@ -7,16 +7,15 @@ import {
ClockCircleOutlined as ClockCircleOutlinedIcon,
CloseCircleOutlined as CloseCircleOutlinedIcon,
LockOutlined as LockOutlinedIcon,
PauseCircleOutlined as PauseCircleOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
SelectOutlined as SelectOutlinedIcon,
SendOutlined as SendOutlinedIcon,
StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
import { useRequest } from "ahooks";
import type { TableProps } from "antd";
import { Button, Card, Col, Divider, Empty, Flex, Grid, Row, Space, Statistic, Table, Tag, Typography, notification, theme } from "antd";
import { Button, Card, Col, Divider, Empty, Flex, Grid, Row, Space, Statistic, Table, type TableProps, Tag, Typography, notification, theme } from "antd";
import dayjs from "dayjs";
import {
CalendarClock as CalendarClockIcon,
@@ -89,7 +88,6 @@ const Dashboard = () => {
const workflow = record.expand?.workflowId;
return (
<Typography.Link
type="secondary"
ellipsis
onClick={() => {
if (workflow) {
@@ -129,7 +127,7 @@ const Dashboard = () => {
);
} else if (record.status === WORKFLOW_RUN_STATUSES.CANCELED) {
return (
<Tag icon={<PauseCircleOutlinedIcon />} color="warning">
<Tag icon={<StopOutlinedIcon />} color="warning">
{t("workflow_run.props.status.canceled")}
</Tag>
);
@@ -178,7 +176,7 @@ const Dashboard = () => {
() => {
return listWorkflowRuns({
page: 1,
perPage: 5,
perPage: 9,
expand: true,
});
},
@@ -286,8 +284,9 @@ const Dashboard = () => {
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
pagination={false}
rowKey={(record: WorkflowRunModel) => record.id}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
size="small"
/>
</Card>
</Flex>

View File

@@ -34,7 +34,7 @@ const SettingsPassword = () => {
onSubmit: async (values) => {
try {
await authWithPassword(getAuthStore().record!.email, values.oldPassword);
await saveAdmin({ password: values.newPassword });
await saveAdmin({ password: values.newPassword, passwordConfirm: values.confirmPassword });
messageApi.success(t("common.text.operation_succeeded"));

View File

@@ -42,7 +42,6 @@ const WorkflowDetail = () => {
useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled", "release", "discard"])
);
useEffect(() => {
// TODO: loading & error
workflowState.init(workflowId!);
return () => {
@@ -52,7 +51,7 @@ const WorkflowDetail = () => {
const [tabValue, setTabValue] = useState<"orchestration" | "runs">("orchestration");
const [isRunning, setIsRunning] = useState(false);
const [isPendingOrRunning, setIsPendingOrRunning] = useState(false);
const lastRunStatus = useMemo(() => workflow.lastRunStatus, [workflow]);
const [allowDiscard, setAllowDiscard] = useState(false);
@@ -60,14 +59,14 @@ const WorkflowDetail = () => {
const [allowRun, setAllowRun] = useState(false);
useEffect(() => {
setIsRunning(lastRunStatus == WORKFLOW_RUN_STATUSES.RUNNING);
setIsPendingOrRunning(lastRunStatus == WORKFLOW_RUN_STATUSES.PENDING || lastRunStatus == WORKFLOW_RUN_STATUSES.RUNNING);
}, [lastRunStatus]);
useEffect(() => {
if (!!workflowId && isRunning) {
subscribeWorkflow(workflowId, (e) => {
if (e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsRunning(false);
if (!!workflowId && isPendingOrRunning) {
subscribeWorkflow(workflowId, (cb) => {
if (cb.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsPendingOrRunning(false);
unsubscribeWorkflow(workflowId);
}
});
@@ -76,15 +75,15 @@ const WorkflowDetail = () => {
unsubscribeWorkflow(workflowId);
};
}
}, [workflowId, isRunning]);
}, [workflowId, isPendingOrRunning]);
useEffect(() => {
const hasReleased = !!workflow.content;
const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content);
setAllowDiscard(!isRunning && hasReleased && hasChanges);
setAllowRelease(!isRunning && hasChanges);
setAllowDiscard(!isPendingOrRunning && hasReleased && hasChanges);
setAllowRelease(!isPendingOrRunning && hasChanges);
setAllowRun(hasReleased);
}, [workflow.content, workflow.draft, workflow.hasDraft, isRunning]);
}, [workflow.content, workflow.draft, workflow.hasDraft, isPendingOrRunning]);
const handleEnableChange = async () => {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
@@ -174,12 +173,12 @@ const WorkflowDetail = () => {
let unsubscribeFn: Awaited<ReturnType<typeof subscribeWorkflow>> | undefined = undefined;
try {
setIsRunning(true);
setIsPendingOrRunning(true);
// subscribe before running workflow
unsubscribeFn = await subscribeWorkflow(workflowId!, (e) => {
if (e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsRunning(false);
if (e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.PENDING && e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsPendingOrRunning(false);
unsubscribeFn?.();
}
});
@@ -188,7 +187,7 @@ const WorkflowDetail = () => {
messageApi.info(t("workflow.detail.orchestration.action.run.prompt"));
} catch (err) {
setIsRunning(false);
setIsPendingOrRunning(false);
unsubscribeFn?.();
console.error(err);
@@ -279,7 +278,7 @@ const WorkflowDetail = () => {
</div>
<div className="flex justify-end">
<Space>
<Button disabled={!allowRun} icon={<CaretRightOutlinedIcon />} loading={isRunning} type="primary" onClick={handleRunClick}>
<Button disabled={!allowRun} icon={<CaretRightOutlinedIcon />} loading={isPendingOrRunning} type="primary" onClick={handleRunClick}>
{t("workflow.detail.orchestration.action.run")}
</Button>

View File

@@ -7,8 +7,8 @@ import {
CloseCircleOutlined as CloseCircleOutlinedIcon,
DeleteOutlined as DeleteOutlinedIcon,
EditOutlined as EditOutlinedIcon,
PauseCircleOutlined as PauseCircleOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
@@ -170,7 +170,7 @@ const WorkflowList = () => {
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.FAILED) {
icon = <CloseCircleOutlinedIcon style={{ color: themeToken.colorError }} />;
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.CANCELED) {
icon = <PauseCircleOutlinedIcon style={{ color: themeToken.colorWarning }} />;
icon = <StopOutlinedIcon style={{ color: themeToken.colorWarning }} />;
}
return (
@@ -350,7 +350,7 @@ const WorkflowList = () => {
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadedError ? getErrMsg(loadedError) : t("workflow.nodata")} />,
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("workflow.nodata"))} />,
}}
pagination={{
current: page,
@@ -366,7 +366,7 @@ const WorkflowList = () => {
setPageSize(pageSize);
},
}}
rowKey={(record: WorkflowModel) => record.id}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</div>

View File

@@ -6,3 +6,11 @@ export const getPocketBase = () => {
pb = new PocketBase("/");
return pb;
};
export const COLLECTION_NAME_ADMIN = "_superusers";
export const COLLECTION_NAME_ACCESS = "access";
export const COLLECTION_NAME_CERTIFICATE = "certificate";
export const COLLECTION_NAME_SETTINGS = "settings";
export const COLLECTION_NAME_WORKFLOW = "workflow";
export const COLLECTION_NAME_WORKFLOW_RUN = "workflow_run";
export const COLLECTION_NAME_WORKFLOW_OUTPUT = "workflow_output";

View File

@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import { type AccessModel } from "@/domain/access";
import { getPocketBase } from "./_pocketbase";
const COLLECTION_NAME = "access";
import { COLLECTION_NAME_ACCESS, getPocketBase } from "./_pocketbase";
export const list = async () => {
return await getPocketBase().collection(COLLECTION_NAME).getFullList<AccessModel>({
return await getPocketBase().collection(COLLECTION_NAME_ACCESS).getFullList<AccessModel>({
filter: "deleted=null",
sort: "-created",
requestKey: null,
@@ -15,15 +13,15 @@ export const list = async () => {
export const save = async (record: MaybeModelRecord<AccessModel>) => {
if (record.id) {
return await getPocketBase().collection(COLLECTION_NAME).update<AccessModel>(record.id, record);
return await getPocketBase().collection(COLLECTION_NAME_ACCESS).update<AccessModel>(record.id, record);
}
return await getPocketBase().collection(COLLECTION_NAME).create<AccessModel>(record);
return await getPocketBase().collection(COLLECTION_NAME_ACCESS).create<AccessModel>(record);
};
export const remove = async (record: MaybeModelRecordWithId<AccessModel>) => {
await getPocketBase()
.collection(COLLECTION_NAME)
.collection(COLLECTION_NAME_ACCESS)
.update<AccessModel>(record.id!, { deleted: dayjs.utc().format("YYYY-MM-DD HH:mm:ss") });
return true;
};

View File

@@ -1,17 +1,15 @@
import { getPocketBase } from "./_pocketbase";
const COLLECTION_NAME = "_superusers";
import { COLLECTION_NAME_ADMIN, getPocketBase } from "./_pocketbase";
export const authWithPassword = (username: string, password: string) => {
return getPocketBase().collection(COLLECTION_NAME).authWithPassword(username, password);
return getPocketBase().collection(COLLECTION_NAME_ADMIN).authWithPassword(username, password);
};
export const getAuthStore = () => {
return getPocketBase().authStore;
};
export const save = (data: { email: string } | { password: string }) => {
export const save = (data: { email: string } | { password: string; passwordConfirm: string }) => {
return getPocketBase()
.collection(COLLECTION_NAME)
.collection(COLLECTION_NAME_ADMIN)
.update(getAuthStore().record?.id || "", data);
};

View File

@@ -2,9 +2,7 @@ import dayjs from "dayjs";
import { type RecordListOptions } from "pocketbase";
import { type CertificateModel } from "@/domain/certificate";
import { getPocketBase } from "./_pocketbase";
const COLLECTION_NAME = "certificate";
import { COLLECTION_NAME_CERTIFICATE, getPocketBase } from "./_pocketbase";
export type ListCertificateRequest = {
page?: number;
@@ -35,12 +33,29 @@ export const list = async (request: ListCertificateRequest) => {
});
}
return pb.collection(COLLECTION_NAME).getList<CertificateModel>(page, perPage, options);
return pb.collection(COLLECTION_NAME_CERTIFICATE).getList<CertificateModel>(page, perPage, options);
};
export const listByWorkflowRunId = async (workflowRunId: string) => {
const pb = getPocketBase();
const options: RecordListOptions = {
filter: pb.filter("workflowRunId={:workflowRunId}", {
workflowRunId: workflowRunId,
}),
sort: "-created",
requestKey: null,
};
const items = await pb.collection(COLLECTION_NAME_CERTIFICATE).getFullList<CertificateModel>(options);
return {
totalItems: items.length,
items: items,
};
};
export const remove = async (record: MaybeModelRecordWithId<CertificateModel>) => {
await getPocketBase()
.collection(COLLECTION_NAME)
.collection(COLLECTION_NAME_CERTIFICATE)
.update<CertificateModel>(record.id!, { deleted: dayjs.utc().format("YYYY-MM-DD HH:mm:ss") });
return true;
};

View File

@@ -1,13 +1,11 @@
import { ClientResponseError } from "pocketbase";
import { type SettingsModel, type SettingsNames } from "@/domain/settings";
import { getPocketBase } from "./_pocketbase";
const COLLECTION_NAME = "settings";
import { COLLECTION_NAME_SETTINGS, getPocketBase } from "./_pocketbase";
export const get = async <T extends NonNullable<unknown>>(name: SettingsNames) => {
try {
const resp = await getPocketBase().collection(COLLECTION_NAME).getFirstListItem<SettingsModel<T>>(`name='${name}'`, {
const resp = await getPocketBase().collection(COLLECTION_NAME_SETTINGS).getFirstListItem<SettingsModel<T>>(`name='${name}'`, {
requestKey: null,
});
return resp;
@@ -25,8 +23,8 @@ export const get = async <T extends NonNullable<unknown>>(name: SettingsNames) =
export const save = async <T extends NonNullable<unknown>>(record: MaybeModelRecordWithId<SettingsModel<T>>) => {
if (record.id) {
return await getPocketBase().collection(COLLECTION_NAME).update<SettingsModel<T>>(record.id, record);
return await getPocketBase().collection(COLLECTION_NAME_SETTINGS).update<SettingsModel<T>>(record.id, record);
}
return await getPocketBase().collection(COLLECTION_NAME).create<SettingsModel<T>>(record);
return await getPocketBase().collection(COLLECTION_NAME_SETTINGS).create<SettingsModel<T>>(record);
};

View File

@@ -1,9 +1,7 @@
import { type RecordListOptions, type RecordSubscription } from "pocketbase";
import { type WorkflowModel } from "@/domain/workflow";
import { getPocketBase } from "./_pocketbase";
const COLLECTION_NAME = "workflow";
import { COLLECTION_NAME_WORKFLOW, getPocketBase } from "./_pocketbase";
export type ListWorkflowRequest = {
page?: number;
@@ -26,11 +24,11 @@ export const list = async (request: ListWorkflowRequest) => {
options.filter = pb.filter("enabled={:enabled}", { enabled: request.enabled });
}
return await pb.collection(COLLECTION_NAME).getList<WorkflowModel>(page, perPage, options);
return await pb.collection(COLLECTION_NAME_WORKFLOW).getList<WorkflowModel>(page, perPage, options);
};
export const get = async (id: string) => {
return await getPocketBase().collection(COLLECTION_NAME).getOne<WorkflowModel>(id, {
return await getPocketBase().collection(COLLECTION_NAME_WORKFLOW).getOne<WorkflowModel>(id, {
requestKey: null,
});
};
@@ -38,25 +36,21 @@ export const get = async (id: string) => {
export const save = async (record: MaybeModelRecord<WorkflowModel>) => {
if (record.id) {
return await getPocketBase()
.collection(COLLECTION_NAME)
.collection(COLLECTION_NAME_WORKFLOW)
.update<WorkflowModel>(record.id as string, record);
}
return await getPocketBase().collection(COLLECTION_NAME).create<WorkflowModel>(record);
return await getPocketBase().collection(COLLECTION_NAME_WORKFLOW).create<WorkflowModel>(record);
};
export const remove = async (record: MaybeModelRecordWithId<WorkflowModel>) => {
return await getPocketBase().collection(COLLECTION_NAME).delete(record.id);
return await getPocketBase().collection(COLLECTION_NAME_WORKFLOW).delete(record.id);
};
export const subscribe = async (id: string, cb: (e: RecordSubscription<WorkflowModel>) => void) => {
const pb = getPocketBase();
return pb.collection("workflow").subscribe(id, cb);
return getPocketBase().collection(COLLECTION_NAME_WORKFLOW).subscribe(id, cb);
};
export const unsubscribe = async (id: string) => {
const pb = getPocketBase();
return pb.collection("workflow").unsubscribe(id);
return getPocketBase().collection(COLLECTION_NAME_WORKFLOW).unsubscribe(id);
};

View File

@@ -1,8 +1,8 @@
import { type WorkflowRunModel } from "@/domain/workflowRun";
import { type RecordSubscription } from "pocketbase";
import { getPocketBase } from "./_pocketbase";
import { type WorkflowRunModel } from "@/domain/workflowRun";
const COLLECTION_NAME = "workflow_run";
import { COLLECTION_NAME_WORKFLOW_RUN, getPocketBase } from "./_pocketbase";
export type ListWorkflowRunsRequest = {
workflowId?: string;
@@ -23,7 +23,7 @@ export const list = async (request: ListWorkflowRunsRequest) => {
}
return await getPocketBase()
.collection(COLLECTION_NAME)
.collection(COLLECTION_NAME_WORKFLOW_RUN)
.getList<WorkflowRunModel>(page, perPage, {
filter: getPocketBase().filter(filter, params),
sort: "-created",
@@ -33,5 +33,13 @@ export const list = async (request: ListWorkflowRunsRequest) => {
};
export const remove = async (record: MaybeModelRecordWithId<WorkflowRunModel>) => {
return await getPocketBase().collection(COLLECTION_NAME).delete(record.id);
return await getPocketBase().collection(COLLECTION_NAME_WORKFLOW_RUN).delete(record.id);
};
export const subscribe = async (id: string, cb: (e: RecordSubscription<WorkflowRunModel>) => void) => {
return getPocketBase().collection(COLLECTION_NAME_WORKFLOW_RUN).subscribe(id, cb);
};
export const unsubscribe = async (id: string) => {
return getPocketBase().collection(COLLECTION_NAME_WORKFLOW_RUN).unsubscribe(id);
};

View File

@@ -25,14 +25,14 @@ export type WorkflowState = {
discard(): void;
destroy(): void;
addNode: (node: WorkflowNode, preId: string) => void;
addNode: (node: WorkflowNode, previousNodeId: string) => void;
updateNode: (node: WorkflowNode) => void;
removeNode: (nodeId: string) => void;
addBranch: (branchId: string) => void;
removeBranch: (branchId: string, index: number) => void;
getWorkflowOuptutBeforeId: (id: string, type: string) => WorkflowNode[];
getWorkflowOuptutBeforeId: (nodeId: string, type: string) => WorkflowNode[];
};
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
@@ -143,10 +143,10 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
});
},
addNode: async (node: WorkflowNode, preId: string) => {
addNode: async (node: WorkflowNode, previousNodeId: string) => {
if (!get().initialized) throw "Workflow not initialized yet";
const root = addNode(get().workflow.draft!, preId, node);
const root = addNode(get().workflow.draft!, previousNodeId, node);
const resp = await saveWorkflow({
id: get().workflow.id!,
draft: root,
@@ -243,7 +243,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
});
},
getWorkflowOuptutBeforeId: (id: string, type: string) => {
return getWorkflowOutputBeforeId(get().workflow.draft as WorkflowNode, id, type);
getWorkflowOuptutBeforeId: (nodeId: string, type: string) => {
return getWorkflowOutputBeforeId(get().workflow.draft as WorkflowNode, nodeId, type);
},
}));

View File

@@ -3,6 +3,8 @@
export const validCronExpression = (expr: string): boolean => {
try {
parseExpression(expr);
if (expr.trim().split(" ").length !== 5) return false; // pocketbase 后端仅支持五段式的表达式
return true;
} catch {
return false;
@@ -10,19 +12,15 @@ export const validCronExpression = (expr: string): boolean => {
};
export const getNextCronExecutions = (expr: string, times = 1): Date[] => {
if (!expr) return [];
if (!validCronExpression(expr)) return [];
try {
const now = new Date();
const cron = parseExpression(expr, { currentDate: now, iterator: true });
const now = new Date();
const cron = parseExpression(expr, { currentDate: now, iterator: true });
const result: Date[] = [];
for (let i = 0; i < times; i++) {
const next = cron.next();
result.push(next.value.toDate());
}
return result;
} catch {
return [];
const result: Date[] = [];
for (let i = 0; i < times; i++) {
const next = cron.next();
result.push(next.value.toDate());
}
return result;
};

View File

@@ -7,13 +7,13 @@ export const getErrMsg = (error: unknown): string => {
return error.message;
} else if (typeof error === "object" && error != null) {
if ("message" in error) {
return String(error.message);
return getErrMsg(error.message);
} else if ("msg" in error) {
return String(error.msg);
return getErrMsg(error.msg);
}
} else if (typeof error === "string") {
return error;
return error || "Unknown error";
}
return String(error ?? "Unknown error");
return "Unknown error";
};