feat(ui): new CertificateList page

This commit is contained in:
Fu Diwei
2025-07-20 20:43:23 +08:00
parent 676861b06d
commit d8e7659088
18 changed files with 417 additions and 306 deletions
+4 -1
View File
@@ -70,12 +70,15 @@ const RootApp = () => {
Layout: {
...antdTheme?.components?.Layout,
bodyBg: "transparent",
siderBg: "transparent",
headerBg: "var(--color-container)",
siderBg: "transparent",
},
Table: {
...antdTheme?.components?.Table,
bodySortBg: "var(--color-container)",
headerBg: "var(--color-container)",
headerSortActiveBg: "var(--color-container)",
headerSortHoverBg: "var(--color-container-hover)",
rowHoverBg: "var(--color-container-hover)",
},
},
+66
View File
@@ -0,0 +1,66 @@
import { useTranslation } from "react-i18next";
import { Typography, theme } from "antd";
import Show from "./Show";
export type EmptyProps = {
className?: string;
style?: React.CSSProperties;
title?: React.ReactNode;
description?: React.ReactNode;
extra?: React.ReactNode;
icon?: React.ReactNode;
};
const Empty = (props: EmptyProps) => {
const { t } = useTranslation();
const { className, style, title = t("common.text.nodata"), description, extra, icon } = props;
const { token: themeToken } = theme.useToken();
const isPrimitive = (node: React.ReactNode): node is string | number | boolean | null => {
return typeof node === "string" || typeof node === "number" || typeof node === "boolean" || node == null;
};
return (
<div className={className} style={style}>
<div className="relative w-full overflow-hidden">
<div className="relative top-0 left-0 z-1 flex h-full w-full py-4">
<div className="relative w-full max-w-lg">
<div className="flex flex-col gap-2 text-center">
<Show when={!!icon}>
<div className="mx-auto">
<div className="flex size-12 items-center justify-center overflow-hidden rounded-md border text-gray-600 shadow-sm dark:text-gray-200">
{icon}
</div>
</div>
</Show>
<div className="my-2">
<Show when={!!title}>
{isPrimitive(title) ? (
<Typography.Title level={4}>{title}</Typography.Title>
) : (
<h4 style={{ color: themeToken.colorTextHeading }}>{title}</h4>
)}
</Show>
<Show when={!!description}>
{isPrimitive(description) ? (
<Typography.Text type="secondary">{description}</Typography.Text>
) : (
<div style={{ color: themeToken.colorTextSecondary }}>{description}</div>
)}
</Show>
</div>
<Show when={!!extra}>
<div>{extra}</div>
</Show>
</div>
</div>
</div>
</div>
</div>
);
};
export default Empty;
+3 -3
View File
@@ -31,6 +31,9 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const [modalApi, ModelContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const tableColumns: TableProps<WorkflowRunModel>["columns"] = [
{
key: "$index",
@@ -146,9 +149,6 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const {
loading,
error: loadedError,
+1 -1
View File
@@ -10,7 +10,7 @@
"access.action.edit": "Edit credential",
"access.action.duplicate": "Duplicate credential",
"access.action.delete": "Delete credential",
"access.action.delete.confirm": "Are you sure want to delete \"{{name}}\" credential?<br>Please be aware that this action is irreversible.",
"access.action.delete.confirm": "Are you sure want to delete this \"{{name}}\" credential?<br>This action cannot be undone.",
"access.props.name": "Name",
"access.props.provider": "Provider",
+8 -5
View File
@@ -2,23 +2,26 @@
"certificate.page.title": "Certificates",
"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": "No certificates. Please create a workflow to generate certificates! 😀",
"certificate.nodata.title": "No Certificates",
"certificate.nodata.description": "Create a workflow to generate certificates!",
"certificate.nodata.button": "Create workflow",
"certificate.search.placeholder": "Search by certificate name or serial number ...",
"certificate.action.view": "View certificate",
"certificate.action.delete": "Delete certificate",
"certificate.action.delete.confirm": "Are you sure want to delete \"{{name}}\" certificate?<br>Please be aware that this action is irreversible.",
"certificate.action.delete.confirm": "Are you sure want to delete this \"{{name}}\" certificate?<br>This action cannot be undone.",
"certificate.action.download": "Download certificate",
"certificate.props.subject_alt_names": "Name",
"certificate.props.validity": "Expiry",
"certificate.props.validity.left_days": "{{left}} / {{total}} days left",
"certificate.props.validity.less_than_day": "Expire soon ({{hours}} hours left)",
"certificate.props.validity.less_than_a_day": "Less than 1 day",
"certificate.props.validity.expired": "Expired",
"certificate.props.validity.expiration": "Expire on {{date}}",
"certificate.props.validity.filter.expire_soon": "Expire soon",
"certificate.props.validity.filter.expired": "Expired",
"certificate.props.validity.filters.all": "All",
"certificate.props.validity.filters.expire_soon": "Expire soon",
"certificate.props.validity.filters.expired": "Expired",
"certificate.props.brand": "Brand",
"certificate.props.source": "Source",
"certificate.props.source.workflow": "Workflow",
+1
View File
@@ -7,6 +7,7 @@
"common.button.edit": "Edit",
"common.button.more": "More",
"common.button.ok": "Ok",
"common.button.reload": "Reload",
"common.button.reset": "Reset",
"common.button.save": "Save changes",
"common.button.submit": "Submit",
+1 -1
View File
@@ -5,7 +5,7 @@
"dashboard.statistics.expire_soon_certificates": "Expire soon certificates",
"dashboard.statistics.expired_certificates": "Expired certificates",
"dashboard.statistics.all_workflows": "All workflows",
"dashboard.statistics.enabled_workflows": "Enabled workflows",
"dashboard.statistics.enabled_workflows": "Active workflows",
"dashboard.statistics.unit": "",
"dashboard.latest_workflow_runs": "Latest workflow runs",
+3 -3
View File
@@ -11,7 +11,7 @@
"workflow.action.duplicate": "Duplicate workflow",
"workflow.action.duplicate.confirm": "Are you sure to duplicate this workflow?",
"workflow.action.delete": "Delete workflow",
"workflow.action.delete.confirm": "Are you sure want to delete \"{{name}}\" workflow?<br>Please be aware that this action is irreversible.",
"workflow.action.delete.confirm": "Are you sure want to delete this \"{{name}}\" workflow?<br>This action cannot be undone.",
"workflow.action.enable": "Enable",
"workflow.action.enable.failed.uncompleted": "Please complete the orchestration and publish the changes first",
"workflow.action.disable": "Disable",
@@ -23,8 +23,8 @@
"workflow.props.trigger.manual": "Manual",
"workflow.props.last_run_at": "Last run at",
"workflow.props.state": "State",
"workflow.props.state.filter.enabled": "Enabled",
"workflow.props.state.filter.disabled": "Disabled",
"workflow.props.state.filters.enabled": "Active",
"workflow.props.state.filters.disabled": "Inactive",
"workflow.props.created_at": "Created at",
"workflow.props.updated_at": "Updated at",
@@ -3,7 +3,7 @@
"workflow_run.action.cancel": "Cancel run",
"workflow_run.action.cancel.confirm": "Are you sure to cancel this run?",
"workflow_run.action.delete": "Delete run",
"workflow_run.action.delete.confirm": "Are you sure want to delete \"{{name}}\" workflow run?<br>Please be aware that this action is irreversible.",
"workflow_run.action.delete.confirm": "Are you sure want to delete this \"{{name}}\" workflow run?<br>This action cannot be undone.",
"workflow_run.table.alert": "Attention: 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.",
+9 -6
View File
@@ -2,7 +2,9 @@
"certificate.page.title": "证书管理",
"certificate.page.subtitle": "SSL 证书含有网站的公钥和网站标识以及其他相关信息。它们来自于工作流的执行输出。",
"certificate.nodata": "暂无证书,新建一个工作流去生成证书吧~ 😀",
"certificate.nodata.title": "暂无证书",
"certificate.nodata.description": "新建一个工作流去生成证书吧~",
"certificate.nodata.button": "新建工作流",
"certificate.search.placeholder": "按证书名称或序列号搜索……",
@@ -14,11 +16,12 @@
"certificate.props.subject_alt_names": "名称",
"certificate.props.validity": "有效期限",
"certificate.props.validity.left_days": "{{left}} / {{total}} 天",
"certificate.props.validity.less_than_day": "即将过期(剩余 {{hours}} 小时)",
"certificate.props.validity.expired": "已期",
"certificate.props.validity.expiration": "{{date}} 期",
"certificate.props.validity.filter.expire_soon": "即将到期",
"certificate.props.validity.filter.expired": "已到期",
"certificate.props.validity.less_than_a_day": "不足 1 天",
"certificate.props.validity.expired": "已期",
"certificate.props.validity.expiration": "{{date}} 期",
"certificate.props.validity.filters.all": "全部",
"certificate.props.validity.filters.expire_soon": "即将过期",
"certificate.props.validity.filters.expired": "已过期",
"certificate.props.brand": "证书品牌",
"certificate.props.source": "来源",
"certificate.props.source.workflow": "工作流",
+1
View File
@@ -7,6 +7,7 @@
"common.button.edit": "编辑",
"common.button.more": "更多",
"common.button.ok": "确定",
"common.button.reload": "重新加载",
"common.button.reset": "重置",
"common.button.save": "保存更改",
"common.button.submit": "提交",
+2 -2
View File
@@ -23,8 +23,8 @@
"workflow.props.trigger.manual": "手动",
"workflow.props.last_run_at": "最近执行时间",
"workflow.props.state": "启用状态",
"workflow.props.state.filter.enabled": "启用",
"workflow.props.state.filter.disabled": "未启用",
"workflow.props.state.filters.enabled": "启用",
"workflow.props.state.filters.disabled": "未启用",
"workflow.props.created_at": "创建时间",
"workflow.props.updated_at": "更新时间",
+12 -13
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { IconCopy, IconEdit, IconPlus, IconReload, IconTrash } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { App, Avatar, Button, Empty, Input, Space, Table, type TableProps, Tabs, Tooltip, Typography } from "antd";
import { App, Avatar, Button, Empty, Input, Table, type TableProps, Tabs, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
@@ -27,6 +27,15 @@ const AccessList = () => {
useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses", "deleteAccess"])
);
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
usage: "both-dns-hosting" satisfies AccessUsageProp,
keyword: searchParams.get("keyword"),
};
});
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 15);
const tableColumns: TableProps<AccessModel>["columns"] = [
{
key: "$index",
@@ -76,7 +85,7 @@ const AccessList = () => {
fixed: "right",
width: 120,
render: (_, record) => (
<Space.Compact>
<div className="flex items-center justify-end">
<AccessEditDrawer
data={record}
usage={filters["usage"] as AccessUsageProp}
@@ -109,23 +118,13 @@ const AccessList = () => {
}}
/>
</Tooltip>
</Space.Compact>
</div>
),
},
];
const [tableData, setTableData] = useState<AccessModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
usage: "both-dns-hosting" satisfies AccessUsageProp,
keyword: searchParams.get("keyword"),
};
});
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 15);
useEffect(() => {
fetchAccesses().catch((err) => {
if (err instanceof ClientResponseError && err.isAbort) {
+141 -128
View File
@@ -1,14 +1,15 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { IconBrowserShare, IconReload, IconTrash } from "@tabler/icons-react";
import { IconBrowserShare, IconCirclePlus, IconReload, IconShieldCheckeredFilled, IconTrash } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { App, Button, Divider, Empty, Input, Menu, type MenuProps, Radio, Space, Table, type TableProps, Tooltip, Typography, theme } from "antd";
import { App, Button, Input, Segmented, Skeleton, Table, type TableProps, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer";
import { CERTIFICATE_SOURCES, type CertificateModel } from "@/domain/certificate";
import Empty from "@/components/Empty";
import { type CertificateModel } from "@/domain/certificate";
import { list as listCertificates, type ListRequest as listCertificatesRequest, remove as removeCertificate } from "@/repository/certificate";
import { getErrMsg } from "@/utils/error";
@@ -19,16 +20,20 @@ const CertificateList = () => {
const { t } = useTranslation();
const { modal, notification } = App.useApp();
const { token: themeToken } = theme.useToken();
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
keyword: searchParams.get("keyword"),
state: searchParams.get("state"),
};
});
const [sorter, setSorter] = useState<ArrayElement<Parameters<NonNullable<TableProps<CertificateModel>["onChange"]>>[2]>>(() => {
return {};
});
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 15);
const tableColumns: TableProps<CertificateModel>["columns"] = [
{
key: "$index",
align: "center",
fixed: "left",
width: 50,
render: (_, __, index) => (page - 1) * pageSize + index + 1,
},
{
key: "name",
title: t("certificate.props.subject_alt_names"),
@@ -38,77 +43,38 @@ const CertificateList = () => {
key: "expiry",
title: t("certificate.props.validity"),
ellipsis: true,
defaultFilteredValue: searchParams.has("state") ? [searchParams.get("state") as string] : undefined,
filterDropdown: ({ setSelectedKeys, confirm, clearFilters }) => {
const items: Required<MenuProps>["items"] = [
["expireSoon", "certificate.props.validity.filter.expire_soon"],
["expired", "certificate.props.validity.filter.expired"],
].map(([key, label]) => {
return {
key,
label: <Radio checked={filters["state"] === key}>{t(label)}</Radio>,
onClick: () => {
if (filters["state"] !== key) {
setPage(1);
setFilters((prev) => ({ ...prev, state: key }));
setSelectedKeys([key]);
}
confirm({ closeDropdown: true });
},
};
});
const handleResetClick = () => {
setPage(1);
setFilters((prev) => ({ ...prev, state: undefined }));
setSelectedKeys([]);
clearFilters?.();
confirm();
};
const handleConfirmClick = () => {
confirm();
};
return (
<div style={{ padding: 0 }}>
<Menu items={items} selectable={false} />
<Divider className="my-0" />
<Space className="w-full justify-end" style={{ padding: themeToken.paddingSM }}>
<Button size="small" disabled={!filters.state} onClick={handleResetClick}>
{t("common.button.reset")}
</Button>
<Button type="primary" size="small" onClick={handleConfirmClick}>
{t("common.button.ok")}
</Button>
</Space>
</div>
);
},
sorter: true,
sortOrder: sorter.columnKey === "expiry" ? sorter.order : undefined,
render: (_, record) => {
const total = dayjs(record.expireAt).diff(dayjs(record.created), "d") + 1;
// 使用 isAfter 更精确地判断是否过期
const isExpired = dayjs().isAfter(dayjs(record.expireAt));
const expired = dayjs().isAfter(dayjs(record.expireAt));
const leftDays = dayjs(record.expireAt).diff(dayjs(), "d");
const leftHours = dayjs(record.expireAt).diff(dayjs(), "h");
return (
<Space className="max-w-full" direction="vertical" size={4}>
{!isExpired ? (
leftDays > 0 ? (
<Typography.Text type="success">{t("certificate.props.validity.left_days", { left: leftDays, total })}</Typography.Text>
<div className="flex max-w-full flex-col gap-1">
{!expired ? (
leftDays >= 1 ? (
<Typography.Text type="success">
<span className="mr-1 inline-block size-2 rounded-full bg-success leading-2">&nbsp;</span>
{t("certificate.props.validity.left_days", { left: leftDays, total })}
</Typography.Text>
) : (
<Typography.Text type="warning">{t("certificate.props.validity.less_than_day", { hours: leftHours > 0 ? leftHours : 1 })}</Typography.Text>
<Typography.Text type="warning">
<span className="mr-1 inline-block size-2 rounded-full bg-warning leading-2">&nbsp;</span>
{t("certificate.props.validity.less_than_a_day")}
</Typography.Text>
)
) : (
<Typography.Text type="danger">{t("certificate.props.validity.expired")}</Typography.Text>
<Typography.Text type="danger">
<span className="mr-1 inline-block size-2 rounded-full bg-error leading-2">&nbsp;</span>
{t("certificate.props.validity.expired")}
</Typography.Text>
)}
<Typography.Text type="secondary">
{t("certificate.props.validity.expiration", { date: dayjs(record.expireAt).format("YYYY-MM-DD") })}
</Typography.Text>
</Space>
</div>
);
},
},
@@ -116,10 +82,10 @@ const CertificateList = () => {
key: "brand",
title: t("certificate.props.brand"),
render: (_, record) => (
<Space className="max-w-full" direction="vertical" size={4}>
<div className="flex max-w-full flex-col gap-1">
<Typography.Text>{record.issuerOrg}</Typography.Text>
<Typography.Text>{record.keyAlgorithm}</Typography.Text>
</Space>
</div>
),
},
{
@@ -127,29 +93,24 @@ const CertificateList = () => {
title: t("certificate.props.source"),
ellipsis: true,
render: (_, record) => {
if (record.source === CERTIFICATE_SOURCES.WORKFLOW) {
const workflowId = record.workflowId;
return (
<Space className="max-w-full" direction="vertical" size={4}>
<Typography.Text>{t("certificate.props.source.workflow")}</Typography.Text>
<Typography.Link
type="secondary"
ellipsis
onClick={() => {
if (workflowId) {
navigate(`/workflows/${workflowId}`);
}
}}
>
{record.expand?.workflowId?.name ?? <span className="font-mono">{t(`#${workflowId}`)}</span>}
</Typography.Link>
</Space>
);
} else if (record.source === CERTIFICATE_SOURCES.UPLOAD) {
return <Typography.Text>{t("certificate.props.source.upload")}</Typography.Text>;
}
return <></>;
const workflowId = record.workflowId;
return (
<div className="flex max-w-full flex-col gap-1">
<Typography.Text>{t(`certificate.props.source.${record.source}`)}</Typography.Text>
<Typography.Link
type="secondary"
ellipsis
onClick={(e) => {
e.stopPropagation();
if (workflowId) {
navigate(`/workflows/${workflowId}`);
}
}}
>
{record.expand?.workflowId?.name ?? <span className="font-mono">{t(`#${workflowId}`)}</span>}
</Typography.Link>
</div>
);
},
},
{
@@ -160,65 +121,63 @@ const CertificateList = () => {
return dayjs(record.created!).format("YYYY-MM-DD HH:mm:ss");
},
},
{
key: "updatedAt",
title: t("certificate.props.updated_at"),
ellipsis: true,
render: (_, record) => {
return dayjs(record.updated!).format("YYYY-MM-DD HH:mm:ss");
},
},
{
key: "$action",
align: "end",
fixed: "right",
width: 120,
render: (_, record) => (
<Space.Compact>
<CertificateDetailDrawer
data={record}
trigger={
<Tooltip title={t("certificate.action.view")}>
<Button color="primary" icon={<IconBrowserShare size="1.25em" />} variant="text" />
</Tooltip>
}
/>
<Tooltip title={t("certificate.action.delete")}>
<Button color="danger" icon={<IconTrash size="1.25em" />} variant="text" onClick={() => handleDeleteClick(record)} />
<div className="flex items-center justify-end">
<Tooltip title={t("certificate.action.view")}>
<Button
color="primary"
icon={<IconBrowserShare size="1.25em" />}
variant="text"
onClick={(e) => {
e.stopPropagation();
handleRecordDetailClick(record);
}}
/>
</Tooltip>
</Space.Compact>
<Tooltip title={t("certificate.action.delete")}>
<Button
color="danger"
icon={<IconTrash size="1.25em" />}
variant="text"
onClick={(e) => {
e.stopPropagation();
handleRecordDeleteClick(record);
}}
/>
</Tooltip>
</div>
),
},
];
const [tableData, setTableData] = useState<CertificateModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
keyword: searchParams.get("keyword"),
state: searchParams.get("state"),
};
});
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 15);
const {
loading,
error: loadedError,
run: refreshData,
} = useRequest(
() => {
let sort: string | undefined;
if (sorter.columnKey === "expiry") {
sort = sorter.order === "ascend" ? "expireAt" : sorter.order === "descend" ? "-expireAt" : "";
}
return listCertificates({
keyword: filters["keyword"] as string,
state: filters["state"] as listCertificatesRequest["state"],
sort: sort,
page: page,
perPage: pageSize,
});
},
{
refreshDeps: [filters, page, pageSize],
refreshDeps: [filters, sorter, page, pageSize],
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
@@ -247,7 +206,15 @@ const CertificateList = () => {
refreshData();
};
const handleDeleteClick = (certificate: CertificateModel) => {
const [detailRecord, setDetailRecord] = useState<CertificateModel>();
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const handleRecordDetailClick = (record: CertificateModel) => {
setDetailRecord(record);
setDetailOpen(true);
};
const handleRecordDeleteClick = (certificate: CertificateModel) => {
modal.confirm({
title: <span className="text-error">{t("certificate.action.delete")}</span>,
content: <span dangerouslySetInnerHTML={{ __html: t("certificate.action.delete.confirm", { name: certificate.subjectAltNames }) }} />,
@@ -281,6 +248,22 @@ const CertificateList = () => {
<div className="flex items-center justify-between gap-x-2 gap-y-3 not-md:flex-col-reverse not-md:items-start not-md:justify-normal">
<div className="flex w-full flex-1 items-center gap-x-2 md:max-w-200">
<div>
<Segmented
className="shadow-xs"
options={[
{ label: <span className="text-sm">{t("certificate.props.validity.filters.all")}</span>, value: "" },
{ label: <span className="text-sm">{t("certificate.props.validity.filters.expire_soon")}</span>, value: "expireSoon" },
{ label: <span className="text-sm">{t("certificate.props.validity.filters.expired")}</span>, value: "expired" },
]}
size="large"
value={(filters["state"] as string) || ""}
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, state: value }));
}}
/>
</div>
<div className="flex-1">
<Input.Search
className="text-sm placeholder:text-sm"
@@ -304,7 +287,26 @@ const CertificateList = () => {
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("certificate.nodata"))} />,
emptyText: loading ? (
<Skeleton />
) : (
<Empty
title={t("certificate.nodata.title")}
description={getErrMsg(loadedError ?? t("certificate.nodata.description"))}
icon={<IconShieldCheckeredFilled size={24} />}
extra={
loadedError ? (
<Button icon={<IconReload size="1.25em" />} type="primary" onClick={handleReloadClick}>
{t("common.button.reload")}
</Button>
) : (
<Button icon={<IconCirclePlus size="1.25em" />} type="primary" onClick={() => navigate("/workflows/new")}>
{t("workflow.action.create")}
</Button>
)
}
/>
),
}}
pagination={{
current: page,
@@ -320,9 +322,20 @@ const CertificateList = () => {
setPageSize(pageSize);
},
}}
rowClassName="cursor-pointer"
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
onChange={(_, __, sorter) => {
setSorter(Array.isArray(sorter) ? sorter[0] : sorter);
}}
onRow={(record) => ({
onClick: () => {
handleRecordDetailClick(record);
},
})}
/>
<CertificateDetailDrawer data={detailRecord} open={detailOpen} onOpenChange={setDetailOpen} />
</div>
</div>
);
+141 -122
View File
@@ -30,9 +30,78 @@ const Dashboard = () => {
const { t } = useTranslation();
const breakpoints = Grid.useBreakpoint();
return (
<div className="px-6 py-4">
<div className="mx-auto max-w-320">
<h1>{t("dashboard.page.title")}</h1>
<StatisticCards />
<Divider />
<Flex justify="stretch" vertical={!breakpoints.lg} gap={16}>
<Card className="max-lg:flex-1 lg:w-[360px]" title={t("dashboard.quick_actions")}>
<Space className="w-full" direction="vertical" size="large">
<Button block type="primary" size="large" icon={<IconPlus 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")}>
{t("dashboard.quick_actions.change_login_password")}
</Button>
<Button block size="large" icon={<IconPlugConnected size="1.25em" />} onClick={() => navigate("/settings/ssl-provider")}>
{t("dashboard.quick_actions.configure_ca")}
</Button>
</Space>
</Card>
<Card className="flex-1" title={t("dashboard.latest_workflow_runs")}>
<WorkflowRunHistoryTable />
</Card>
</Flex>
</div>
</div>
);
};
const StatisticCard = ({
label,
loading,
icon,
value,
suffix,
onClick,
}: {
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>}
/>
</div>
</Card>
);
};
const StatisticCards = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { notification } = App.useApp();
const { token: themeToken } = theme.useToken();
const breakpoints = Grid.useBreakpoint();
const statisticsGridSpans = {
xs: { flex: "50%" },
@@ -63,6 +132,65 @@ const Dashboard = () => {
}
);
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={<IconSchema 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>
);
};
const WorkflowRunHistoryTable = () => {
const { t } = useTranslation();
const tableColumns: TableProps<WorkflowRunModel>["columns"] = [
{
key: "$index",
@@ -161,127 +289,18 @@ const Dashboard = () => {
);
return (
<div className="px-6 py-4">
<div className="mx-auto max-w-320">
<h1>{t("dashboard.page.title")}</h1>
<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={<IconSchema 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>
<Divider />
<Flex justify="stretch" vertical={!breakpoints.lg} gap={16}>
<Card className="max-lg:flex-1 lg:w-[360px]" title={t("dashboard.quick_actions")}>
<Space className="w-full" direction="vertical" size="large">
<Button block type="primary" size="large" icon={<IconPlus 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")}>
{t("dashboard.quick_actions.change_login_password")}
</Button>
<Button block size="large" icon={<IconPlugConnected size="1.25em" />} onClick={() => navigate("/settings/ssl-provider")}>
{t("dashboard.quick_actions.configure_ca")}
</Button>
</Space>
</Card>
<Card className="flex-1" title={t("dashboard.latest_workflow_runs")}>
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={tableLoading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
pagination={false}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 720px)" }}
size="small"
/>
</Card>
</Flex>
</div>
</div>
);
};
const StatisticCard = ({
label,
loading,
icon,
value,
suffix,
onClick,
}: {
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>}
/>
</div>
</Card>
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={tableLoading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
pagination={false}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 720px)" }}
size="small"
/>
);
};
+17 -19
View File
@@ -9,7 +9,6 @@ import { ClientResponseError } from "pocketbase";
import WorkflowStatusIcon from "@/components/workflow/WorkflowStatusIcon";
import { WORKFLOW_TRIGGERS, type WorkflowModel, cloneNode, initWorkflow, isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun";
import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error";
@@ -22,6 +21,15 @@ const WorkflowList = () => {
const { message, modal, notification } = App.useApp();
const { token: themeToken } = theme.useToken();
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
keyword: searchParams.get("keyword"),
state: searchParams.get("state"),
};
});
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 15);
const tableColumns: TableProps<WorkflowModel>["columns"] = [
{
key: "$index",
@@ -69,8 +77,8 @@ const WorkflowList = () => {
defaultFilteredValue: searchParams.has("state") ? [searchParams.get("state") as string] : undefined,
filterDropdown: ({ setSelectedKeys, confirm, clearFilters }) => {
const items: Required<MenuProps>["items"] = [
["enabled", "workflow.props.state.filter.enabled"],
["disabled", "workflow.props.state.filter.disabled"],
["enabled", "workflow.props.state.filters.enabled"],
["disabled", "workflow.props.state.filters.disabled"],
].map(([key, label]) => {
return {
key,
@@ -120,7 +128,7 @@ const WorkflowList = () => {
<Switch
checked={enabled}
onChange={() => {
handleEnabledChange(record);
handleRecordActiveChange(record);
}}
/>
);
@@ -178,7 +186,7 @@ const WorkflowList = () => {
icon={<IconCopy size="1.25em" />}
variant="text"
onClick={() => {
handleDuplicateClick(record);
handleRecordDuplicateClick(record);
}}
/>
</Tooltip>
@@ -190,7 +198,7 @@ const WorkflowList = () => {
icon={<IconTrash size="1.25em" />}
variant="text"
onClick={() => {
handleDeleteClick(record);
handleRecordDeleteClick(record);
}}
/>
</Tooltip>
@@ -201,16 +209,6 @@ const WorkflowList = () => {
const [tableData, setTableData] = useState<WorkflowModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
keyword: searchParams.get("keyword"),
state: searchParams.get("state"),
};
});
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 15);
const {
loading,
error: loadedError,
@@ -258,7 +256,7 @@ const WorkflowList = () => {
refreshData();
};
const handleEnabledChange = async (workflow: WorkflowModel) => {
const handleRecordActiveChange = async (workflow: WorkflowModel) => {
try {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
message.warning(t("workflow.action.enable.failed.uncompleted"));
@@ -285,7 +283,7 @@ const WorkflowList = () => {
}
};
const handleDuplicateClick = (workflow: WorkflowModel) => {
const handleRecordDuplicateClick = (workflow: WorkflowModel) => {
modal.confirm({
title: t("workflow.action.duplicate"),
content: t("workflow.action.duplicate.confirm"),
@@ -315,7 +313,7 @@ const WorkflowList = () => {
});
};
const handleDeleteClick = (workflow: WorkflowModel) => {
const handleRecordDeleteClick = (workflow: WorkflowModel) => {
modal.confirm({
title: <span className="text-error">{t("workflow.action.delete")}</span>,
content: <span dangerouslySetInnerHTML={{ __html: t("workflow.action.delete.confirm", { name: workflow.name }) }} />,
+4 -1
View File
@@ -6,6 +6,7 @@ import { COLLECTION_NAME_CERTIFICATE, getPocketBase } from "./_pocketbase";
export type ListRequest = {
keyword?: string;
state?: "expireSoon" | "expired";
sort?: string;
page?: number;
perPage?: number;
};
@@ -23,12 +24,14 @@ export const list = async (request: ListRequest) => {
filters.push(pb.filter("expireAt<={:expiredAt}", { expiredAt: new Date() }));
}
const sort = request.sort || "-created";
const page = request.page || 1;
const perPage = request.perPage || 10;
return pb.collection(COLLECTION_NAME_CERTIFICATE).getList<CertificateModel>(page, perPage, {
expand: "workflowId",
filter: filters.join(" && "),
sort: "-created",
sort: sort,
requestKey: null,
});
};
+2
View File
@@ -2,6 +2,8 @@
type Nullish<T> = {
[P in keyof T]?: T[P] | null | undefined;
};
type ArrayElement<T> = T extends (infer U)[] ? U : never;
}
export {};