feat: add timezone support #114

This commit is contained in:
moonrailgun 2024-10-20 22:47:22 +08:00
parent 83850f2981
commit c7e20df516
10 changed files with 1838 additions and 19 deletions

View File

@ -38,13 +38,22 @@ import {
} from '@/components/ui/form'; } from '@/components/ui/form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useEventWithLoading } from '@/hooks/useEvent'; import { useEvent, useEventWithLoading } from '@/hooks/useEvent';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { AlertConfirm } from '@/components/AlertConfirm'; import { AlertConfirm } from '@/components/AlertConfirm';
import { ROLES } from '@tianji/shared'; import { ROLES } from '@tianji/shared';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import dayjs from 'dayjs';
import { getTimezoneList } from '@/utils/date';
export const Route = createFileRoute('/settings/workspace')({ export const Route = createFileRoute('/settings/workspace')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
@ -62,7 +71,7 @@ const columnHelper = createColumnHelper<MemberInfo>();
function PageComponent() { function PageComponent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id: workspaceId, name, role } = useCurrentWorkspace(); const { id: workspaceId, name, role, settings } = useCurrentWorkspace();
const hasAdminPermission = useHasAdminPermission(); const hasAdminPermission = useHasAdminPermission();
const { data: members = [], refetch: refetchMembers } = const { data: members = [], refetch: refetchMembers } =
trpc.workspace.members.useQuery({ trpc.workspace.members.useQuery({
@ -71,6 +80,9 @@ function PageComponent() {
const updateCurrentWorkspaceName = useUserStore( const updateCurrentWorkspaceName = useUserStore(
(state) => state.updateCurrentWorkspaceName (state) => state.updateCurrentWorkspaceName
); );
const updateCurrentWorkspaceSettings = useUserStore(
(state) => state.updateCurrentWorkspaceSettings
);
const form = useForm<InviteFormValues>({ const form = useForm<InviteFormValues>({
resolver: zodResolver(inviteFormSchema), resolver: zodResolver(inviteFormSchema),
defaultValues: { defaultValues: {
@ -89,6 +101,10 @@ function PageComponent() {
onSuccess: defaultSuccessHandler, onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler, onError: defaultErrorHandler,
}); });
const updateSettings = trpc.workspace.updateSettings.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const [renameWorkspaceName, setRenameWorkspaceName] = useState(''); const [renameWorkspaceName, setRenameWorkspaceName] = useState('');
const [handleRename, isRenameLoading] = useEventWithLoading(async () => { const [handleRename, isRenameLoading] = useEventWithLoading(async () => {
@ -112,6 +128,19 @@ function PageComponent() {
} }
); );
const handleUpdateSettings = useEvent(async (key: string, value: string) => {
const { settings } = await updateSettings.mutateAsync({
workspaceId,
settings: {
[key]: value,
},
});
updateCurrentWorkspaceSettings(settings);
});
const timezoneList = useMemo(() => getTimezoneList(), []);
const columns = useMemo(() => { const columns = useMemo(() => {
return [ return [
columnHelper.accessor( columnHelper.accessor(
@ -167,6 +196,36 @@ function PageComponent() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="text-lg font-bold">
{t('General')}
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex-1">{t('Timezone')}</div>
<div>
<Select
value={settings['timezone'] ?? dayjs.tz.guess()}
onValueChange={(value) =>
handleUpdateSettings('timezone', value)
}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timezoneList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(handleInvite)} onSubmit={form.handleSubmit(handleInvite)}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
import { describe, expect, test } from 'vitest';
import { getTimezoneList } from './date';
describe('getTimezoneList', () => {
test('should return timezone list with correct labels and values', () => {
const result = getTimezoneList();
expect(result).toMatchSnapshot();
});
});

View File

@ -68,3 +68,25 @@ export function formatDateWithUnit(val: dayjs.ConfigType, unit: DateUnit) {
return formatDate(val); return formatDate(val);
} }
function formatOffset(offset: number) {
const sign = offset >= 0 ? '+' : '-';
const absOffset = Math.abs(offset);
const hours = String(Math.floor(absOffset / 60)).padStart(2, '0');
const minutes = String(absOffset % 60).padStart(2, '0');
return `${sign}${hours}:${minutes}`;
}
export function getTimezoneList() {
const timezones = Intl.supportedValuesOf('timeZone');
return timezones.map((timezone) => {
const offset = dayjs().tz(timezone).utcOffset();
return {
label: `${timezone} (${formatOffset(offset)})`,
value: timezone,
};
});
}

View File

@ -1,8 +1,14 @@
import { version } from '@tianji/shared'; import { version } from '@tianji/shared';
import axios from 'axios'; import axios from 'axios';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import timezone from 'dayjs/plugin/timezone.js';
axios.defaults.headers.common['User-Agent'] = `tianji/${version}`; axios.defaults.headers.common['User-Agent'] = `tianji/${version}`;
dayjs.extend(utc);
dayjs.extend(timezone);
(BigInt.prototype as any).toJSON = function () { (BigInt.prototype as any).toJSON = function () {
const int = Number.parseInt(this.toString()); const int = Number.parseInt(this.toString());
return int ?? this.toString(); return int ?? this.toString();

View File

@ -1,7 +1,8 @@
import { Monitor, Notification } from '@prisma/client'; import { Monitor } from '@prisma/client';
import { prisma } from '../_client.js'; import { prisma } from '../_client.js';
import { MonitorRunner } from './runner.js'; import { MonitorRunner } from './runner.js';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { MonitorWithNotification } from './types.js';
export type MonitorUpsertData = Pick< export type MonitorUpsertData = Pick<
Monitor, Monitor,
@ -13,8 +14,6 @@ export type MonitorUpsertData = Pick<
payload: Record<string, any>; payload: Record<string, any>;
}; };
type MonitorWithNotification = Monitor & { notifications: Notification[] };
export class MonitorManager { export class MonitorManager {
private monitorRunner: Record<string, MonitorRunner> = {}; private monitorRunner: Record<string, MonitorRunner> = {};
private isStarted = false; private isStarted = false;
@ -64,9 +63,7 @@ export class MonitorManager {
delete this.monitorRunner[monitor.id]; delete this.monitorRunner[monitor.id];
} }
const runner = (this.monitorRunner[monitor.id] = new MonitorRunner( const runner = await this.createRunner(monitor);
monitor
));
runner.startMonitor(); runner.startMonitor();
return monitor; return monitor;
@ -112,7 +109,7 @@ export class MonitorManager {
Promise.all( Promise.all(
monitors.map(async (m) => { monitors.map(async (m) => {
try { try {
const runner = new MonitorRunner(m); const runner = await this.createRunner(m);
this.monitorRunner[m.id] = runner; this.monitorRunner[m.id] = runner;
await runner.startMonitor(); await runner.startMonitor();
} catch (err) { } catch (err) {
@ -128,8 +125,32 @@ export class MonitorManager {
return this.monitorRunner[monitorId]; return this.monitorRunner[monitorId];
} }
createRunner(monitor: MonitorWithNotification) { /**
* Restart all runner basic on workspace id
*/
restartWithWorkspaceId(workspaceId: string) {
Object.values(this.monitorRunner).map((runner) => {
if (runner.workspace.id === workspaceId) {
this.createRunner(runner.monitor);
}
});
}
/**
* create runner
*/
async createRunner(monitor: MonitorWithNotification) {
if (this.monitorRunner[monitor.id]) {
this.monitorRunner[monitor.id].stopMonitor();
}
const workspace = await prisma.workspace.findUniqueOrThrow({
where: {
id: monitor.workspaceId,
},
});
const runner = (this.monitorRunner[monitor.id] = new MonitorRunner( const runner = (this.monitorRunner[monitor.id] = new MonitorRunner(
workspace,
monitor monitor
)); ));

View File

@ -1,4 +1,4 @@
import { Monitor, Notification } from '@prisma/client'; import { Notification, Workspace } from '@prisma/client';
import { subscribeEventBus } from '../../ws/shared.js'; import { subscribeEventBus } from '../../ws/shared.js';
import { prisma } from '../_client.js'; import { prisma } from '../_client.js';
import { monitorProviders } from './provider/index.js'; import { monitorProviders } from './provider/index.js';
@ -8,6 +8,8 @@ import { logger } from '../../utils/logger.js';
import { token } from '../notification/token/index.js'; import { token } from '../notification/token/index.js';
import { ContentToken } from '../notification/token/type.js'; import { ContentToken } from '../notification/token/type.js';
import { createAuditLog } from '../auditLog.js'; import { createAuditLog } from '../auditLog.js';
import { MonitorWithNotification } from './types.js';
import { get } from 'lodash-es';
/** /**
* Class which actually run monitor data collect * Class which actually run monitor data collect
@ -17,7 +19,14 @@ export class MonitorRunner {
timer: NodeJS.Timeout | null = null; timer: NodeJS.Timeout | null = null;
retriedNum = 0; retriedNum = 0;
constructor(public monitor: Monitor & { notifications: Notification[] }) {} constructor(
public workspace: Workspace,
public monitor: MonitorWithNotification
) {}
getTimezone(): string {
return get(this.workspace, ['settings', 'timezone']) || 'utc';
}
/** /**
* Start single monitor * Start single monitor
@ -74,9 +83,9 @@ export class MonitorRunner {
); );
await this.notify(`[${monitor.name}] 🔴 Down`, [ await this.notify(`[${monitor.name}] 🔴 Down`, [
token.text( token.text(
`[${monitor.name}] 🔴 Down\nTime: ${dayjs().format( `[${monitor.name}] 🔴 Down\nTime: ${dayjs()
'YYYY-MM-DD HH:mm:ss (z)' .tz(this.getTimezone())
)}` .format('YYYY-MM-DD HH:mm:ss (z)')}`
), ),
]); ]);
currentStatus = 'DOWN'; currentStatus = 'DOWN';
@ -88,9 +97,9 @@ export class MonitorRunner {
); );
await this.notify(`[${monitor.name}] ✅ Up`, [ await this.notify(`[${monitor.name}] ✅ Up`, [
token.text( token.text(
`[${monitor.name}] ✅ Up\nTime: ${dayjs().format( `[${monitor.name}] ✅ Up\nTime: ${dayjs()
'YYYY-MM-DD HH:mm:ss (z)' .tz(this.getTimezone())
)}` .format('YYYY-MM-DD HH:mm:ss (z)')}`
), ),
]); ]);
currentStatus = 'UP'; currentStatus = 'UP';

View File

@ -0,0 +1,5 @@
import { Monitor, Notification } from '@prisma/client';
export type MonitorWithNotification = Monitor & {
notifications: Notification[];
};

View File

@ -292,7 +292,7 @@ export const monitorRouter = router({
}); });
let runner = monitorManager.getRunner(monitorId); let runner = monitorManager.getRunner(monitorId);
if (!runner) { if (!runner) {
runner = monitorManager.createRunner(monitor); runner = await monitorManager.createRunner(monitor);
} }
if (active === true) { if (active === true) {

View File

@ -270,6 +270,15 @@ export const workspaceRouter = router({
settings: merge({}, prev.settings, settings), settings: merge({}, prev.settings, settings),
}, },
}); });
if (
'timezone' in settings &&
get(prev, ['settings', 'timezone']) !== settings.timezone
) {
// should be restart all monitor
monitorManager.restartWithWorkspaceId(workspaceId);
}
return res; return res;
}), }),
invite: workspaceAdminProcedure invite: workspaceAdminProcedure