From 6e2c719374e0365de4a25b095548532afb89a0bd Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 27 Aug 2025 20:31:17 +0800 Subject: [PATCH] feat: diagnostics logs & crons --- ui/src/components/access/AccessSelect.tsx | 4 +- .../forms/AccessConfigFieldsProviderSSH.tsx | 4 +- .../provider/DeploymentProviderPicker.tsx | 4 +- .../components/workflow/WorkflowRunDetail.tsx | 24 +- .../designer/forms/BizApplyNodeConfigForm.tsx | 4 +- ui/src/i18n/locales/en/nls.settings.json | 8 +- ui/src/i18n/locales/zh/nls.settings.json | 8 +- ui/src/pages/accesses/AccessList.tsx | 25 +- ui/src/pages/certificates/CertificateList.tsx | 6 +- ui/src/pages/dashboard/Dashboard.tsx | 6 +- ui/src/pages/settings/Settings.tsx | 1 + ui/src/pages/settings/SettingsAppearance.tsx | 4 +- ui/src/pages/settings/SettingsDiagnostics.tsx | 268 ++++++++++++++++++ ui/src/pages/workflows/WorkflowDetailRuns.tsx | 4 +- ui/src/pages/workflows/WorkflowList.tsx | 6 +- ui/src/repository/system.ts | 44 +++ ui/src/routers/index.tsx | 5 + 17 files changed, 374 insertions(+), 51 deletions(-) create mode 100644 ui/src/pages/settings/SettingsDiagnostics.tsx create mode 100644 ui/src/repository/system.ts diff --git a/ui/src/components/access/AccessSelect.tsx b/ui/src/components/access/AccessSelect.tsx index cf028db9e..eef8d5df0 100644 --- a/ui/src/components/access/AccessSelect.tsx +++ b/ui/src/components/access/AccessSelect.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useAsyncEffect } from "ahooks"; +import { useMount } from "ahooks"; import { Avatar, Select, type SelectProps, Typography, theme } from "antd"; import { type AccessModel } from "@/domain/access"; @@ -16,7 +16,7 @@ const AccessSelect = ({ onFilter, ...props }: AccessTypeSelectProps) => { const { token: themeToken } = theme.useToken(); const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"])); - useAsyncEffect(() => fetchAccesses(false), []); + useMount(() => fetchAccesses(false)); const [options, setOptions] = useState>([]); useEffect(() => { diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx index 28c1e4bcf..3f32228a7 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx @@ -65,13 +65,13 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) - + - + fetchAccesses(false), []); + useMount(() => fetchAccesses(false)); const wrapperRef = useRef(null); const wrapperSize = useSize(wrapperRef); diff --git a/ui/src/components/workflow/WorkflowRunDetail.tsx b/ui/src/components/workflow/WorkflowRunDetail.tsx index e5a6780f5..17bda5695 100644 --- a/ui/src/components/workflow/WorkflowRunDetail.tsx +++ b/ui/src/components/workflow/WorkflowRunDetail.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { EditorState, FlowLayoutDefault } from "@flowgram.ai/fixed-layout-editor"; import { IconBrowserShare, IconCheck, IconDots, IconDownload, IconSettings2, IconTransferOut } from "@tabler/icons-react"; import { useRequest } from "ahooks"; -import { Alert, App, Button, Card, Divider, Dropdown, Empty, Skeleton, Spin, Table, type TableProps, Tooltip, Typography, theme } from "antd"; +import { Alert, App, Button, Card, Divider, Dropdown, Empty, Skeleton, Table, type TableProps, Tooltip, Typography, theme } from "antd"; import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; @@ -197,7 +197,7 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin const [showTimestamp, setShowTimestamp] = useState(true); const [showWhitespace, setShowWhitespace] = useState(true); - const renderRecord = (record: Log) => { + const renderLogRecord = (record: Log) => { let message = <>{record.message}; if (record.data != null && Object.keys(record.data).length > 0) { message = ( @@ -215,18 +215,19 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin return (
- {showTimestamp ?
[{dayjs(record.timestamp).format("YYYY-MM-DD HH:mm:ss")}]
: <>} + {showTimestamp ?
[{dayjs(record.timestamp).format("YYYY-MM-DD HH:mm:ss")}]
: <>}
{message} @@ -308,22 +309,15 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin
- 0} - fallback={ - - - - } - > + 0} fallback={}> {listData.map((group) => { return ( -
+
{`#${group.id}\u00A0`} {group.name}
-
{group.records.map((record) => renderRecord(record))}
+
{group.records.map((record) => renderLogRecord(record))}
); })} diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index 88a5ae759..69922e02d 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -3,7 +3,7 @@ import { getI18n, useTranslation } from "react-i18next"; import { Link } from "react-router"; import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor"; import { IconChevronRight, IconCircleMinus, IconPlus } from "@tabler/icons-react"; -import { useAsyncEffect, useControllableValue } from "ahooks"; +import { useControllableValue, useMount } from "ahooks"; import { type AnchorProps, AutoComplete, Button, Divider, Flex, Form, type FormInstance, Input, InputNumber, Select, Switch, Typography } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -456,7 +456,7 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const InternalEmailInput = memo( ({ disabled, placeholder, ...props }: { disabled?: boolean; placeholder?: string; value?: string; onChange?: (value: string) => void }) => { const { emails, fetchEmails, removeEmail } = useContactEmailsStore(); - useAsyncEffect(() => fetchEmails(false), []); + useMount(() => fetchEmails(false)); const [value, setValue] = useControllableValue(props, { valuePropName: "value", diff --git a/ui/src/i18n/locales/en/nls.settings.json b/ui/src/i18n/locales/en/nls.settings.json index b6a1dd978..78f0d83e5 100644 --- a/ui/src/i18n/locales/en/nls.settings.json +++ b/ui/src/i18n/locales/en/nls.settings.json @@ -46,5 +46,11 @@ "settings.persistence.form.expired_certificates_max_days.label": "Max days retention of expired certificates", "settings.persistence.form.expired_certificates_max_days.placeholder": "Please enter the maximum retention days of expired certificates", "settings.persistence.form.expired_certificates_max_days.unit": "days", - "settings.persistence.form.expired_certificates_max_days.help": "Set to 0 to disable cleanup expired certificates." + "settings.persistence.form.expired_certificates_max_days.help": "Set to 0 to disable cleanup expired certificates.", + + "settings.diagnostics.tab": "Diagnostics", + "settings.diagnostics.logs.title": "System logs", + "settings.diagnostics.crons.title": "CRON jobs", + "settings.diagnostics.crons.job.next_trigger_time": "Expected next execution time: ", + "settings.diagnostics.workflow_dispatcher.title": "Workflow dispatcher" } diff --git a/ui/src/i18n/locales/zh/nls.settings.json b/ui/src/i18n/locales/zh/nls.settings.json index d63fe6011..e804d0b92 100644 --- a/ui/src/i18n/locales/zh/nls.settings.json +++ b/ui/src/i18n/locales/zh/nls.settings.json @@ -46,5 +46,11 @@ "settings.persistence.form.expired_certificates_max_days.label": "证书过期后保留天数", "settings.persistence.form.expired_certificates_max_days.placeholder": "请输入过期证书保留天数", "settings.persistence.form.expired_certificates_max_days.unit": "天", - "settings.persistence.form.expired_certificates_max_days.help": "设置为 0 表示永久保留,不会自动清理。" + "settings.persistence.form.expired_certificates_max_days.help": "设置为 0 表示永久保留,不会自动清理。", + + "settings.diagnostics.tab": "系统诊断", + "settings.diagnostics.logs.title": "系统日志", + "settings.diagnostics.crons.title": "后台任务", + "settings.diagnostics.crons.job.next_trigger_time": "预计下次运行时间:", + "settings.diagnostics.workflow_dispatcher.title": "工作流调度器" } diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index 5df4c7301..09e1bbfa5 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { IconCirclePlus, IconCopy, IconDots, IconEdit, IconFingerprint, IconPlus, IconReload, IconTrash } from "@tabler/icons-react"; -import { useRequest } from "ahooks"; +import { useMount, useRequest } from "ahooks"; import { App, Avatar, Button, Dropdown, Input, Skeleton, Table, type TableProps, Tabs, Typography, theme } from "antd"; import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; @@ -35,6 +35,16 @@ const AccessList = () => { const { accesses, loadedAtOnce, fetchAccesses, deleteAccess } = useAccessesStore( useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses", "deleteAccess"]) ); + useMount(() => { + fetchAccesses().catch((err) => { + if (err instanceof ClientResponseError && err.isAbort) { + return; + } + + console.error(err); + notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + }); + }); const [filters, setFilters] = useState>(() => { return { @@ -187,17 +197,6 @@ const AccessList = () => { }, }; - useEffect(() => { - fetchAccesses().catch((err) => { - if (err instanceof ClientResponseError && err.isAbort) { - return; - } - - console.error(err); - notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); - }); - }, []); - const { loading, run: refreshData } = useRequest( () => { const startIndex = (page - 1) * pageSize; diff --git a/ui/src/pages/certificates/CertificateList.tsx b/ui/src/pages/certificates/CertificateList.tsx index eff45e204..f7c50fa0b 100644 --- a/ui/src/pages/certificates/CertificateList.tsx +++ b/ui/src/pages/certificates/CertificateList.tsx @@ -217,7 +217,7 @@ const CertificateList = () => { const { loading, - error: loadedError, + error: loadError, run: refreshData, } = useRequest( () => { @@ -413,10 +413,10 @@ const CertificateList = () => { } extra={ - loadedError ? ( + loadError ? ( diff --git a/ui/src/pages/dashboard/Dashboard.tsx b/ui/src/pages/dashboard/Dashboard.tsx index 7e322e992..03224cac2 100644 --- a/ui/src/pages/dashboard/Dashboard.tsx +++ b/ui/src/pages/dashboard/Dashboard.tsx @@ -324,7 +324,7 @@ const WorkflowRunHistoryTable = ({ className, style }: { className?: string; sty const { loading, - error: loadedError, + error: loadError, run: refreshData, } = useRequest( () => { @@ -379,10 +379,10 @@ const WorkflowRunHistoryTable = ({ className, style }: { className?: string; sty } extra={ - loadedError ? ( + loadError ? ( diff --git a/ui/src/pages/settings/Settings.tsx b/ui/src/pages/settings/Settings.tsx index b7e4ae522..96b02d29c 100644 --- a/ui/src/pages/settings/Settings.tsx +++ b/ui/src/pages/settings/Settings.tsx @@ -15,6 +15,7 @@ const Settings = () => { ["appearance", "settings.appearance.tab", ], ["ssl-provider", "settings.sslprovider.tab", ], ["persistence", "settings.persistence.tab", ], + ["diagnostics", "settings.diagnostics.tab", ], ] satisfies [string, string, React.ReactElement][]; const [menuKey, setMenuKey] = useState(() => location.pathname.split("/")[2]); useEffect(() => { diff --git a/ui/src/pages/settings/SettingsAppearance.tsx b/ui/src/pages/settings/SettingsAppearance.tsx index 9c29c7755..304724fa7 100644 --- a/ui/src/pages/settings/SettingsAppearance.tsx +++ b/ui/src/pages/settings/SettingsAppearance.tsx @@ -23,7 +23,7 @@ const SettingsAppearance = () => {

{t("settings.appearance.pagination.title")}

- + ); }; @@ -100,7 +100,7 @@ const SettingsAppearanceLanguage = ({ className, style }: { className?: string; ); }; -const SettingsPaginationLanguage = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { +const SettingsAppearancePagination = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { const { t } = useTranslation(); const { appSettings: globalAppSettings, setAppSettings: setGlobalAppSettings } = useAppSettings(); diff --git a/ui/src/pages/settings/SettingsDiagnostics.tsx b/ui/src/pages/settings/SettingsDiagnostics.tsx new file mode 100644 index 000000000..7e77278e3 --- /dev/null +++ b/ui/src/pages/settings/SettingsDiagnostics.tsx @@ -0,0 +1,268 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconReload } from "@tabler/icons-react"; +import { useRequest } from "ahooks"; +import { Button, Divider, Empty, List, Pagination, Tooltip, Typography } from "antd"; +import dayjs from "dayjs"; + +import Show from "@/components/Show"; +import { listCronJobs, listLogs } from "@/repository/system"; +import { getNextCronExecutions } from "@/utils/cron"; +import { mergeCls } from "@/utils/css"; +import { getErrMsg } from "@/utils/error"; + +const SettingsDiagnostics = () => { + const { t } = useTranslation(); + + return ( + <> +

{t("settings.diagnostics.logs.title")}

+ + + + +

{t("settings.diagnostics.crons.title")}

+ + + + +

{t("settings.diagnostics.workflow_dispatcher.title")}

+ + + ); +}; + +const SettingsDiagnosticsLogs = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { + const { t } = useTranslation(); + + const [page, setPage] = useState(1); + const [pageSize] = useState(10); + + type Log = Awaited>["items"][number]; + const [listData, setListData] = useState([]); + + const [hasMore, setHasMore] = useState(true); + + const { + loading, + error: loadError, + run: refreshData, + } = useRequest( + () => { + return listLogs({ page: page, perPage: pageSize }); + }, + { + refreshDeps: [page, pageSize], + onSuccess: (res) => { + if (page === 1) { + setListData([]); + } + + setListData((prev) => [...prev, ...res.items]); + setHasMore(res.items.length >= pageSize); + }, + } + ); + + const renderLogRecord = (record: Log) => { + let message = <>{record.message}; + if (record.data != null && Object.keys(record.data).length > 0) { + message = ( +
+ {record.message} + {Object.entries(record.data).map(([key, value]) => ( +
+
{key}:
+
{JSON.stringify(value)}
+
+ ))} +
+ ); + } + + enum LogLevel { + Info = 0, + Warn = 4, + Error = 8, + } + + return ( +
+
[{dayjs(record.created).format("YYYY-MM-DD HH:mm:ss")}]
+
+ {message} +
+
+ ); + }; + + const handleReloadClick = () => { + refreshData(); + }; + + const handleLoadMoreClick = () => { + setPage((prev) => prev + 1); + }; + + return ( +
+
+
+ +
+ +
+
+ + + {loadError && ( + + )} + + + 0}> +
+
+ {listData.map((record) => { + return ( +
+ {renderLogRecord(record)} +
+ ); + })} +
+ {hasMore && ( + + Load more + + )} +
+
+
+
+
+ ); +}; + +const SettingsDiagnosticsCrons = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { + const { t } = useTranslation(); + + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + type CronJob = Awaited>["items"][number]; + const [listData, setListData] = useState([]); + const [listTotal, setListTotal] = useState(0); + + const { + loading, + error: loadError, + run: refreshData, + } = useRequest( + () => { + return listCronJobs().then((res) => { + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + return { + items: res.items.slice(startIndex, endIndex), + totalItems: res.items.length, + }; + }); + }, + { + refreshDeps: [page, pageSize], + onSuccess: (res) => { + setListData(res.items); + setListTotal(res.totalItems); + }, + } + ); + + const handleReloadClick = () => { + refreshData(); + }; + + const handlePaginationChange = (page: number, pageSize: number) => { + setPage(page); + setPageSize(pageSize); + }; + + return ( +
+ + bordered + dataSource={listData} + loading={loading} + locale={{ + emptyText: ( + + {loadError && ( + + )} + + ), + }} + rowKey={(record) => record.id} + renderItem={(record) => ( + + + {t("settings.diagnostics.crons.job.next_trigger_time")} +
+ {dayjs(getNextCronExecutions(record.cron)[0]).format("YYYY-MM-DD HH:mm:ss")} + + } + mouseEnterDelay={1} + placement="topRight" + > +
+
+ {record.id} +
+
+ {record.cron} +
+
+
+
+ )} + /> + 1 || listTotal > pageSize}> +
+ +
+
+
+ ); +}; + +const SettingsDiagnosticsWorkflowDispatcher = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { + const { t } = useTranslation(); + + return ( +
+
TODO ...
+
+ ); +}; + +export default SettingsDiagnostics; diff --git a/ui/src/pages/workflows/WorkflowDetailRuns.tsx b/ui/src/pages/workflows/WorkflowDetailRuns.tsx index 8aafb8761..8f4facad6 100644 --- a/ui/src/pages/workflows/WorkflowDetailRuns.tsx +++ b/ui/src/pages/workflows/WorkflowDetailRuns.tsx @@ -194,7 +194,7 @@ const WorkflowDetailRuns = () => { const { loading, - error: loadedError, + error: loadError, run: refreshData, } = useRequest( () => { @@ -363,7 +363,7 @@ const WorkflowDetailRuns = () => { } /> ), diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index 59f4f6620..0deef24a1 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -229,7 +229,7 @@ const WorkflowList = () => { const { loading, - error: loadedError, + error: loadError, run: refreshData, } = useRequest( () => { @@ -491,10 +491,10 @@ const WorkflowList = () => { } extra={ - loadedError ? ( + loadError ? ( diff --git a/ui/src/repository/system.ts b/ui/src/repository/system.ts new file mode 100644 index 000000000..6d8fae360 --- /dev/null +++ b/ui/src/repository/system.ts @@ -0,0 +1,44 @@ +import { getPocketBase } from "./_pocketbase"; + +export const listCronJobs = () => { + return getPocketBase() + .crons.getFullList({ + requestKey: null, + }) + .then((res) => { + const jobs = res + .filter((job) => !job.id.startsWith("__pb")) + .map((job) => { + return { + id: job.id, + cron: job.expression, + }; + }); + return { + items: jobs, + }; + }); +}; + +export type ListLogsRequest = { + page?: number; + perPage?: number; +}; + +export const listLogs = (request: ListLogsRequest) => { + const page = request.page || 1; + const perPage = request.perPage || 10; + + return getPocketBase() + .logs.getList(page, perPage, { + filter: 'data.type!="request"', + sort: "-@rowid", + skipTotal: true, + requestKey: null, + }) + .then((res) => { + return { + items: res.items, + }; + }); +}; diff --git a/ui/src/routers/index.tsx b/ui/src/routers/index.tsx index 5e192a1e7..be671ebcf 100644 --- a/ui/src/routers/index.tsx +++ b/ui/src/routers/index.tsx @@ -11,6 +11,7 @@ import Login from "@/pages/login/Login"; import Settings from "@/pages/settings/Settings"; import SettingsAccount from "@/pages/settings/SettingsAccount"; import SettingsAppearance from "@/pages/settings/SettingsAppearance"; +import SettingsDiagnostics from "@/pages/settings/SettingsDiagnostics"; import SettingsPersistence from "@/pages/settings/SettingsPersistence"; import SettingsSSLProvider from "@/pages/settings/SettingsSSLProvider"; import WorkflowDetail from "@/pages/workflows/WorkflowDetail"; @@ -82,6 +83,10 @@ export const router = createHashRouter([ path: "/settings/persistence", element: , }, + { + path: "/settings/diagnostics", + element: , + }, ], }, ],