feat(ui): new Dashboard page

This commit is contained in:
Fu Diwei 2025-07-24 20:22:31 +08:00
parent 488960397a
commit d29427b018
25 changed files with 376 additions and 284 deletions

View File

@ -86,7 +86,7 @@ const AccessProviderPicker = ({ className, style, autoFocus, placeholder, showOp
<Input.Search ref={keywordInputRef} placeholder={placeholder ?? t("common.text.search")} onChange={(e) => setKeyword(e.target.value.trim())} />
<div className="mt-4">
<Show when={providers.length > 0} fallback={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<Show when={providers.length > 0} fallback={<Empty description={t("common.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<div
className={mergeCls("grid w-full gap-2", `grid-cols-${providerCols}`, {
"gap-4": gap === "large",

View File

@ -106,7 +106,7 @@ const DeploymentProviderPicker = ({ className, style, autoFocus, onFilter, place
/>
<div className="flex-1">
<Show when={providers.length > 0} fallback={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("common.text.nodata")} />}>
<Show when={providers.length > 0} fallback={<Empty description={t("common.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<div
className={mergeCls("grid w-full gap-2", `grid-cols-${providerCols}`, {
"gap-4": gap === "large",

View File

@ -343,7 +343,7 @@ const WorkflowRunArtifacts = ({ runId }: { runId: string }) => {
dataSource={tableData}
loading={tableLoading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
emptyText: <Empty description={t("common.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
pagination={false}
rowKey={(record) => record.id}

View File

@ -11,6 +11,7 @@ import Empty from "@/components/Empty";
import WorkflowStatusTag from "@/components/workflow/WorkflowStatusTag";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { useAppSettings } from "@/hooks";
import {
list as listWorkflowRuns,
remove as removeWorkflowRun,
@ -31,15 +32,19 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const { modal, notification } = App.useApp();
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const { appSettings: globalAppSettings } = useAppSettings();
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(globalAppSettings.defaultPerPage!);
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const tableColumns: TableProps<WorkflowRunModel>["columns"] = [
{
key: "$index",
align: "center",
fixed: "left",
width: 50,
width: 48,
render: (_, __, index) => (page - 1) * pageSize + index + 1,
},
{
@ -106,7 +111,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
return (
<div className="flex items-center justify-end">
<Tooltip title={t("common.button.view")}>
<Tooltip title={t("workflow_run.action.view.button")}>
<Button
color="primary"
icon={<IconBrowserShare size="1.25em" />}
@ -129,7 +134,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
}}
/>
</Tooltip>
<Tooltip title={t("common.button.delete")}>
<Tooltip title={t("workflow_run.action.delete.button")}>
<Button
color="danger"
danger
@ -145,10 +150,15 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
</div>
);
},
onCell: () => {
return {
onClick: (e) => {
e.stopPropagation();
},
};
},
},
];
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const {
loading,
@ -206,6 +216,11 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
};
}, [tableData]);
const handlePaginationChange = (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
};
const { setData: setDetailRecord, setOpen: setDetailOpen, ...detailDrawerProps } = WorkflowRunDetailDrawer.useProps();
const handleRecordDetailClick = (workflowRun: WorkflowRunModel) => {
@ -259,7 +274,8 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
return (
<div className={className} style={style}>
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.table.alert") }}></span>} showIcon type="info" />
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.deletion.alert") }}></span>} showIcon type="info" />
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.cancellation.alert") }}></span>} showIcon type="info" />
<Table<WorkflowRunModel>
columns={tableColumns}
@ -269,7 +285,11 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
emptyText: loading ? (
<Skeleton />
) : (
<Empty title={t("common.text.nodata")} description={loadedError ? getErrMsg(loadedError) : undefined} icon={<IconHistory size={24} />} />
<Empty
title={t("common.text.nodata")}
description={loadedError ? getErrMsg(loadedError) : t("workflow_run.nodata.description")}
icon={<IconHistory size={24} />}
/>
),
}}
pagination={{
@ -277,14 +297,8 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
rowClassName="cursor-pointer"
rowKey={(record) => record.id}

View File

@ -4,7 +4,7 @@
"access.nodata": "No credentials",
"access.nodata.title": "No credentials",
"access.nodata.description": "Add a credential to store authentication information.",
"access.nodata.description": "It looks like you don't have any credentials. Get started by adding one.",
"access.nodata.button": "Create credential",
"access.search.placeholder": "Search by credential name ...",

View File

@ -3,7 +3,7 @@
"certificate.page.subtitle": "SSL certificates contain the website's public key and the website's identity, along with related information. They are generated from the execution output of workflows.",
"certificate.nodata.title": "No Certificates",
"certificate.nodata.description": "Add a workflow to generate certificates!",
"certificate.nodata.description": "It looks like you don't have any certificates. Get started by running a workflow.",
"certificate.nodata.button": "Go to workflows",
"certificate.search.placeholder": "Search by certificate name or serial number ...",

View File

@ -6,7 +6,6 @@
"common.button.copy": "Copy",
"common.button.delete": "Delete",
"common.button.download": "Download",
"common.button.duplicate": "Duplicate",
"common.button.edit": "Edit",
"common.button.more": "More",
"common.button.ok": "Ok",

View File

@ -6,9 +6,10 @@
"dashboard.statistics.expired_certificates": "Expired certificates",
"dashboard.statistics.all_workflows": "All workflows",
"dashboard.statistics.enabled_workflows": "Active workflows",
"dashboard.statistics.unit": "",
"dashboard.latest_workflow_runs": "Latest workflow runs",
"dashboard.latest_workflow_runs.nodata.description": "It looks like you don't have any runs. Get started by running a workflow.",
"dashboard.latest_workflow_runs.nodata.button": "Go to workflows",
"dashboard.quick_actions": "Quick actions",
"dashboard.quick_actions.create_workflow": "Create workflow",

View File

@ -41,9 +41,9 @@
"provider.baiducloud.dns": "Baidu Cloud - DNS",
"provider.baishan": "Baishan",
"provider.baishan.cdn": "Baishan - CDN (Content Delivery Network)",
"provider.baotapanel": "aaPanel (aka BaoTaPanel)",
"provider.baotapanel.console": "aaPanel (aka BaoTaPanel) - Console",
"provider.baotapanel.site": "aaPanel (aka BaoTaPanel) - Website",
"provider.baotapanel": "aaPanel (aka BaotaPanel)",
"provider.baotapanel.console": "aaPanel (aka BaotaPanel) - Console",
"provider.baotapanel.site": "aaPanel (aka BaotaPanel) - Website",
"provider.baotawaf": "aaWAF (aka BaotaWAF)",
"provider.baotawaf.console": "aaWAF (aka BaotaWAF) - Console",
"provider.baotawaf.site": "aaWAF (aka BaotaWAF) - Website",

View File

@ -32,7 +32,7 @@
"settings.sslprovider.tab": "Certificate authority",
"settings.sslprovider.ca.title": "System-wide CA",
"settings.sslprovider.ca.tips": "You can set different CAs for each workflow as well. <br>The certificate validity lifetime, certificate algorithm, domain names count, and support for wildcard domain names are allowed may vary among different providers. After switching service providers, please check whether the configuration of the workflows needs to be adjusted.",
"settings.sslprovider.ca.tips": "You can use different CAs for each workflow as well. If you want to do this, please visit the credentials page. <br>The certificate validity lifetime, certificate algorithm, domain names count, and support for wildcard domain names are allowed may vary among different providers. After switching service providers, please check whether the configuration of the workflows needs to be adjusted.",
"settings.sslprovider.form.provider.label": "ACME CA provider",
"settings.sslprovider.form.letsencryptstaging_alert": "The staging environment can reduce the chance of your running up against rate limits. <br><br>Learn more:<br><a href=\"https://letsencrypt.org/docs/staging-environment/\" target=\"_blank\">https://letsencrypt.org/docs/staging-environment/</a>",
"settings.sslprovider.form.zerossl_eab_kid.label": "EAB KID",

View File

@ -926,7 +926,7 @@
"workflow_node.deploy.form.webhook_data.label": "Webhook data (Optional)",
"workflow_node.deploy.form.webhook_data.placeholder": "Please enter Webhook data to override the default value",
"workflow_node.deploy.form.webhook_data.tooltip": "Leave it blank to use the default Webhook data provided by the credential.",
"workflow_node.deploy.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"list-style: disc;\"><li><strong>${DOMAIN}</strong>: The primary domain of the certificate (<i>CommonName</i>).</li><li><strong>${DOMAINS}</strong>: The domain list of the certificate (<i>SubjectAltNames</i>).</li><li><strong>${CERTIFICATE}</strong>: The PEM format content of the certificate file.</li><li><strong>${SERVER_CERTIFICATE}</strong>: The PEM format content of the server certificate file.</li><li><strong>${INTERMEDIA_CERTIFICATE}</strong>: The PEM format content of the intermediate CA certificate file.</li><li><strong>${PRIVATE_KEY}</strong>: The PEM format content of the private key file.</li></ol></details><br>Please visit the authorization management page for addtional notes.",
"workflow_node.deploy.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"list-style: disc;\"><li><strong>${DOMAIN}</strong>: The primary domain of the certificate (<i>CommonName</i>).</li><li><strong>${DOMAINS}</strong>: The domain list of the certificate (<i>SubjectAltNames</i>).</li><li><strong>${CERTIFICATE}</strong>: The PEM format content of the certificate file.</li><li><strong>${SERVER_CERTIFICATE}</strong>: The PEM format content of the server certificate file.</li><li><strong>${INTERMEDIA_CERTIFICATE}</strong>: The PEM format content of the intermediate CA certificate file.</li><li><strong>${PRIVATE_KEY}</strong>: The PEM format content of the private key file.</li></ol></details><br>Please visit the credentials page for addtional notes.",
"workflow_node.deploy.form.webhook_data.errmsg.json_invalid": "Please enter a valiod JSON string",
"workflow_node.deploy.form.strategy_config.label": "Strategy settings",
"workflow_node.deploy.form.skip_on_last_succeeded.label": "Repeated deployment",
@ -977,7 +977,7 @@
"workflow_node.notify.form.webhook_data.label": "Webhook data (Optional)",
"workflow_node.notify.form.webhook_data.placeholder": "Please enter Webhook data to override the default value",
"workflow_node.notify.form.webhook_data.tooltip": "Leave it blank to use the default Webhook data provided by the credential.",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"list-style: disc;\"><li><strong>${SUBJECT}</strong>: The subject of notification.</li><li><strong>${MESSAGE}</strong>: The message of notification.</li></ol></details><br>Please visit the authorization management page for addtional notes.",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"list-style: disc;\"><li><strong>${SUBJECT}</strong>: The subject of notification.</li><li><strong>${MESSAGE}</strong>: The message of notification.</li></ol></details><br>Please visit the credentials page for addtional notes.",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valiod JSON string",
"workflow_node.notify.form.strategy_config.label": "Strategy settings",
"workflow_node.notify.form.skip_on_all_prev_skipped.label": "Silent behavior",

View File

@ -1,11 +1,16 @@
{
"workflow_run.action.cancel.button": "Cancel",
"workflow_run.action.view.button": "View details",
"workflow_run.action.cancel.button": "Cancel workflow run",
"workflow_run.action.cancel.modal.title": "Cancel run",
"workflow_run.action.cancel.modal.content": "Are you sure to cancel this run?",
"workflow_run.action.delete.button": "Delete workflow run",
"workflow_run.action.delete.modal.title": "Delete \"{{name}}\"",
"workflow_run.action.delete.modal.content": "Are you sure want to delete this workflow run? <br>This action cannot be undone.",
"workflow_run.table.alert": "The workflow run contains the execution results of each node. Deleting it may trigger re-application or re-deployment of certificates due to the inability to find the previous execution result. Please do not delete unless necessary. It is recommended to keep it for at least 180 days.",
"workflow_run.deletion.alert": "The workflow run contains the execution results of each node. Deleting it may trigger re-application or re-deployment of certificates due to the inability to find the previous execution result. Please do not delete unless necessary. It is recommended to keep it for at least 180 days. ",
"workflow_run.cancellation.alert": "If the process is unexpectedly terminated or the server times out, you can manually cancel long-hanging runs to prevent blocking subsequent executions.",
"workflow_run.nodata.description": "It looks like you don't have any runs. Get started by running this workflow.",
"workflow_run.props.id": "ID",
"workflow_run.props.status": "Status",

View File

@ -1,9 +1,9 @@
{
"access.page.title": "授权管理",
"access.page.subtitle": "授权中存储有用于访问特定第三方应用程序或服务的身份验证信息如账号密码、API 密钥、OAuth 令牌等)。",
"access.page.title": "授权凭据",
"access.page.subtitle": "授权凭据中存储有用于访问特定第三方应用程序或服务的身份验证信息如账号密码、API 密钥、OAuth 令牌等)。",
"access.nodata.title": "暂无授权",
"access.nodata.description": "新建一个授权并存储身份验证信息吧~",
"access.nodata.description": "当前未找到授权信息。请先创建。",
"access.nodata.button": "新建授权",
"access.search.placeholder": "按授权名称搜索……",

View File

@ -1,9 +1,9 @@
{
"certificate.page.title": "证书管理",
"certificate.page.title": "SSL 证书",
"certificate.page.subtitle": "SSL 证书含有网站的公钥和网站标识以及其他相关信息。它们来自于工作流的执行输出。",
"certificate.nodata.title": "暂无证书",
"certificate.nodata.description": "新建一个工作流去生成证书吧~",
"certificate.nodata.description": "当前未找到任何认证。请先运行一个工作流以生成证书。",
"certificate.nodata.button": "前往工作流",
"certificate.search.placeholder": "按证书名称或序列号搜索……",

View File

@ -6,7 +6,6 @@
"common.button.copy": "复制",
"common.button.delete": "刪除",
"common.button.download": "下载",
"common.button.duplicate": "复制",
"common.button.edit": "编辑",
"common.button.more": "更多",
"common.button.ok": "确定",

View File

@ -6,9 +6,10 @@
"dashboard.statistics.expired_certificates": "已过期证书",
"dashboard.statistics.all_workflows": "所有工作流",
"dashboard.statistics.enabled_workflows": "已启用工作流",
"dashboard.statistics.unit": "个",
"dashboard.latest_workflow_runs": "最近执行的工作流",
"dashboard.latest_workflow_runs.nodata.description": "当前未找到任何工作流执行记录。请先运行一个工作流。",
"dashboard.latest_workflow_runs.nodata.button": "前往工作流",
"dashboard.quick_actions": "快捷操作",
"dashboard.quick_actions.create_workflow": "新建工作流",

View File

@ -32,7 +32,7 @@
"settings.sslprovider.tab": "证书颁发机构",
"settings.sslprovider.ca.title": "全局证书颁发机构",
"settings.sslprovider.ca.tips": "你也可以在每个工作流中设置不同的证书颁发机构。<br>不同服务商所支持的证书有效期、证书算法、多域名数量上限、是否允许泛域名等可能不同,切换服务商后请注意检查已有工作流的配置是否需要调整。",
"settings.sslprovider.ca.tips": "如果你希望为在每个工作流中使用不同的证书颁发机构,请前往授权凭据页面。<br>不同服务商所支持的证书有效期、证书算法、多域名数量上限、是否允许泛域名等可能不同,切换服务商后请注意检查已有工作流的配置是否需要调整。",
"settings.sslprovider.form.provider.label": "证书颁发机构提供商",
"settings.sslprovider.form.letsencryptstaging_alert": "测试环境比生产环境有更宽松的速率限制,可进行测试性部署。<br><br>点击下方链接了解更多:<br><a href=\"https://letsencrypt.org/zh-cn/docs/staging-environment/\" target=\"_blank\">https://letsencrypt.org/zh-cn/docs/staging-environment/</a>",
"settings.sslprovider.form.zerossl_eab_kid.label": "EAB KID",

View File

@ -3,7 +3,7 @@
"workflow.page.subtitle": "工作流是自动化流程的节点集合。当满足触发条件时,工作流开始顺序执行各节点以完成复杂的任务。",
"workflow.nodata.title": "暂无工作流",
"workflow.nodata.description": "请先新建工作流以执行证书申请、部署等任务",
"workflow.nodata.description": "当前未找到工作流。请先创建。",
"workflow.nodata.button": "新建工作流",
"workflow.search.placeholder": "按工作流名称搜索……",

View File

@ -924,7 +924,7 @@
"workflow_node.deploy.form.webhook_data.label": "Webhook 回调数据(可选)",
"workflow_node.deploy.form.webhook_data.placeholder": "请输入 Webhook 回调数据以覆盖默认值",
"workflow_node.deploy.form.webhook_data.tooltip": "不填写时,将使用所选部署目标授权的默认 Webhook 回调数据。",
"workflow_node.deploy.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"list-style: disc;\"><li><strong>${DOMAIN}</strong>:证书的主域名(即 <i>CommonName</i>)。</li><li><strong>${DOMAINS}</strong>:证书的多域名列表(即 <i>SubjectAltNames</i>)。</li><li><strong>${CERTIFICATE}</strong>:证书文件 PEM 格式内容。</li><li><strong>${SERVER_CERTIFICATE}</strong>证书文件仅含服务器证书PEM 格式内容。</li><li><strong>${INTERMEDIA_CERTIFICATE}</strong>证书文件仅含中间证书PEM 格式内容。</li><li><strong>${PRIVATE_KEY}</strong>:私钥文件 PEM 格式内容。</li></ol></details><br>其他注意事项请前往授权管理页面查看。",
"workflow_node.deploy.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"list-style: disc;\"><li><strong>${DOMAIN}</strong>:证书的主域名(即 <i>CommonName</i>)。</li><li><strong>${DOMAINS}</strong>:证书的多域名列表(即 <i>SubjectAltNames</i>)。</li><li><strong>${CERTIFICATE}</strong>:证书文件 PEM 格式内容。</li><li><strong>${SERVER_CERTIFICATE}</strong>证书文件仅含服务器证书PEM 格式内容。</li><li><strong>${INTERMEDIA_CERTIFICATE}</strong>证书文件仅含中间证书PEM 格式内容。</li><li><strong>${PRIVATE_KEY}</strong>:私钥文件 PEM 格式内容。</li></ol></details><br>其他注意事项请前往授权凭据页面查看。",
"workflow_node.deploy.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串",
"workflow_node.deploy.form.strategy_config.label": "执行策略",
"workflow_node.deploy.form.skip_on_last_succeeded.label": "重复部署",
@ -975,7 +975,7 @@
"workflow_node.notify.form.webhook_data.label": "Webhook 回调数据(可选)",
"workflow_node.notify.form.webhook_data.placeholder": "请输入 Webhook 回调数据以覆盖默认值",
"workflow_node.notify.form.webhook_data.tooltip": "不填写时,将使用所选部署目标授权的默认 Webhook 回调数据。",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"list-style: disc;\"><li><strong>${SUBJECT}</strong>:通知主题。</li><li><strong>${MESSAGE}</strong>:通知内容。</ol></details><br>其他注意事项请前往授权管理页面查看。",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"list-style: disc;\"><li><strong>${SUBJECT}</strong>:通知主题。</li><li><strong>${MESSAGE}</strong>:通知内容。</ol></details><br>其他注意事项请前往授权凭据页面查看。",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串",
"workflow_node.notify.form.strategy_config.label": "执行策略",
"workflow_node.notify.form.skip_on_all_prev_skipped.label": "静默行为",

View File

@ -1,11 +1,16 @@
{
"workflow_run.action.view.button": "查看详情",
"workflow_run.action.cancel.button": "取消执行",
"workflow_run.action.cancel.modal.title": "取消执行",
"workflow_run.action.cancel.modal.content": "确定要取消此执行吗?此操作仅中止流程,但不会回滚已执行的节点。",
"workflow_run.action.delete.button": "删除执行",
"workflow_run.action.delete.modal.title": "删除执行「{{name}}」",
"workflow_run.action.delete.modal.content": "确定要删除该工作流执行记录吗?删除后仅会清除日志历史,但不会影响签发的证书。<br>注意此操作不可撤销,请谨慎操作。",
"workflow_run.table.alert": "执行记录中包含工作流各节点的执行结果,删除后可能导致因找不到前次执行结果而触发重新申请或部署证书。如无必要请勿提前删除,建议保留至少 180 天。",
"workflow_run.deletion.alert": "执行记录中包含工作流各节点的执行结果,删除后可能导致因找不到前次执行结果而触发重新申请或部署证书。如无必要请勿提前删除,建议保留至少 180 天。",
"workflow_run.cancellation.alert": "如遇进程意外中止、服务器超时等原因,你可以手动取消长时间挂起的执行,避免阻塞后续执行。",
"workflow_run.nodata.description": "当前未找到任何执行记录。请先运行此工作流。",
"workflow_run.props.id": "ID",
"workflow_run.props.status": "状态",

View File

@ -78,63 +78,63 @@ const AccessList = () => {
fixed: "right",
width: 64,
render: (_, record) => (
<div
className="flex items-center justify-end"
onClick={(e) => {
e.stopPropagation();
<Dropdown
menu={{
items: [
{
key: "edit",
label: t("access.action.edit.button"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />
</span>
),
onClick: () => {
handleRecordDetailClick(record);
},
},
{
key: "duplicate",
label: t("access.action.duplicate.button"),
icon: (
<span className="anticon scale-125">
<IconCopy size="1em" />
</span>
),
onClick: () => {
handleRecordDuplicateClick(record);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("access.action.delete.button"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: () => {
handleRecordDeleteClick(record);
},
},
],
}}
trigger={["click"]}
>
<Dropdown
menu={{
items: [
{
key: "edit",
label: t("access.action.edit.button"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />
</span>
),
onClick: () => {
handleRecordDetailClick(record);
},
},
{
key: "duplicate",
label: t("access.action.duplicate.button"),
icon: (
<span className="anticon scale-125">
<IconCopy size="1em" />
</span>
),
onClick: () => {
handleRecordDuplicateClick(record);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("access.action.delete.button"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: () => {
handleRecordDeleteClick(record);
},
},
],
}}
trigger={["click"]}
>
<Button icon={<IconDotsVertical size="1.25em" />} type="text" />
</Dropdown>
</div>
<Button icon={<IconDotsVertical size="1.25em" />} type="text" />
</Dropdown>
),
onCell: () => {
return {
onClick: (e) => {
e.stopPropagation();
},
};
},
},
];
const tableRowSelection: TableProps<AccessModel>["rowSelection"] = {

View File

@ -137,51 +137,51 @@ const CertificateList = () => {
fixed: "right",
width: 64,
render: (_, record) => (
<div
className="flex items-center justify-end"
onClick={(e) => {
e.stopPropagation();
<Dropdown
menu={{
items: [
{
key: "view",
label: t("certificate.action.view.button"),
icon: (
<span className="anticon scale-125">
<IconBrowserShare size="1em" />
</span>
),
onClick: () => {
handleRecordDetailClick(record);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("certificate.action.delete.button"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: () => {
handleRecordDeleteClick(record);
},
},
],
}}
trigger={["click"]}
>
<Dropdown
menu={{
items: [
{
key: "view",
label: t("certificate.action.view.button"),
icon: (
<span className="anticon scale-125">
<IconBrowserShare size="1em" />
</span>
),
onClick: () => {
handleRecordDetailClick(record);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("certificate.action.delete.button"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: () => {
handleRecordDeleteClick(record);
},
},
],
}}
trigger={["click"]}
>
<Button icon={<IconDotsVertical size="1.25em" />} type="text" />
</Dropdown>
</div>
<Button icon={<IconDotsVertical size="1.25em" />} type="text" />
</Dropdown>
),
onCell: () => {
return {
onClick: (e) => {
e.stopPropagation();
},
};
},
},
];
const tableRowSelection: TableProps<CertificateModel>["rowSelection"] = {
@ -404,7 +404,7 @@ const CertificateList = () => {
) : (
<Empty
title={t("certificate.nodata.title")}
description={getErrMsg(loadedError ?? t("certificate.nodata.description"))}
description={loadedError ? getErrMsg(loadedError) : t("certificate.nodata.description")}
icon={<IconCertificate size={24} />}
extra={
loadedError ? (

View File

@ -3,25 +3,31 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
IconActivity,
IconHierarchy3,
IconAlertHexagon,
IconCirclePlus,
IconExternalLink,
IconHexagonLetterX,
IconHistory,
IconLock,
IconPlugConnected,
IconPlus,
IconReload,
IconRoute,
IconShieldCheckered,
IconShieldExclamation,
IconShieldX,
IconUserShield,
} from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { App, Button, Card, Col, Divider, Empty, Flex, Grid, Row, Statistic, Table, type TableProps, Typography, theme } from "antd";
import { App, Button, Card, Col, Divider, Flex, Grid, Row, Skeleton, Table, type TableProps, Typography } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import { get as getStatistics } from "@/api/statistics";
import Empty from "@/components/Empty";
import WorkflowRunDetailDrawer from "@/components/workflow/WorkflowRunDetailDrawer";
import WorkflowStatusTag from "@/components/workflow/WorkflowStatusTag";
import { type Statistics } from "@/domain/statistics";
import { type WorkflowRunModel } from "@/domain/workflowRun";
import { useBrowserTheme } from "@/hooks";
import { list as listWorkflowRuns } from "@/repository/workflowRun";
import { mergeCls } from "@/utils/css";
import { getErrMsg } from "@/utils/error";
const Dashboard = () => {
@ -36,17 +42,19 @@ const Dashboard = () => {
<div className="mx-auto max-w-320">
<h1>{t("dashboard.page.title")}</h1>
<StatisticCards />
<div className="my-[6px]">
<StatisticCards />
</div>
<Divider />
<Flex justify="stretch" vertical={!breakpoints.lg} gap={16}>
<Card className="transition-all max-lg:flex-1 lg:w-[280px] xl:w-[360px]" title={t("dashboard.quick_actions")}>
<div className="flex flex-col gap-4">
<Button block type="primary" size="large" icon={<IconPlus size="1.25em" />} onClick={() => navigate("/workflows/new")}>
<Button block type="primary" size="large" icon={<IconCirclePlus size="1.25em" />} onClick={() => navigate("/workflows/new")}>
{t("dashboard.quick_actions.create_workflow")}
</Button>
<Button block size="large" icon={<IconUserShield size="1.25em" />} onClick={() => navigate("/settings/account")}>
<Button block size="large" icon={<IconLock size="1.25em" />} onClick={() => navigate("/settings/account")}>
{t("dashboard.quick_actions.change_password")}
</Button>
<Button block size="large" icon={<IconPlugConnected size="1.25em" />} onClick={() => navigate("/settings/ssl-provider")}>
@ -64,53 +72,81 @@ const Dashboard = () => {
};
const StatisticCard = ({
className,
style,
label,
loading,
icon,
value,
suffix,
onClick,
}: {
className?: string;
style?: React.CSSProperties;
label: React.ReactNode;
loading?: boolean;
icon: React.ReactNode;
value?: string | number | React.ReactNode;
suffix?: React.ReactNode;
onClick?: () => void;
}) => {
return (
<Card className="size-full overflow-hidden" hoverable loading={loading} variant="borderless" onClick={onClick}>
<div className="flex gap-2">
{icon}
<Statistic
title={label}
valueRender={() => {
return <Typography.Text className="text-4xl">{value}</Typography.Text>;
}}
suffix={<Typography.Text className="text-sm">{suffix}</Typography.Text>}
/>
<Card
className={mergeCls("size-full overflow-hidden ", className)}
style={style}
styles={{ body: { padding: 0 } }}
hoverable
loading={loading}
variant="borderless"
onClick={onClick}
>
<div className="relative overflow-hidden pt-6 pr-4 pb-4 pl-6">
<div className="absolute inset-0 z-0 bg-stone-200 opacity-10">
<div
className="size-full"
style={{
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.8) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.8) 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
/>
</div>
<div className="mb-2">
<div className="truncate text-sm font-medium text-white/75">{label}</div>
</div>
<div className="relative flex items-center justify-between">
<div className="truncate text-4xl font-medium text-white">{value}</div>
<div className="flex size-12 items-center justify-center rounded-full bg-white/25 p-3 text-white/75">{icon}</div>
</div>
</div>
</Card>
);
};
const StatisticCards = () => {
const StatisticCards = ({ className, style }: { className?: string; style?: React.CSSProperties }) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { notification } = App.useApp();
const { token: themeToken } = theme.useToken();
const { theme: browserTheme } = useBrowserTheme();
const statisticsGridSpans = {
const { notification } = App.useApp();
const cardGridSpans = {
xs: { flex: "50%" },
md: { flex: "50%" },
lg: { flex: "33.3333%" },
xl: { flex: "33.3333%" },
xxl: { flex: "20%" },
};
const cardStylesFn = (color: string) => ({
background:
browserTheme === "dark"
? `linear-gradient(135deg, color-mix(in srgb, ${color} 50%, black 20%) 0%, color-mix(in srgb, ${color} 50%, white 20%) 100%)`
: `linear-gradient(135deg, color-mix(in srgb, ${color} 80%, black 30%) 0%, color-mix(in srgb, ${color} 80%, white 30%) 100%)`,
});
const [statistics, setStatistics] = useState<Statistics>();
const { loading: statisticsLoading } = useRequest(
const { loading } = useRequest(
() => {
return getStatistics();
},
@ -132,74 +168,77 @@ const StatisticCards = () => {
);
return (
<Row className="justify-stretch" gutter={[16, 16]}>
<Col {...statisticsGridSpans}>
<StatisticCard
icon={<IconShieldCheckered size={48} strokeWidth={1} color={themeToken.colorInfo} />}
label={t("dashboard.statistics.all_certificates")}
loading={statisticsLoading}
value={statistics?.certificateTotal ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/certificates")}
/>
</Col>
<Col {...statisticsGridSpans}>
<StatisticCard
icon={<IconShieldExclamation size={48} strokeWidth={1} color={themeToken.colorWarning} />}
label={t("dashboard.statistics.expire_soon_certificates")}
loading={statisticsLoading}
value={statistics?.certificateExpireSoon ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/certificates?state=expireSoon")}
/>
</Col>
<Col {...statisticsGridSpans}>
<StatisticCard
icon={<IconShieldX size={48} strokeWidth={1} color={themeToken.colorError} />}
label={t("dashboard.statistics.expired_certificates")}
loading={statisticsLoading}
value={statistics?.certificateExpired ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/certificates?state=expired")}
/>
</Col>
<Col {...statisticsGridSpans}>
<StatisticCard
icon={<IconHierarchy3 size={48} strokeWidth={1} color={themeToken.colorInfo} />}
label={t("dashboard.statistics.all_workflows")}
loading={statisticsLoading}
value={statistics?.workflowTotal ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/workflows")}
/>
</Col>
<Col {...statisticsGridSpans}>
<StatisticCard
icon={<IconActivity size={48} strokeWidth={1} color={themeToken.colorSuccess} />}
label={t("dashboard.statistics.enabled_workflows")}
loading={statisticsLoading}
value={statistics?.workflowEnabled ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/workflows?state=enabled")}
/>
</Col>
</Row>
<div className={className} style={style}>
<Row className="justify-stretch" gutter={[16, 16]}>
<Col className="overflow-hidden" {...cardGridSpans}>
<StatisticCard
style={cardStylesFn("var(--color-info)")}
icon={<IconShieldCheckered size={48} />}
label={t("dashboard.statistics.all_certificates")}
loading={loading}
value={statistics?.certificateTotal ?? "-"}
onClick={() => navigate("/certificates")}
/>
</Col>
<Col className="overflow-hidden" {...cardGridSpans}>
<StatisticCard
style={cardStylesFn("var(--color-warning)")}
icon={<IconAlertHexagon size={48} />}
label={t("dashboard.statistics.expire_soon_certificates")}
loading={loading}
value={statistics?.certificateExpireSoon ?? "-"}
onClick={() => navigate("/certificates?state=expireSoon")}
/>
</Col>
<Col className="overflow-hidden" {...cardGridSpans}>
<StatisticCard
style={cardStylesFn("var(--color-error)")}
icon={<IconHexagonLetterX size={48} />}
label={t("dashboard.statistics.expired_certificates")}
loading={loading}
value={statistics?.certificateExpired ?? "-"}
onClick={() => navigate("/certificates?state=expired")}
/>
</Col>
<Col className="overflow-hidden" {...cardGridSpans}>
<StatisticCard
style={cardStylesFn("var(--color-info)")}
icon={<IconRoute size={48} />}
label={t("dashboard.statistics.all_workflows")}
loading={loading}
value={statistics?.workflowTotal ?? "-"}
onClick={() => navigate("/workflows")}
/>
</Col>
<Col className="overflow-hidden" {...cardGridSpans}>
<StatisticCard
style={cardStylesFn("var(--color-success)")}
icon={<IconActivity size={48} />}
label={t("dashboard.statistics.enabled_workflows")}
loading={loading}
value={statistics?.workflowEnabled ?? "-"}
onClick={() => navigate("/workflows?state=enabled")}
/>
</Col>
</Row>
</div>
);
};
const WorkflowRunHistoryTable = () => {
const WorkflowRunHistoryTable = ({ className, style }: { className?: string; style?: React.CSSProperties }) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { notification } = App.useApp();
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
const tableColumns: TableProps<WorkflowRunModel>["columns"] = [
{
key: "$index",
align: "center",
fixed: "left",
width: 50,
width: 48,
render: (_, __, index) => index + 1,
},
{
@ -255,12 +294,16 @@ const WorkflowRunHistoryTable = () => {
},
},
];
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
const { loading: tableLoading } = useRequest(
const {
loading,
error: loadedError,
run: refreshData,
} = useRequest(
() => {
return listWorkflowRuns({
page: 1,
perPage: 9,
perPage: 15,
expand: true,
});
},
@ -281,6 +324,12 @@ const WorkflowRunHistoryTable = () => {
}
);
const handleReloadClick = () => {
if (loading) return;
refreshData();
};
const { setData: setDetailRecord, setOpen: setDetailOpen, ...detailDrawerProps } = WorkflowRunDetailDrawer.useProps();
const handleRecordDetailClick = (workflowRun: WorkflowRunModel) => {
@ -289,13 +338,32 @@ const WorkflowRunHistoryTable = () => {
};
return (
<>
<div className={className} style={style}>
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={tableLoading}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
emptyText: loading ? (
<Skeleton />
) : (
<Empty
title={t("common.text.nodata")}
description={loadedError ? getErrMsg(loadedError) : t("dashboard.latest_workflow_runs.nodata.description")}
icon={<IconHistory size={24} />}
extra={
loadedError ? (
<Button icon={<IconReload size="1.25em" />} type="primary" onClick={handleReloadClick}>
{t("common.button.reload")}
</Button>
) : (
<Button icon={<IconExternalLink size="1.25em" />} type="primary" onClick={() => navigate("/workflows")}>
{t("dashboard.latest_workflow_runs.nodata.button")}
</Button>
)
}
/>
),
}}
pagination={false}
rowClassName="cursor-pointer"
@ -310,7 +378,7 @@ const WorkflowRunHistoryTable = () => {
/>
<WorkflowRunDetailDrawer {...detailDrawerProps} />
</>
</div>
);
};

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { IconArrowBackUp, IconChevronDown, IconDots, IconHistory, IconPlayerPlay, IconRobot, IconTrash } from "@tabler/icons-react";
import { Alert, App, Button, Card, Dropdown, Flex, Form, Input, Segmented, Space, Tabs } from "antd";
import { Alert, App, Button, Card, Dropdown, Flex, Form, Input, Segmented, Space } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { isEqual } from "radash";
import { z } from "zod";

View File

@ -124,63 +124,63 @@ const WorkflowList = () => {
fixed: "right",
width: 64,
render: (_, record) => (
<div
className="flex items-center justify-end"
onClick={(e) => {
e.stopPropagation();
<Dropdown
menu={{
items: [
{
key: "edit",
label: t("workflow.action.edit.button"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />
</span>
),
onClick: () => {
handleRecordDetailClick(record);
},
},
{
key: "duplicate",
label: t("workflow.action.duplicate.button"),
icon: (
<span className="anticon scale-125">
<IconCopy size="1em" />
</span>
),
onClick: () => {
handleRecordDuplicateClick(record);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("workflow.action.delete.button"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: () => {
handleRecordDeleteClick(record);
},
},
],
}}
trigger={["click"]}
>
<Dropdown
menu={{
items: [
{
key: "edit",
label: t("workflow.action.edit.button"),
icon: (
<span className="anticon scale-125">
<IconEdit size="1em" />
</span>
),
onClick: () => {
handleRecordDetailClick(record);
},
},
{
key: "duplicate",
label: t("workflow.action.duplicate.button"),
icon: (
<span className="anticon scale-125">
<IconCopy size="1em" />
</span>
),
onClick: () => {
handleRecordDuplicateClick(record);
},
},
{
type: "divider",
},
{
key: "delete",
label: t("workflow.action.delete.button"),
danger: true,
icon: (
<span className="anticon scale-125">
<IconTrash size="1em" />
</span>
),
onClick: () => {
handleRecordDeleteClick(record);
},
},
],
}}
trigger={["click"]}
>
<Button icon={<IconDotsVertical size="1.25em" />} type="text" />
</Dropdown>
</div>
<Button icon={<IconDotsVertical size="1.25em" />} type="text" />
</Dropdown>
),
onCell: () => {
return {
onClick: (e) => {
e.stopPropagation();
},
};
},
},
];
const tableRowSelection: TableProps<WorkflowModel>["rowSelection"] = {
@ -462,7 +462,7 @@ const WorkflowList = () => {
) : (
<Empty
title={t("workflow.nodata.title")}
description={getErrMsg(loadedError ?? t("workflow.nodata.description"))}
description={loadedError ? getErrMsg(loadedError) : t("workflow.nodata.description")}
icon={<IconHierarchy3 size={24} />}
extra={
loadedError ? (