feat: add custom domain support for status page
This commit is contained in:
parent
f0bd4dd993
commit
a73720411c
@ -1,7 +1,7 @@
|
|||||||
import { Switch, Divider, Form, Input, Typography } from 'antd';
|
import { Switch, Divider, Form, Input, Typography } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MonitorPicker } from '../MonitorPicker';
|
import { MonitorPicker } from '../MonitorPicker';
|
||||||
import { urlSlugValidator } from '../../../utils/validator';
|
import { domainValidator, urlSlugValidator } from '../../../utils/validator';
|
||||||
import { useTranslation } from '@i18next-toolkit/react';
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LuMinusCircle, LuPlus } from 'react-icons/lu';
|
import { LuMinusCircle, LuPlus } from 'react-icons/lu';
|
||||||
@ -12,6 +12,7 @@ export interface MonitorStatusPageEditFormValues {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
monitorList: PrismaJson.MonitorStatusPageList;
|
monitorList: PrismaJson.MonitorStatusPageList;
|
||||||
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MonitorStatusPageEditFormProps {
|
interface MonitorStatusPageEditFormProps {
|
||||||
@ -71,6 +72,25 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
|
|||||||
<Input addonBefore={`${window.origin}/status/`} />
|
<Input addonBefore={`${window.origin}/status/`} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('Custom Domain')}
|
||||||
|
name="domain"
|
||||||
|
extra={
|
||||||
|
<div>
|
||||||
|
{t(
|
||||||
|
'You can config your status page in your own domain, for example: status.example.com'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: domainValidator,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.List name="monitorList">
|
<Form.List name="monitorList">
|
||||||
{(fields, { add, remove }, { errors }) => {
|
{(fields, { add, remove }, { errors }) => {
|
||||||
return (
|
return (
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { trpc } from '../../../api/trpc';
|
import { trpc } from '../../../api/trpc';
|
||||||
import { useAllowEdit } from './useAllowEdit';
|
import { useAllowEdit } from './useAllowEdit';
|
||||||
import {
|
import {
|
||||||
@ -38,6 +38,17 @@ export const MonitorStatusPage: React.FC<MonitorStatusPageProps> = React.memo(
|
|||||||
|
|
||||||
const monitorList = info?.monitorList ?? [];
|
const monitorList = info?.monitorList ?? [];
|
||||||
|
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
if (!info) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
domain: info.domain ?? '',
|
||||||
|
};
|
||||||
|
}, [info]);
|
||||||
|
|
||||||
const [{ loading }, handleSave] = useRequest(
|
const [{ loading }, handleSave] = useRequest(
|
||||||
async (values: MonitorStatusPageEditFormValues) => {
|
async (values: MonitorStatusPageEditFormValues) => {
|
||||||
if (!info) {
|
if (!info) {
|
||||||
@ -84,7 +95,7 @@ export const MonitorStatusPage: React.FC<MonitorStatusPageProps> = React.memo(
|
|||||||
<div className="w-1/3 overflow-auto border-r border-gray-300 px-4 py-8 dark:border-gray-600">
|
<div className="w-1/3 overflow-auto border-r border-gray-300 px-4 py-8 dark:border-gray-600">
|
||||||
<MonitorStatusPageEditForm
|
<MonitorStatusPageEditForm
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
initialValues={info ?? {}}
|
initialValues={initialValues}
|
||||||
onFinish={handleSave}
|
onFinish={handleSave}
|
||||||
onCancel={() => setEditMode(false)}
|
onCancel={() => setEditMode(false)}
|
||||||
/>
|
/>
|
||||||
|
@ -17,6 +17,15 @@ export const hostnameValidator: Validator = (rule, value, callback) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const domainValidator: Validator = (rule, value, callback) => {
|
||||||
|
try {
|
||||||
|
z.string().regex(hostnameRegex).parse(value);
|
||||||
|
callback();
|
||||||
|
} catch (err) {
|
||||||
|
callback('Not valid, it should be domain, for example: example.com');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const urlSlugValidator: Validator = (rule, value, callback) => {
|
export const urlSlugValidator: Validator = (rule, value, callback) => {
|
||||||
try {
|
try {
|
||||||
z.string().regex(slugRegex).parse(value);
|
z.string().regex(slugRegex).parse(value);
|
||||||
|
@ -19,6 +19,7 @@ import { logger } from './utils/logger';
|
|||||||
import { monitorRouter } from './router/monitor';
|
import { monitorRouter } from './router/monitor';
|
||||||
import { healthRouter } from './router/health';
|
import { healthRouter } from './router/health';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { monitorPageManager } from './model/monitor/page/manager';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -27,7 +28,6 @@ app.use(express.json());
|
|||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
app.use(morgan('tiny'));
|
app.use(morgan('tiny'));
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.static('public'));
|
|
||||||
|
|
||||||
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
|
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
@ -60,6 +60,26 @@ if (env.allowOpenapi) {
|
|||||||
app.use('/open', trpcOpenapiHttpHandler);
|
app.use('/open', trpcOpenapiHttpHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom Status Page
|
||||||
|
app.use('/*', (req, res, next) => {
|
||||||
|
if (req.baseUrl === '/' || req.baseUrl === '') {
|
||||||
|
const customDomain = monitorPageManager.findPageDomain(req.hostname);
|
||||||
|
if (customDomain) {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.send(
|
||||||
|
`<body style="padding: 0; margin: 0;"><iframe style="border:none; width: 100%; height: 100%;" title="" src="/status/${customDomain.slug}" /></body>`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
// fallback
|
// fallback
|
||||||
app.use('/*', (req, res) => {
|
app.use('/*', (req, res) => {
|
||||||
if (req.method === 'GET' && req.accepts('html')) {
|
if (req.method === 'GET' && req.accepts('html')) {
|
||||||
|
84
src/server/model/monitor/page/manager.ts
Normal file
84
src/server/model/monitor/page/manager.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { logger } from '../../../utils/logger';
|
||||||
|
import { prisma } from '../../_client';
|
||||||
|
|
||||||
|
class MonitorPageManager {
|
||||||
|
private customDomainPage: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
workspaceId: string;
|
||||||
|
pageId: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
> = {}; // key: domain
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
prisma.monitorStatusPage
|
||||||
|
.findMany({
|
||||||
|
where: {
|
||||||
|
domain: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
domain: true,
|
||||||
|
workspaceId: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
res.forEach((item) => {
|
||||||
|
if (!item.domain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.customDomainPage[item.domain] = {
|
||||||
|
pageId: item.id,
|
||||||
|
workspaceId: item.workspaceId,
|
||||||
|
slug: item.slug,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Loaded ${res.length} custom domain for status page`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('Cannot load monitor page domain list:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check domain existed
|
||||||
|
* if domain not been used, return true
|
||||||
|
*/
|
||||||
|
async checkDomain(domain: string) {
|
||||||
|
const res = await prisma.monitorStatusPage.findFirst({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return !res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePageDomain(
|
||||||
|
domain: string,
|
||||||
|
info: {
|
||||||
|
workspaceId: string;
|
||||||
|
pageId: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.customDomainPage[domain] = info;
|
||||||
|
logger.info(`Update page domain: ${domain} to page ${info.pageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
findPageDomain(domain: string) {
|
||||||
|
if (!this.customDomainPage[domain]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.customDomainPage[domain];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monitorPageManager = new MonitorPageManager();
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MonitorStatusPage" ADD COLUMN "domain" TEXT;
|
@ -355,6 +355,7 @@ model MonitorStatusPage {
|
|||||||
/// [MonitorStatusPageList]
|
/// [MonitorStatusPageList]
|
||||||
/// @zod.custom(imports.MonitorStatusPageListSchema)
|
/// @zod.custom(imports.MonitorStatusPageListSchema)
|
||||||
monitorList Json @default("[]") // monitor list
|
monitorList Json @default("[]") // monitor list
|
||||||
|
domain String? // custom domain which can add cname record
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ export const MonitorStatusPageModelSchema = z.object({
|
|||||||
* [MonitorStatusPageList]
|
* [MonitorStatusPageList]
|
||||||
*/
|
*/
|
||||||
monitorList: imports.MonitorStatusPageListSchema,
|
monitorList: imports.MonitorStatusPageListSchema,
|
||||||
|
domain: z.string().nullish(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
})
|
})
|
||||||
|
@ -31,6 +31,7 @@ import {
|
|||||||
MonitorInfoWithNotificationIds,
|
MonitorInfoWithNotificationIds,
|
||||||
MonitorPublicInfoSchema,
|
MonitorPublicInfoSchema,
|
||||||
} from '../../model/_schema/monitor';
|
} from '../../model/_schema/monitor';
|
||||||
|
import { monitorPageManager } from '../../model/monitor/page/manager';
|
||||||
|
|
||||||
export const monitorRouter = router({
|
export const monitorRouter = router({
|
||||||
all: workspaceProcedure
|
all: workspaceProcedure
|
||||||
@ -599,12 +600,14 @@ export const monitorRouter = router({
|
|||||||
MonitorStatusPageModelSchema.pick({
|
MonitorStatusPageModelSchema.pick({
|
||||||
description: true,
|
description: true,
|
||||||
monitorList: true,
|
monitorList: true,
|
||||||
|
domain: true,
|
||||||
}).partial()
|
}).partial()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.output(MonitorStatusPageModelSchema)
|
.output(MonitorStatusPageModelSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { workspaceId, slug, title, description, monitorList } = input;
|
const { workspaceId, slug, title, description, monitorList, domain } =
|
||||||
|
input;
|
||||||
|
|
||||||
const existSlugCount = await prisma.monitorStatusPage.count({
|
const existSlugCount = await prisma.monitorStatusPage.count({
|
||||||
where: {
|
where: {
|
||||||
@ -616,17 +619,30 @@ export const monitorRouter = router({
|
|||||||
throw new Error('This slug has been existed');
|
throw new Error('This slug has been existed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await prisma.monitorStatusPage.create({
|
if (domain && !(await monitorPageManager.checkDomain(domain))) {
|
||||||
|
throw new Error('This domain has been used');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await prisma.monitorStatusPage.create({
|
||||||
data: {
|
data: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
monitorList,
|
monitorList,
|
||||||
|
domain: domain || null, // make sure not ''
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
if (page.domain) {
|
||||||
|
monitorPageManager.updatePageDomain(page.domain, {
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
pageId: page.id,
|
||||||
|
slug: page.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
}),
|
}),
|
||||||
editPage: workspaceOwnerProcedure
|
editPage: workspaceOwnerProcedure
|
||||||
.meta(
|
.meta(
|
||||||
@ -644,12 +660,14 @@ export const monitorRouter = router({
|
|||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
monitorList: true,
|
monitorList: true,
|
||||||
|
domain: true,
|
||||||
}).partial()
|
}).partial()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.output(MonitorStatusPageModelSchema)
|
.output(MonitorStatusPageModelSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { id, workspaceId, slug, title, description, monitorList } = input;
|
const { id, workspaceId, slug, title, description, monitorList, domain } =
|
||||||
|
input;
|
||||||
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
const existSlugCount = await prisma.monitorStatusPage.count({
|
const existSlugCount = await prisma.monitorStatusPage.count({
|
||||||
@ -666,7 +684,11 @@ export const monitorRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return prisma.monitorStatusPage.update({
|
if (domain && !(await monitorPageManager.checkDomain(domain))) {
|
||||||
|
throw new Error('This domain has been used');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await prisma.monitorStatusPage.update({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@ -676,8 +698,19 @@ export const monitorRouter = router({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
monitorList,
|
monitorList,
|
||||||
|
domain: domain || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (page.domain) {
|
||||||
|
monitorPageManager.updatePageDomain(page.domain, {
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
pageId: page.id,
|
||||||
|
slug: page.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
}),
|
}),
|
||||||
deletePage: workspaceOwnerProcedure
|
deletePage: workspaceOwnerProcedure
|
||||||
.meta(
|
.meta(
|
||||||
|
@ -3,6 +3,7 @@ import { Server as HTTPServer } from 'http';
|
|||||||
import { jwtVerify } from '../middleware/auth';
|
import { jwtVerify } from '../middleware/auth';
|
||||||
import { socketEventBus } from './shared';
|
import { socketEventBus } from './shared';
|
||||||
import { isCuid } from '../utils/common';
|
import { isCuid } from '../utils/common';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
export function initSocketio(httpServer: HTTPServer) {
|
export function initSocketio(httpServer: HTTPServer) {
|
||||||
const io = new SocketIOServer(httpServer, {
|
const io = new SocketIOServer(httpServer, {
|
||||||
@ -30,7 +31,7 @@ export function initSocketio(httpServer: HTTPServer) {
|
|||||||
try {
|
try {
|
||||||
const user = jwtVerify(token);
|
const user = jwtVerify(token);
|
||||||
|
|
||||||
console.log('[Socket] Authenticated via JWT:', user.username);
|
logger.info('[Socket] Authenticated via JWT:', user.username);
|
||||||
|
|
||||||
socket.data.user = user;
|
socket.data.user = user;
|
||||||
socket.data.token = token;
|
socket.data.token = token;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user