feat(v2): add all settings pages

This commit is contained in:
moonrailgun 2024-04-02 00:07:38 +08:00
parent 20e19633ae
commit fa7534a8e0
17 changed files with 925 additions and 46 deletions

View File

@ -3,18 +3,27 @@ import { TipIcon } from './TipIcon';
interface CommonHeaderProps { interface CommonHeaderProps {
title: string; title: string;
desc?: React.ReactNode; desc?: string;
tip?: React.ReactNode;
actions?: React.ReactNode; actions?: React.ReactNode;
} }
export const CommonHeader: React.FC<CommonHeaderProps> = React.memo((props) => { export const CommonHeader: React.FC<CommonHeaderProps> = React.memo((props) => {
return ( return (
<> <div className="flex w-full items-center">
<h1 className="text-xl font-bold">{props.title}</h1> <div className="flex flex-1 items-center">
<h1 className="text-xl font-bold">{props.title}</h1>
{props.desc && <TipIcon className="ml-1" content={props.desc} />} {props.desc && (
<span className="text-muted-foreground ml-2 self-end text-sm">
{props.desc}
</span>
)}
{props.tip && <TipIcon className="ml-1" content={props.tip} />}
</div>
{props.actions && <div className="ml-auto">{props.actions}</div>} {props.actions && <div className="ml-auto">{props.actions}</div>}
</> </div>
); );
}); });
CommonHeader.displayName = 'CommonHeader'; CommonHeader.displayName = 'CommonHeader';

View File

@ -48,7 +48,7 @@ export const CommonList: React.FC<CommonListProps> = React.memo((props) => {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{props.hasSearch && ( {props.hasSearch && (
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 p-4 backdrop-blur"> <div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 px-4 pt-4 backdrop-blur">
<form> <form>
<div className="relative"> <div className="relative">
<LuSearch className="text-muted-foreground absolute left-2 top-2.5 h-4 w-4" /> <LuSearch className="text-muted-foreground absolute left-2 top-2.5 h-4 w-4" />
@ -64,7 +64,7 @@ export const CommonList: React.FC<CommonListProps> = React.memo((props) => {
)} )}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="flex flex-col gap-2 p-4 pt-0"> <div className="flex flex-col gap-2 p-4">
{finalList.map((item) => { {finalList.map((item) => {
const isSelected = item.href === location.pathname; const isSelected = item.href === location.pathname;
@ -88,9 +88,13 @@ export const CommonList: React.FC<CommonListProps> = React.memo((props) => {
</div> </div>
</div> </div>
</div> </div>
<div className="text-muted-foreground line-clamp-2 w-full text-xs">
{item.content} {item.content && (
</div> <div className="text-muted-foreground line-clamp-2 w-full text-xs">
{item.content}
</div>
)}
{Array.isArray(item.tags) && item.tags.length > 0 ? ( {Array.isArray(item.tags) && item.tags.length > 0 ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.tags.map((tag) => ( {item.tags.map((tag) => (

View File

@ -60,7 +60,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) => { ) => {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : 'button';
const icon = Icon ? <Icon className="mr-1" /> : undefined; const icon = Icon ? (
<Icon className={cn(props.children && 'mr-1')} />
) : undefined;
const children = ( const children = (
<> <>
{loading ? <Spinner className="mr-1" /> : icon} {loading ? <Spinner className="mr-1" /> : icon}

View File

@ -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 <NoWorkspaceTip />;
}
if (!websiteId) {
return <ErrorTip />;
}
if (isLoading) {
return <Loading />;
}
if (!website) {
return <ErrorTip />;
}
return (
<div>
<div>
<Tabs>
<Tabs.TabPane key={'detail'} tab={'Detail'}>
<Form
layout="vertical"
initialValues={{
id: website.id,
name: website.name,
domain: website.domain,
monitorId: website.monitorId,
}}
onFinish={handleSave}
>
<Form.Item label={t('Website ID')} name="id">
<Input size="large" disabled={true} />
</Form.Item>
<Form.Item
label={t('Name')}
name="name"
rules={[{ required: true }]}
>
<Input size="large" />
</Form.Item>
<Form.Item
label={t('Domain')}
name="domain"
rules={[
{ required: true },
{
validator: hostnameValidator,
},
]}
>
<Input size="large" />
</Form.Item>
<Form.Item
label={t('Monitor')}
name="monitorId"
tooltip={t(
'You can bind a monitor which will display health status in website overview'
)}
>
<MonitorPicker size="large" allowClear={true} />
</Form.Item>
<Form.Item>
<Button size="large" htmlType="submit">
{t('Save')}
</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key={'data'} tab={'Data'}>
<Popconfirm
title={t('Delete Website')}
onConfirm={() => handleDeleteWebsite()}
>
<Button type="primary" danger={true}>
{t('Delete Website')}
</Button>
</Popconfirm>
</Tabs.TabPane>
</Tabs>
</div>
</div>
);
}
);
WebsiteConfig.displayName = 'WebsiteConfig';

View File

@ -13,9 +13,12 @@ import {
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useEvent } from '@/hooks/useEvent';
import { useSettingsStore } from '@/store/settings';
import { useUserInfo } from '@/store/user'; import { useUserInfo } from '@/store/user';
import { languages } from '@/utils/constants'; import { languages } from '@/utils/constants';
import { useTranslation, setLanguage } from '@i18next-toolkit/react'; import { useTranslation, setLanguage } from '@i18next-toolkit/react';
import { useNavigate } from '@tanstack/react-router';
import React from 'react'; import React from 'react';
import { LuMoreVertical } from 'react-icons/lu'; import { LuMoreVertical } from 'react-icons/lu';
@ -24,7 +27,15 @@ interface UserConfigProps {
} }
export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => { export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
const userInfo = useUserInfo(); 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 = ( const avatar = (
<Avatar> <Avatar>
@ -64,10 +75,28 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
)} )}
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem>Profile</DropdownMenuItem> <DropdownMenuItem
<DropdownMenuItem>Settings</DropdownMenuItem> onClick={() =>
navigate({
to: '/settings/profile',
})
}
>
{t('Profile')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
navigate({
to: '/settings/notifications',
})
}
>
{t('Notifications')}
</DropdownMenuItem>
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger>Language</DropdownMenuSubTrigger> <DropdownMenuSubTrigger>{t('Language')}</DropdownMenuSubTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuSubContent> <DropdownMenuSubContent>
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
@ -87,14 +116,24 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
</DropdownMenuPortal> </DropdownMenuPortal>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuItem <DropdownMenuSub>
className="cursor-default" <DropdownMenuSubTrigger>{t('Theme')}</DropdownMenuSubTrigger>
onSelect={(e) => { <DropdownMenuPortal>
e.preventDefault(); <DropdownMenuSubContent>
}} <DropdownMenuRadioGroup
> value={colorScheme}
<ColorSchemeSwitcher /> onValueChange={handleChangeColorSchema}
</DropdownMenuItem> >
<DropdownMenuRadioItem value={'dark'}>
{t('Dark')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={'light'}>
{t('Light')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@ -13,6 +13,7 @@
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as WebsiteImport } from './routes/website' import { Route as WebsiteImport } from './routes/website'
import { Route as TelemetryImport } from './routes/telemetry' import { Route as TelemetryImport } from './routes/telemetry'
import { Route as SettingsImport } from './routes/settings'
import { Route as ServerImport } from './routes/server' import { Route as ServerImport } from './routes/server'
import { Route as RegisterImport } from './routes/register' import { Route as RegisterImport } from './routes/register'
import { Route as PageImport } from './routes/page' 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 IndexImport } from './routes/index'
import { Route as WebsiteOverviewImport } from './routes/website/overview' import { Route as WebsiteOverviewImport } from './routes/website/overview'
import { Route as WebsiteAddImport } from './routes/website/add' 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 TelemetryAddImport } from './routes/telemetry/add'
import { Route as TelemetryTelemetryIdImport } from './routes/telemetry/$telemetryId' 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 PageAddImport } from './routes/page/add'
import { Route as PageSlugImport } from './routes/page/$slug' import { Route as PageSlugImport } from './routes/page/$slug'
import { Route as MonitorAddImport } from './routes/monitor/add' import { Route as MonitorAddImport } from './routes/monitor/add'
import { Route as MonitorMonitorIdImport } from './routes/monitor/$monitorId' 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 // Create/Update Routes
@ -42,6 +48,11 @@ const TelemetryRoute = TelemetryImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const SettingsRoute = SettingsImport.update({
path: '/settings',
getParentRoute: () => rootRoute,
} as any)
const ServerRoute = ServerImport.update({ const ServerRoute = ServerImport.update({
path: '/server', path: '/server',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@ -87,11 +98,6 @@ const WebsiteAddRoute = WebsiteAddImport.update({
getParentRoute: () => WebsiteRoute, getParentRoute: () => WebsiteRoute,
} as any) } as any)
const WebsiteWebsiteIdRoute = WebsiteWebsiteIdImport.update({
path: '/$websiteId',
getParentRoute: () => WebsiteRoute,
} as any)
const TelemetryAddRoute = TelemetryAddImport.update({ const TelemetryAddRoute = TelemetryAddImport.update({
path: '/add', path: '/add',
getParentRoute: () => TelemetryRoute, getParentRoute: () => TelemetryRoute,
@ -102,6 +108,26 @@ const TelemetryTelemetryIdRoute = TelemetryTelemetryIdImport.update({
getParentRoute: () => TelemetryRoute, getParentRoute: () => TelemetryRoute,
} as any) } 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({ const PageAddRoute = PageAddImport.update({
path: '/add', path: '/add',
getParentRoute: () => PageRoute, getParentRoute: () => PageRoute,
@ -122,6 +148,16 @@ const MonitorMonitorIdRoute = MonitorMonitorIdImport.update({
getParentRoute: () => MonitorRoute, getParentRoute: () => MonitorRoute,
} as any) } 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 // Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -154,6 +190,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ServerImport preLoaderRoute: typeof ServerImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/settings': {
preLoaderRoute: typeof SettingsImport
parentRoute: typeof rootRoute
}
'/telemetry': { '/telemetry': {
preLoaderRoute: typeof TelemetryImport preLoaderRoute: typeof TelemetryImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
@ -178,6 +218,22 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PageAddImport preLoaderRoute: typeof PageAddImport
parentRoute: typeof PageImport 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': { '/telemetry/$telemetryId': {
preLoaderRoute: typeof TelemetryTelemetryIdImport preLoaderRoute: typeof TelemetryTelemetryIdImport
parentRoute: typeof TelemetryImport parentRoute: typeof TelemetryImport
@ -186,10 +242,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TelemetryAddImport preLoaderRoute: typeof TelemetryAddImport
parentRoute: typeof TelemetryImport parentRoute: typeof TelemetryImport
} }
'/website/$websiteId': {
preLoaderRoute: typeof WebsiteWebsiteIdImport
parentRoute: typeof WebsiteImport
}
'/website/add': { '/website/add': {
preLoaderRoute: typeof WebsiteAddImport preLoaderRoute: typeof WebsiteAddImport
parentRoute: typeof WebsiteImport parentRoute: typeof WebsiteImport
@ -198,6 +250,14 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WebsiteOverviewImport preLoaderRoute: typeof WebsiteOverviewImport
parentRoute: typeof WebsiteImport 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]), PageRoute.addChildren([PageSlugRoute, PageAddRoute]),
RegisterRoute, RegisterRoute,
ServerRoute, ServerRoute,
SettingsRoute.addChildren([
SettingsAuditLogRoute,
SettingsNotificationsRoute,
SettingsProfileRoute,
SettingsUsageRoute,
]),
TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]), TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]),
WebsiteRoute.addChildren([ WebsiteRoute.addChildren([
WebsiteWebsiteIdRoute,
WebsiteAddRoute, WebsiteAddRoute,
WebsiteOverviewRoute, WebsiteOverviewRoute,
WebsiteWebsiteIdConfigRoute,
WebsiteWebsiteIdIndexRoute,
]), ]),
]) ])

View File

@ -14,6 +14,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useEventWithLoading } from '@/hooks/useEvent'; import { useEventWithLoading } from '@/hooks/useEvent';
@ -77,11 +78,11 @@ export const ServerContent: React.FC = React.memo(() => {
disabled={loading} disabled={loading}
onConfirm={handleClearOfflineNode} onConfirm={handleClearOfflineNode}
> >
<Button size="large" loading={loading}> <Button loading={loading}>{t('Clear Offline')}</Button>
{t('Clear Offline')}
</Button>
</Popconfirm> </Popconfirm>
<Separator orientation="vertical" className="h-6" />
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" Icon={LuPlus}> <Button variant="outline" Icon={LuPlus}>

View File

@ -0,0 +1,71 @@
import { trpc } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader';
import { CommonList } from '@/components/CommonList';
import { CommonWrapper } from '@/components/CommonWrapper';
import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent';
import { LayoutV2 } from '@/pages/LayoutV2';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route';
import { Trans, useTranslation } from '@i18next-toolkit/react';
import {
createFileRoute,
useNavigate,
useRouterState,
} from '@tanstack/react-router';
import { useEffect } from 'react';
export const Route = createFileRoute('/settings')({
beforeLoad: routeAuthBeforeLoad,
component: TelemetryComponent,
});
function TelemetryComponent() {
const { t } = useTranslation();
const navigate = useNavigate();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const items = [
{
id: 'profile',
title: t('Profile'),
href: '/settings/profile',
},
{
id: 'notifications',
title: t('Notifications'),
href: '/settings/notifications',
},
{
id: 'auditLog',
title: t('Audit Log'),
href: '/settings/auditLog',
},
{
id: 'usage',
title: t('Usage'),
href: '/settings/usage',
},
];
useEffect(() => {
if (pathname === Route.fullPath) {
navigate({
to: '/settings/profile',
});
}
}, []);
return (
<LayoutV2
list={
<CommonWrapper header={<CommonHeader title={t('Settings')} />}>
<CommonList items={items} />
</CommonWrapper>
}
/>
);
}

View File

@ -0,0 +1,124 @@
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 { Empty, List } from 'antd';
import { useMemo, useRef } from 'react';
import { trpc } from '../../api/trpc';
import { useCurrentWorkspaceId } from '../../store/user';
import { CommonHeader } from '@/components/CommonHeader';
import { last } from 'lodash-es';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useWatch } from '@/hooks/useWatch';
import dayjs from 'dayjs';
import { ColorTag } from '@/components/ColorTag';
export const Route = createFileRoute('/settings/auditLog')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const { t } = useTranslation();
const workspaceId = useCurrentWorkspaceId();
const parentRef = useRef<HTMLDivElement>(null);
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
trpc.auditLog.fetchByCursor.useInfiniteQuery({
workspaceId,
});
const allData = useMemo(() => {
if (!data) {
return [];
}
return [...data.pages.flatMap((p) => p.items)];
}, [data]);
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? allData.length + 1 : allData.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 5,
});
const virtualItems = rowVirtualizer.getVirtualItems();
useWatch([virtualItems], () => {
const lastItem = last(virtualItems);
if (!lastItem) {
return;
}
if (
lastItem.index >= allData.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
});
return (
<CommonWrapper header={<CommonHeader title={t('Audit Log')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<List>
<div ref={parentRef} className="h-full w-full overflow-auto">
{virtualItems.length === 0 && <Empty />}
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualItems.map((virtualRow) => {
const isLoaderRow = virtualRow.index > allData.length - 1;
const item = allData[virtualRow.index];
return (
<List.Item
key={virtualRow.index}
className="absolute left-0 top-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? (
t('Loading more...')
) : (
t('Nothing more to load')
)
) : (
<div className="flex h-7 items-center overflow-hidden">
{item.relatedType && (
<ColorTag label={item.relatedType} />
)}
<div
className="mr-2 w-9 text-xs opacity-60"
title={dayjs(item.createdAt).format(
'YYYY-MM-DD HH:mm:ss'
)}
>
{dayjs(item.createdAt).format('MM-DD HH:mm')}
</div>
<div className="h-full flex-1 overflow-auto">
{item.content}
</div>
</div>
)}
</List.Item>
);
})}
</div>
</div>
</List>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -0,0 +1,137 @@
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 { List, Popconfirm } from 'antd';
import { useState } from 'react';
import { trpc } from '../../api/trpc';
import {
NotificationFormValues,
NotificationInfoModal,
} from '../../components/modals/NotificationInfo';
import { useEvent } from '../../hooks/useEvent';
import { useCurrentWorkspaceId } from '../../store/user';
import { CommonHeader } from '@/components/CommonHeader';
import { Button } from '@/components/ui/button';
import { LuFileEdit, LuPlus, LuTrash2 } from 'react-icons/lu';
export const Route = createFileRoute('/settings/notifications')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const currentWorkspaceId = useCurrentWorkspaceId();
const { data: list = [], refetch } = trpc.notification.all.useQuery({
workspaceId: currentWorkspaceId!,
});
const [editingFormData, setEditingFormData] = useState<
NotificationFormValues | undefined
>(undefined);
const upsertMutation = trpc.notification.upsert.useMutation();
const deleteMutation = trpc.notification.delete.useMutation();
const handleOpenModal = useEvent((initValues?: NotificationFormValues) => {
setEditingFormData(initValues);
setOpen(true);
});
const handleCloseModal = useEvent(() => {
setEditingFormData(undefined);
setOpen(false);
});
const handleSubmit = useEvent(async (values: NotificationFormValues) => {
await upsertMutation.mutateAsync({
workspaceId: currentWorkspaceId!,
...values,
});
handleCloseModal();
refetch();
});
const handleDelete = useEvent(async (notificationId: string) => {
await deleteMutation.mutateAsync({
workspaceId: currentWorkspaceId!,
id: notificationId,
});
refetch();
});
return (
<CommonWrapper
header={
<CommonHeader
title={t('Notifications')}
actions={
<>
<Button
variant="outline"
Icon={LuPlus}
onClick={() => handleOpenModal()}
>
{t('New')}
</Button>
</>
}
/>
}
>
<ScrollArea className="h-full overflow-hidden p-4">
<div>
<List
bordered={true}
dataSource={list}
renderItem={(item) => (
<List.Item
actions={[
<Button
variant="default"
Icon={LuFileEdit}
onClick={() => {
handleOpenModal({
id: item.id,
name: item.name,
type: item.type,
payload: item.payload as Record<string, any>,
});
}}
>
{t('Edit')}
</Button>,
<Popconfirm
title={t('Is delete this item?')}
okButtonProps={{
danger: true,
}}
onConfirm={() => {
handleDelete(item.id);
}}
>
<Button variant="destructive" size="icon">
<LuTrash2 />
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta title={item.name} />
</List.Item>
)}
/>
<NotificationInfoModal
key={editingFormData?.id}
open={open}
initialValues={editingFormData}
onSubmit={handleSubmit}
onCancel={() => handleCloseModal()}
/>
</div>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -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 (
<CommonWrapper header={<CommonHeader title={t('Profile')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<div>
<Card>
<Form layout="vertical">
<Form.Item label={t('Current Workspace Id')}>
<Typography.Text copyable={true} code={true}>
{userInfo?.currentWorkspace?.id}
</Typography.Text>
</Form.Item>
<Form.Item label={t('User Id')}>
<Typography.Text copyable={true} code={true}>
{userInfo?.id}
</Typography.Text>
</Form.Item>
<Form.Item label={t('Password')}>
<Button
danger={true}
onClick={() => setOpenChangePassword(true)}
>
{t('Change Password')}
</Button>
</Form.Item>
</Form>
</Card>
<Modal
open={openChangePassword}
title={t('Change password')}
footer={null}
maskClosable={false}
onCancel={() => setOpenChangePassword(false)}
destroyOnClose={true}
>
<Form
layout="vertical"
onFinish={async (values) => {
const { oldPassword, newPassword } = values;
await changePasswordMutation.mutateAsync({
oldPassword,
newPassword,
});
logout();
}}
>
<Form.Item
label={t('Old Password')}
name="oldPassword"
rules={[{ required: true }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t('New Password')}
name="newPassword"
rules={[{ required: true }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label={t('New Password Repeat')}
name="newPasswordRepeat"
rules={[
{ required: true },
(form) => ({
validator(rule, value) {
if (
!value ||
form.getFieldValue('newPassword') === value
) {
return Promise.resolve();
}
return Promise.reject(
t('The two passwords are not consistent')
);
},
}),
]}
>
<Input.Password />
</Form.Item>
<Form.Item className="text-right">
<Button
type="primary"
htmlType="submit"
loading={changePasswordMutation.isLoading}
>
{t('Submit')}
</Button>
</Form.Item>
</Form>
</Modal>
</div>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -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 (
<CommonWrapper header={<CommonHeader title={t('Usage')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<Card>
<CardHeader>
<div className="mb-2 text-lg">
{t('Statistic Date')}:
<span className="ml-2 font-bold">
{startDate.format('YYYY/MM/DD')} -{' '}
{endDate.format('YYYY/MM/DD')}
</span>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Website Accepted Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.websiteAcceptedCount ?? 0)}
</CardContent>
</Card>
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Website Event Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.websiteEventCount ?? 0)}
</CardContent>
</Card>
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Monitor Execution Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.monitorExecutionCount ?? 0)}
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -65,7 +65,7 @@ function TelemetryComponent() {
header={ header={
<CommonHeader <CommonHeader
title={t('Telemetry')} title={t('Telemetry')}
desc={ tip={
<div className="space-y-2"> <div className="space-y-2">
<p> <p>
<Trans> <Trans>

View File

@ -78,11 +78,10 @@ function WebsiteComponent() {
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="icon"
Icon={LuPlus} Icon={LuPlus}
onClick={handleClickAdd} onClick={handleClickAdd}
> />
{t('Add')}
</Button>
</div> </div>
} }
/> />

View File

@ -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 <ErrorTip />;
}
if (isLoading) {
return <Loading />;
}
if (!website) {
return <NotFoundTip />;
}
return (
<CommonWrapper
header={<CommonHeader title={website.name} desc={t('Config')} />}
>
<ScrollArea className="h-full overflow-hidden p-4">
<WebsiteConfig websiteId={websiteId} />
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -4,6 +4,7 @@ import { CommonWrapper } from '@/components/CommonWrapper';
import { ErrorTip } from '@/components/ErrorTip'; import { ErrorTip } from '@/components/ErrorTip';
import { Loading } from '@/components/Loading'; import { Loading } from '@/components/Loading';
import { NotFoundTip } from '@/components/NotFoundTip'; import { NotFoundTip } from '@/components/NotFoundTip';
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { WebsiteCodeBtn } from '@/components/website/WebsiteCodeBtn'; import { WebsiteCodeBtn } from '@/components/website/WebsiteCodeBtn';
import { WebsiteMetricsTable } from '@/components/website/WebsiteMetricsTable'; import { WebsiteMetricsTable } from '@/components/website/WebsiteMetricsTable';
@ -13,10 +14,11 @@ import { useGlobalRangeDate } from '@/hooks/useGlobalRangeDate';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Card } from 'antd'; import { Card } from 'antd';
import { LuSettings } from 'react-icons/lu';
export const Route = createFileRoute('/website/$websiteId')({ export const Route = createFileRoute('/website/$websiteId/')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
component: WebsiteDetailComponent, component: WebsiteDetailComponent,
}); });
@ -30,6 +32,7 @@ function WebsiteDetailComponent() {
websiteId, websiteId,
}); });
const { startDate, endDate } = useGlobalRangeDate(); const { startDate, endDate } = useGlobalRangeDate();
const navigate = useNavigate();
if (!websiteId) { if (!websiteId) {
return <ErrorTip />; return <ErrorTip />;
@ -52,9 +55,23 @@ function WebsiteDetailComponent() {
<CommonHeader <CommonHeader
title={website.name} title={website.name}
actions={ actions={
<> <div className="space-x-2">
<Button
size="icon"
variant="outline"
onClick={() =>
navigate({
to: '/website/$websiteId/config',
params: {
websiteId,
},
})
}
>
<LuSettings />
</Button>
<WebsiteCodeBtn websiteId={website.id} /> <WebsiteCodeBtn websiteId={website.id} />
</> </div>
} }
/> />
} }

View File

@ -41,7 +41,7 @@ function WebsiteOverviewComponent() {
return ( return (
<CommonWrapper <CommonWrapper
header={<h1 className="text-xl font-bold">{t('Add Website')}</h1>} header={<h1 className="text-xl font-bold">{t('Website Overview')}</h1>}
> >
<ScrollArea className="h-full overflow-hidden p-4"> <ScrollArea className="h-full overflow-hidden p-4">
{websites.length === 0 && isLoading === false && ( {websites.length === 0 && isLoading === false && (