diff --git a/src/client/components/CommonHeader.tsx b/src/client/components/CommonHeader.tsx index fa7e7948..3cd1c892 100644 --- a/src/client/components/CommonHeader.tsx +++ b/src/client/components/CommonHeader.tsx @@ -3,18 +3,27 @@ import { TipIcon } from './TipIcon'; interface CommonHeaderProps { title: string; - desc?: React.ReactNode; + desc?: string; + tip?: React.ReactNode; actions?: React.ReactNode; } export const CommonHeader: React.FC = React.memo((props) => { return ( - <> -

{props.title}

+
+
+

{props.title}

- {props.desc && } + {props.desc && ( + + {props.desc} + + )} + + {props.tip && } +
{props.actions &&
{props.actions}
} - +
); }); CommonHeader.displayName = 'CommonHeader'; diff --git a/src/client/components/CommonList.tsx b/src/client/components/CommonList.tsx index 05b01cab..aa40c649 100644 --- a/src/client/components/CommonList.tsx +++ b/src/client/components/CommonList.tsx @@ -48,7 +48,7 @@ export const CommonList: React.FC = React.memo((props) => { return (
{props.hasSearch && ( -
+
@@ -64,7 +64,7 @@ export const CommonList: React.FC = React.memo((props) => { )} -
+
{finalList.map((item) => { const isSelected = item.href === location.pathname; @@ -88,9 +88,13 @@ export const CommonList: React.FC = React.memo((props) => {
-
- {item.content} -
+ + {item.content && ( +
+ {item.content} +
+ )} + {Array.isArray(item.tags) && item.tags.length > 0 ? (
{item.tags.map((tag) => ( diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx index af8386fc..1dae2830 100644 --- a/src/client/components/ui/button.tsx +++ b/src/client/components/ui/button.tsx @@ -60,7 +60,9 @@ const Button = React.forwardRef( ) => { const Comp = asChild ? Slot : 'button'; - const icon = Icon ? : undefined; + const icon = Icon ? ( + + ) : undefined; const children = ( <> {loading ? : icon} diff --git a/src/client/components/website/WebsiteConfig.tsx b/src/client/components/website/WebsiteConfig.tsx new file mode 100644 index 00000000..50375082 --- /dev/null +++ b/src/client/components/website/WebsiteConfig.tsx @@ -0,0 +1,151 @@ +import { Button, Form, Input, message, Popconfirm, Tabs } from 'antd'; +import React from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { deleteWorkspaceWebsite } from '../../api/model/website'; +import { useRequest } from '../../hooks/useRequest'; +import { useCurrentWorkspaceId } from '../../store/user'; +import { ErrorTip } from '../ErrorTip'; +import { Loading } from '../Loading'; +import { NoWorkspaceTip } from '../NoWorkspaceTip'; +import { MonitorPicker } from '../monitor/MonitorPicker'; +import { + defaultErrorHandler, + defaultSuccessHandler, + getQueryKey, + trpc, +} from '../../api/trpc'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEvent } from '../../hooks/useEvent'; +import { hostnameValidator } from '../../utils/validator'; +import { useTranslation } from '@i18next-toolkit/react'; + +export const WebsiteConfig: React.FC<{ websiteId: string }> = React.memo( + (props) => { + const { websiteId } = props; + const { t } = useTranslation(); + const workspaceId = useCurrentWorkspaceId(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data: website, isLoading } = trpc.website.info.useQuery({ + workspaceId, + websiteId, + }); + + const updateMutation = trpc.website.updateInfo.useMutation({ + onSuccess: () => { + queryClient.resetQueries(getQueryKey(trpc.website.info)); + defaultSuccessHandler(); + }, + onError: defaultErrorHandler, + }); + + const handleSave = useEvent( + async (values: { name: string; domain: string; monitorId: string }) => { + await updateMutation.mutateAsync({ + workspaceId, + websiteId, + name: values.name, + domain: values.domain, + monitorId: values.monitorId, + }); + } + ); + + const [, handleDeleteWebsite] = useRequest(async () => { + await deleteWorkspaceWebsite(workspaceId, websiteId!); + + message.success(t('Delete Success')); + + navigate('/settings/websites'); + }); + + if (!workspaceId) { + return ; + } + + if (!websiteId) { + return ; + } + + if (isLoading) { + return ; + } + + if (!website) { + return ; + } + + return ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + handleDeleteWebsite()} + > + + + + +
+
+ ); + } +); +WebsiteConfig.displayName = 'WebsiteConfig'; diff --git a/src/client/pages/Layout/UserConfig.tsx b/src/client/pages/Layout/UserConfig.tsx index 3cebd480..81c6b2b1 100644 --- a/src/client/pages/Layout/UserConfig.tsx +++ b/src/client/pages/Layout/UserConfig.tsx @@ -13,9 +13,12 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, } from '@/components/ui/dropdown-menu'; +import { useEvent } from '@/hooks/useEvent'; +import { useSettingsStore } from '@/store/settings'; import { useUserInfo } from '@/store/user'; import { languages } from '@/utils/constants'; import { useTranslation, setLanguage } from '@i18next-toolkit/react'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; import { LuMoreVertical } from 'react-icons/lu'; @@ -24,7 +27,15 @@ interface UserConfigProps { } export const UserConfig: React.FC = React.memo((props) => { const userInfo = useUserInfo(); - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation(); + const navigate = useNavigate(); + const colorScheme = useSettingsStore((state) => state.colorScheme); + + const handleChangeColorSchema = useEvent((colorScheme) => { + useSettingsStore.setState({ + colorScheme, + }); + }); const avatar = ( @@ -64,10 +75,28 @@ export const UserConfig: React.FC = React.memo((props) => { )} - Profile - Settings + + navigate({ + to: '/settings/profile', + }) + } + > + {t('Profile')} + + + + navigate({ + to: '/settings/notifications', + }) + } + > + {t('Notifications')} + + - Language + {t('Language')} = React.memo((props) => { - { - e.preventDefault(); - }} - > - - + + {t('Theme')} + + + + + {t('Dark')} + + + {t('Light')} + + + + +
diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index c1ec572a..acd0d4fd 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as rootRoute } from './routes/__root' import { Route as WebsiteImport } from './routes/website' import { Route as TelemetryImport } from './routes/telemetry' +import { Route as SettingsImport } from './routes/settings' import { Route as ServerImport } from './routes/server' import { Route as RegisterImport } from './routes/register' import { Route as PageImport } from './routes/page' @@ -22,13 +23,18 @@ import { Route as DashboardImport } from './routes/dashboard' import { Route as IndexImport } from './routes/index' import { Route as WebsiteOverviewImport } from './routes/website/overview' import { Route as WebsiteAddImport } from './routes/website/add' -import { Route as WebsiteWebsiteIdImport } from './routes/website/$websiteId' import { Route as TelemetryAddImport } from './routes/telemetry/add' import { Route as TelemetryTelemetryIdImport } from './routes/telemetry/$telemetryId' +import { Route as SettingsUsageImport } from './routes/settings/usage' +import { Route as SettingsProfileImport } from './routes/settings/profile' +import { Route as SettingsNotificationsImport } from './routes/settings/notifications' +import { Route as SettingsAuditLogImport } from './routes/settings/auditLog' import { Route as PageAddImport } from './routes/page/add' import { Route as PageSlugImport } from './routes/page/$slug' import { Route as MonitorAddImport } from './routes/monitor/add' import { Route as MonitorMonitorIdImport } from './routes/monitor/$monitorId' +import { Route as WebsiteWebsiteIdIndexImport } from './routes/website/$websiteId/index' +import { Route as WebsiteWebsiteIdConfigImport } from './routes/website/$websiteId/config' // Create/Update Routes @@ -42,6 +48,11 @@ const TelemetryRoute = TelemetryImport.update({ getParentRoute: () => rootRoute, } as any) +const SettingsRoute = SettingsImport.update({ + path: '/settings', + getParentRoute: () => rootRoute, +} as any) + const ServerRoute = ServerImport.update({ path: '/server', getParentRoute: () => rootRoute, @@ -87,11 +98,6 @@ const WebsiteAddRoute = WebsiteAddImport.update({ getParentRoute: () => WebsiteRoute, } as any) -const WebsiteWebsiteIdRoute = WebsiteWebsiteIdImport.update({ - path: '/$websiteId', - getParentRoute: () => WebsiteRoute, -} as any) - const TelemetryAddRoute = TelemetryAddImport.update({ path: '/add', getParentRoute: () => TelemetryRoute, @@ -102,6 +108,26 @@ const TelemetryTelemetryIdRoute = TelemetryTelemetryIdImport.update({ getParentRoute: () => TelemetryRoute, } as any) +const SettingsUsageRoute = SettingsUsageImport.update({ + path: '/usage', + getParentRoute: () => SettingsRoute, +} as any) + +const SettingsProfileRoute = SettingsProfileImport.update({ + path: '/profile', + getParentRoute: () => SettingsRoute, +} as any) + +const SettingsNotificationsRoute = SettingsNotificationsImport.update({ + path: '/notifications', + getParentRoute: () => SettingsRoute, +} as any) + +const SettingsAuditLogRoute = SettingsAuditLogImport.update({ + path: '/auditLog', + getParentRoute: () => SettingsRoute, +} as any) + const PageAddRoute = PageAddImport.update({ path: '/add', getParentRoute: () => PageRoute, @@ -122,6 +148,16 @@ const MonitorMonitorIdRoute = MonitorMonitorIdImport.update({ getParentRoute: () => MonitorRoute, } as any) +const WebsiteWebsiteIdIndexRoute = WebsiteWebsiteIdIndexImport.update({ + path: '/$websiteId/', + getParentRoute: () => WebsiteRoute, +} as any) + +const WebsiteWebsiteIdConfigRoute = WebsiteWebsiteIdConfigImport.update({ + path: '/$websiteId/config', + getParentRoute: () => WebsiteRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -154,6 +190,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ServerImport parentRoute: typeof rootRoute } + '/settings': { + preLoaderRoute: typeof SettingsImport + parentRoute: typeof rootRoute + } '/telemetry': { preLoaderRoute: typeof TelemetryImport parentRoute: typeof rootRoute @@ -178,6 +218,22 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PageAddImport parentRoute: typeof PageImport } + '/settings/auditLog': { + preLoaderRoute: typeof SettingsAuditLogImport + parentRoute: typeof SettingsImport + } + '/settings/notifications': { + preLoaderRoute: typeof SettingsNotificationsImport + parentRoute: typeof SettingsImport + } + '/settings/profile': { + preLoaderRoute: typeof SettingsProfileImport + parentRoute: typeof SettingsImport + } + '/settings/usage': { + preLoaderRoute: typeof SettingsUsageImport + parentRoute: typeof SettingsImport + } '/telemetry/$telemetryId': { preLoaderRoute: typeof TelemetryTelemetryIdImport parentRoute: typeof TelemetryImport @@ -186,10 +242,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TelemetryAddImport parentRoute: typeof TelemetryImport } - '/website/$websiteId': { - preLoaderRoute: typeof WebsiteWebsiteIdImport - parentRoute: typeof WebsiteImport - } '/website/add': { preLoaderRoute: typeof WebsiteAddImport parentRoute: typeof WebsiteImport @@ -198,6 +250,14 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsiteOverviewImport parentRoute: typeof WebsiteImport } + '/website/$websiteId/config': { + preLoaderRoute: typeof WebsiteWebsiteIdConfigImport + parentRoute: typeof WebsiteImport + } + '/website/$websiteId/': { + preLoaderRoute: typeof WebsiteWebsiteIdIndexImport + parentRoute: typeof WebsiteImport + } } } @@ -211,11 +271,18 @@ export const routeTree = rootRoute.addChildren([ PageRoute.addChildren([PageSlugRoute, PageAddRoute]), RegisterRoute, ServerRoute, + SettingsRoute.addChildren([ + SettingsAuditLogRoute, + SettingsNotificationsRoute, + SettingsProfileRoute, + SettingsUsageRoute, + ]), TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]), WebsiteRoute.addChildren([ - WebsiteWebsiteIdRoute, WebsiteAddRoute, WebsiteOverviewRoute, + WebsiteWebsiteIdConfigRoute, + WebsiteWebsiteIdIndexRoute, ]), ]) diff --git a/src/client/routes/server.tsx b/src/client/routes/server.tsx index 7e6945f1..2568a230 100644 --- a/src/client/routes/server.tsx +++ b/src/client/routes/server.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useEventWithLoading } from '@/hooks/useEvent'; @@ -77,11 +78,11 @@ export const ServerContent: React.FC = React.memo(() => { disabled={loading} onConfirm={handleClearOfflineNode} > - + + + + + } + /> + } + > + +
+ ( + { + handleOpenModal({ + id: item.id, + name: item.name, + type: item.type, + payload: item.payload as Record, + }); + }} + > + {t('Edit')} + , + { + handleDelete(item.id); + }} + > + + , + ]} + > + + + )} + /> + + handleCloseModal()} + /> +
+
+ + ); +} diff --git a/src/client/routes/settings/profile.tsx b/src/client/routes/settings/profile.tsx new file mode 100644 index 00000000..0584b8c0 --- /dev/null +++ b/src/client/routes/settings/profile.tsx @@ -0,0 +1,128 @@ +import { routeAuthBeforeLoad } from '@/utils/route'; +import { createFileRoute } from '@tanstack/react-router'; +import { useTranslation } from '@i18next-toolkit/react'; +import { CommonWrapper } from '@/components/CommonWrapper'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Button, Card, Form, Input, Modal, Typography } from 'antd'; +import { useLogout } from '@/api/model/user'; +import { trpc, defaultSuccessHandler, defaultErrorHandler } from '@/api/trpc'; +import { useUserStore } from '@/store/user'; +import { useState } from 'react'; +import { CommonHeader } from '@/components/CommonHeader'; + +export const Route = createFileRoute('/settings/profile')({ + beforeLoad: routeAuthBeforeLoad, + component: PageComponent, +}); + +function PageComponent() { + const { t } = useTranslation(); + const userInfo = useUserStore((state) => state.info); + const [openChangePassword, setOpenChangePassword] = useState(false); + + const changePasswordMutation = trpc.user.changePassword.useMutation({ + onSuccess: defaultSuccessHandler, + onError: defaultErrorHandler, + }); + + const logout = useLogout(); + + return ( + }> + +
+ +
+ + + {userInfo?.currentWorkspace?.id} + + + + + {userInfo?.id} + + + + + +
+
+ + setOpenChangePassword(false)} + destroyOnClose={true} + > +
{ + const { oldPassword, newPassword } = values; + await changePasswordMutation.mutateAsync({ + oldPassword, + newPassword, + }); + logout(); + }} + > + + + + + + + ({ + validator(rule, value) { + if ( + !value || + form.getFieldValue('newPassword') === value + ) { + return Promise.resolve(); + } + + return Promise.reject( + t('The two passwords are not consistent') + ); + }, + }), + ]} + > + + + + + +
+
+
+
+
+ ); +} diff --git a/src/client/routes/settings/usage.tsx b/src/client/routes/settings/usage.tsx new file mode 100644 index 00000000..d8e6e0bb --- /dev/null +++ b/src/client/routes/settings/usage.tsx @@ -0,0 +1,81 @@ +import { routeAuthBeforeLoad } from '@/utils/route'; +import { createFileRoute } from '@tanstack/react-router'; +import { useTranslation } from '@i18next-toolkit/react'; +import { CommonWrapper } from '@/components/CommonWrapper'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Statistic } from 'antd'; +import { useMemo } from 'react'; +import { trpc } from '../../api/trpc'; +import { useCurrentWorkspaceId } from '../../store/user'; +import { CommonHeader } from '@/components/CommonHeader'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import dayjs from 'dayjs'; +import { formatNumber } from '@/utils/common'; + +export const Route = createFileRoute('/settings/usage')({ + beforeLoad: routeAuthBeforeLoad, + component: PageComponent, +}); + +function PageComponent() { + const { t } = useTranslation(); + const workspaceId = useCurrentWorkspaceId(); + const [startDate, endDate] = useMemo( + () => [dayjs().startOf('month'), dayjs().endOf('day')], + [] + ); + + const { data } = trpc.billing.usage.useQuery({ + workspaceId, + startAt: startDate.valueOf(), + endAt: endDate.valueOf(), + }); + + return ( + }> + + + +
+ {t('Statistic Date')}: + + {startDate.format('YYYY/MM/DD')} -{' '} + {endDate.format('YYYY/MM/DD')} + +
+
+ +
+ + + {t('Website Accepted Count')} + + + {formatNumber(data?.websiteAcceptedCount ?? 0)} + + + + + + {t('Website Event Count')} + + + {formatNumber(data?.websiteEventCount ?? 0)} + + + + + + {t('Monitor Execution Count')} + + + {formatNumber(data?.monitorExecutionCount ?? 0)} + + +
+
+
+
+
+ ); +} diff --git a/src/client/routes/telemetry.tsx b/src/client/routes/telemetry.tsx index 31de1831..75232616 100644 --- a/src/client/routes/telemetry.tsx +++ b/src/client/routes/telemetry.tsx @@ -65,7 +65,7 @@ function TelemetryComponent() { header={

diff --git a/src/client/routes/website.tsx b/src/client/routes/website.tsx index b5149746..c197a70e 100644 --- a/src/client/routes/website.tsx +++ b/src/client/routes/website.tsx @@ -78,11 +78,10 @@ function WebsiteComponent() { + />

} /> diff --git a/src/client/routes/website/$websiteId/config.tsx b/src/client/routes/website/$websiteId/config.tsx new file mode 100644 index 00000000..35d12475 --- /dev/null +++ b/src/client/routes/website/$websiteId/config.tsx @@ -0,0 +1,49 @@ +import { trpc } from '@/api/trpc'; +import { CommonHeader } from '@/components/CommonHeader'; +import { CommonWrapper } from '@/components/CommonWrapper'; +import { ErrorTip } from '@/components/ErrorTip'; +import { Loading } from '@/components/Loading'; +import { NotFoundTip } from '@/components/NotFoundTip'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { WebsiteConfig } from '@/components/website/WebsiteConfig'; +import { useCurrentWorkspaceId } from '@/store/user'; +import { routeAuthBeforeLoad } from '@/utils/route'; +import { useTranslation } from '@i18next-toolkit/react'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/website/$websiteId/config')({ + beforeLoad: routeAuthBeforeLoad, + component: WebsiteDetailComponent, +}); + +function WebsiteDetailComponent() { + const { websiteId } = Route.useParams<{ websiteId: string }>(); + const workspaceId = useCurrentWorkspaceId(); + const { data: website, isLoading } = trpc.website.info.useQuery({ + workspaceId, + websiteId, + }); + const { t } = useTranslation(); + + if (!websiteId) { + return ; + } + + if (isLoading) { + return ; + } + + if (!website) { + return ; + } + + return ( + } + > + + + + + ); +} diff --git a/src/client/routes/website/$websiteId.tsx b/src/client/routes/website/$websiteId/index.tsx similarity index 86% rename from src/client/routes/website/$websiteId.tsx rename to src/client/routes/website/$websiteId/index.tsx index 71761efc..501530b9 100644 --- a/src/client/routes/website/$websiteId.tsx +++ b/src/client/routes/website/$websiteId/index.tsx @@ -4,6 +4,7 @@ import { CommonWrapper } from '@/components/CommonWrapper'; import { ErrorTip } from '@/components/ErrorTip'; import { Loading } from '@/components/Loading'; import { NotFoundTip } from '@/components/NotFoundTip'; +import { Button } from '@/components/ui/button'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { WebsiteCodeBtn } from '@/components/website/WebsiteCodeBtn'; import { WebsiteMetricsTable } from '@/components/website/WebsiteMetricsTable'; @@ -13,10 +14,11 @@ import { useGlobalRangeDate } from '@/hooks/useGlobalRangeDate'; import { useCurrentWorkspaceId } from '@/store/user'; import { routeAuthBeforeLoad } from '@/utils/route'; import { useTranslation } from '@i18next-toolkit/react'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { Card } from 'antd'; +import { LuSettings } from 'react-icons/lu'; -export const Route = createFileRoute('/website/$websiteId')({ +export const Route = createFileRoute('/website/$websiteId/')({ beforeLoad: routeAuthBeforeLoad, component: WebsiteDetailComponent, }); @@ -30,6 +32,7 @@ function WebsiteDetailComponent() { websiteId, }); const { startDate, endDate } = useGlobalRangeDate(); + const navigate = useNavigate(); if (!websiteId) { return ; @@ -52,9 +55,23 @@ function WebsiteDetailComponent() { +
+ - +
} /> } diff --git a/src/client/routes/website/overview.tsx b/src/client/routes/website/overview.tsx index 9f770970..a82a2ce2 100644 --- a/src/client/routes/website/overview.tsx +++ b/src/client/routes/website/overview.tsx @@ -41,7 +41,7 @@ function WebsiteOverviewComponent() { return ( {t('Add Website')}} + header={

{t('Website Overview')}

} > {websites.length === 0 && isLoading === false && (