feat: add telemetry list

This commit is contained in:
moonrailgun 2024-02-18 00:47:22 +08:00
parent dd0ad8c5de
commit 5e720abb11
16 changed files with 388 additions and 20 deletions

View File

@ -9,7 +9,7 @@ import { Register } from './pages/Register';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './api/cache';
import { TokenLoginContainer } from './components/TokenLoginContainer';
import React, { Suspense, useRef } from 'react';
import React, { useRef } from 'react';
import { trpc, trpcClient } from './api/trpc';
import { MonitorPage } from './pages/Monitor';
import { WebsitePage } from './pages/Website';
@ -19,7 +19,7 @@ import { ConfigProvider, theme } from 'antd';
import clsx from 'clsx';
import { useSettingsStore } from './store/settings';
import { StatusPage } from './pages/Status';
import { Loading } from './components/Loading';
import { TelemetryPage } from './pages/Telemetry';
export const AppRoutes: React.FC = React.memo(() => {
const { info } = useUserStore();
@ -35,6 +35,7 @@ export const AppRoutes: React.FC = React.memo(() => {
<Route path="/monitor/*" element={<MonitorPage />} />
<Route path="/website/*" element={<WebsitePage />} />
<Route path="/servers" element={<Servers />} />
<Route path="/telemetry/*" element={<TelemetryPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Route>
) : (

View File

@ -0,0 +1,146 @@
import { t } from '@i18next-toolkit/react';
import { Button, Form, Input, Modal, Table } from 'antd';
import React, { useMemo, useState } from 'react';
import { AppRouterOutput, trpc } from '../../api/trpc';
import { useCurrentWorkspaceId } from '../../store/user';
import { type ColumnsType } from 'antd/es/table/interface';
import {
BarChartOutlined,
EditOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { PageHeader } from '../PageHeader';
import { useEvent } from '../../hooks/useEvent';
type TelemetryInfo = AppRouterOutput['telemetry']['all'][number];
export const TelemetryList: React.FC = React.memo(() => {
const workspaceId = useCurrentWorkspaceId();
const [isModalOpen, setIsModalOpen] = useState(false);
const [form] = Form.useForm<{ id?: string; name: string }>();
const upsertTelemetryMutation = trpc.telemetry.upsert.useMutation();
const utils = trpc.useUtils();
const handleAddTelemetry = useEvent(async () => {
await form.validateFields();
const values = form.getFieldsValue();
await upsertTelemetryMutation.mutateAsync({
telemetryId: values.id,
workspaceId,
name: values.name,
});
utils.telemetry.all.refetch();
setIsModalOpen(false);
form.resetFields();
});
const handleEditTelemetry = useEvent(async (info: TelemetryInfo) => {
setIsModalOpen(true);
form.setFieldsValue({
id: info.id,
name: info.name,
});
});
return (
<div>
<PageHeader
title={t('Telemetry')}
action={
<div>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => setIsModalOpen(true)}
>
{t('Add Telemetry')}
</Button>
</div>
}
/>
<TelemetryListTable onEdit={handleEditTelemetry} />
<Modal
title={t('Add Telemetry')}
open={isModalOpen}
okButtonProps={{
loading: upsertTelemetryMutation.isLoading,
}}
onOk={() => handleAddTelemetry()}
onCancel={() => setIsModalOpen(false)}
>
<Form layout="vertical" form={form}>
<Form.Item name="id" hidden={true} />
<Form.Item
label={t('Telemetry Name')}
name="name"
tooltip={t('Telemetry Name to Display')}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
});
TelemetryList.displayName = 'TelemetryList';
const TelemetryListTable: React.FC<{
onEdit: (info: TelemetryInfo) => void;
}> = React.memo((props) => {
const workspaceId = useCurrentWorkspaceId();
const { data = [], isLoading } = trpc.telemetry.all.useQuery({
workspaceId,
});
const navigate = useNavigate();
const columns = useMemo((): ColumnsType<TelemetryInfo> => {
return [
{
dataIndex: 'name',
title: t('Name'),
},
{
key: 'action',
render: (_, record) => {
return (
<div className="flex gap-2 justify-end">
<Button
icon={<EditOutlined />}
onClick={() => props.onEdit(record)}
>
{t('Edit')}
</Button>
<Button
icon={<BarChartOutlined />}
onClick={() => {
navigate(`/telemetry/${record.id}`);
}}
>
{t('View')}
</Button>
</div>
);
},
},
] as ColumnsType<TelemetryInfo>;
}, []);
return (
<Table
loading={isLoading}
dataSource={data}
columns={columns}
rowKey="id"
/>
);
});
TelemetryListTable.displayName = 'TelemetryListTable';

View File

@ -12,6 +12,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { RiMenuUnfoldLine } from 'react-icons/ri';
import { useTranslation } from '@i18next-toolkit/react';
import { LanguageSelector } from '../components/LanguageSelector';
import { useGlobalConfig } from '../hooks/useConfig';
export const Layout: React.FC = React.memo(() => {
const [params] = useSearchParams();
@ -34,6 +35,7 @@ export const Layout: React.FC = React.memo(() => {
const showHeader = !params.has('hideHeader');
const navigate = useNavigate();
const { t } = useTranslation();
const { alphaMode } = useGlobalConfig();
const accountEl = (
<Dropdown
@ -117,6 +119,14 @@ export const Layout: React.FC = React.memo(() => {
label={t('Servers')}
onClick={() => setOpenDraw(false)}
/>
{alphaMode && (
<MobileNavItem
to="/telemetry"
label={t('Telemetry')}
onClick={() => setOpenDraw(false)}
/>
)}
<MobileNavItem
to="/settings"
label={t('Settings')}
@ -147,6 +157,7 @@ export const Layout: React.FC = React.memo(() => {
<NavItem to="/monitor" label={t('Monitor')} />
<NavItem to="/website" label={t('Website')} />
<NavItem to="/servers" label={t('Servers')} />
<NavItem to="/telemetry" label={t('Telemetry')} />
<NavItem to="/settings" label={t('Settings')} />
</div>

View File

@ -0,0 +1,7 @@
import React from 'react';
import { TelemetryList } from '../../components/telemetry/TelemetryList';
export const TelemetryPage: React.FC = React.memo(() => {
return <TelemetryList />;
});
TelemetryPage.displayName = 'TelemetryPage';

View File

@ -0,0 +1,35 @@
-- AlterTable
ALTER TABLE "TelemetryEvent" ADD COLUMN "telemetryId" VARCHAR(30);
-- AlterTable
ALTER TABLE "TelemetrySession" ADD COLUMN "telemetryId" VARCHAR(30);
-- CreateTable
CREATE TABLE "Telemetry" (
"id" VARCHAR(30) NOT NULL,
"workspaceId" VARCHAR(30) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
"deletedAt" TIMESTAMPTZ(6),
CONSTRAINT "Telemetry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Telemetry_id_key" ON "Telemetry"("id");
-- CreateIndex
CREATE INDEX "Telemetry_workspaceId_idx" ON "Telemetry"("workspaceId");
-- CreateIndex
CREATE INDEX "Telemetry_createdAt_idx" ON "Telemetry"("createdAt");
-- AddForeignKey
ALTER TABLE "Telemetry" ADD CONSTRAINT "Telemetry_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TelemetrySession" ADD CONSTRAINT "TelemetrySession_telemetryId_fkey" FOREIGN KEY ("telemetryId") REFERENCES "Telemetry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TelemetryEvent" ADD CONSTRAINT "TelemetryEvent_telemetryId_fkey" FOREIGN KEY ("telemetryId") REFERENCES "Telemetry"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -49,6 +49,7 @@ model Workspace {
notifications Notification[]
monitors Monitor[]
monitorStatusPages MonitorStatusPage[]
telemetryList Telemetry[]
// for user currentWorkspace
selectedUsers User[] // user list who select this workspace, not use in most of case
@ -201,9 +202,27 @@ model WebsiteSessionData {
@@index([sessionId])
}
model Telemetry {
id String @id @unique @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30)
name String @db.VarChar(100)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
deletedAt DateTime? @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onUpdate: Cascade, onDelete: Cascade)
sessions TelemetrySession[]
events TelemetryEvent[]
@@index([workspaceId])
@@index([createdAt])
}
model TelemetrySession {
id String @id @unique @db.Uuid
workspaceId String @db.VarChar(30)
telemetryId String? @db.VarChar(30) // if null, means Default
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
@ -217,6 +236,7 @@ model TelemetrySession {
accuracyRadius Int?
createdAt DateTime @default(now()) @db.Timestamptz(6)
telemetry Telemetry? @relation(fields: [telemetryId], references: [id], onUpdate: Cascade, onDelete: Cascade)
telemetryEvent TelemetryEvent[]
@@index([createdAt])
@ -225,8 +245,9 @@ model TelemetrySession {
model TelemetryEvent {
id String @id() @default(cuid()) @db.VarChar(30)
sessionId String @db.Uuid
workspaceId String @db.VarChar(30)
telemetryId String? @db.VarChar(30) // if null, means Default
sessionId String @db.Uuid
eventName String? @db.VarChar(100)
urlOrigin String @db.VarChar(500)
urlPath String @db.VarChar(500)
@ -236,7 +257,8 @@ model TelemetryEvent {
payload Json? @db.Json // Other payload info get from query params, should be a object
createdAt DateTime @default(now()) @db.Timestamptz(6)
session TelemetrySession @relation(fields: [sessionId], references: [id], onUpdate: Cascade, onDelete: Cascade)
telemetry Telemetry? @relation(fields: [telemetryId], references: [id], onUpdate: Cascade, onDelete: Cascade)
session TelemetrySession @relation(fields: [sessionId], references: [id], onUpdate: Cascade, onDelete: Cascade)
@@index([createdAt])
@@index([sessionId])

View File

@ -6,6 +6,7 @@ export * from "./websitesession"
export * from "./websiteevent"
export * from "./websiteeventdata"
export * from "./websitesessiondata"
export * from "./telemetry"
export * from "./telemetrysession"
export * from "./telemetryevent"
export * from "./notification"

View File

@ -0,0 +1,29 @@
import * as z from "zod"
import * as imports from "./schemas"
import { CompleteWorkspace, RelatedWorkspaceModelSchema, CompleteTelemetrySession, RelatedTelemetrySessionModelSchema, CompleteTelemetryEvent, RelatedTelemetryEventModelSchema } from "./index"
export const TelemetryModelSchema = z.object({
id: z.string(),
workspaceId: z.string(),
name: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
deletedAt: z.date().nullish(),
})
export interface CompleteTelemetry extends z.infer<typeof TelemetryModelSchema> {
workspace: CompleteWorkspace
sessions: CompleteTelemetrySession[]
events: CompleteTelemetryEvent[]
}
/**
* RelatedTelemetryModelSchema contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedTelemetryModelSchema: z.ZodSchema<CompleteTelemetry> = z.lazy(() => TelemetryModelSchema.extend({
workspace: RelatedWorkspaceModelSchema,
sessions: RelatedTelemetrySessionModelSchema.array(),
events: RelatedTelemetryEventModelSchema.array(),
}))

View File

@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas"
import { CompleteTelemetrySession, RelatedTelemetrySessionModelSchema } from "./index"
import { CompleteTelemetry, RelatedTelemetryModelSchema, CompleteTelemetrySession, RelatedTelemetrySessionModelSchema } from "./index"
// Helper schema for JSON fields
type Literal = boolean | number | string
@ -10,8 +10,9 @@ const jsonSchema: z.ZodSchema<Json> = z.lazy(() => z.union([literalSchema, z.arr
export const TelemetryEventModelSchema = z.object({
id: z.string(),
sessionId: z.string(),
workspaceId: z.string(),
telemetryId: z.string().nullish(),
sessionId: z.string(),
eventName: z.string().nullish(),
urlOrigin: z.string(),
urlPath: z.string(),
@ -23,6 +24,7 @@ export const TelemetryEventModelSchema = z.object({
})
export interface CompleteTelemetryEvent extends z.infer<typeof TelemetryEventModelSchema> {
telemetry?: CompleteTelemetry | null
session: CompleteTelemetrySession
}
@ -32,5 +34,6 @@ export interface CompleteTelemetryEvent extends z.infer<typeof TelemetryEventMod
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedTelemetryEventModelSchema: z.ZodSchema<CompleteTelemetryEvent> = z.lazy(() => TelemetryEventModelSchema.extend({
telemetry: RelatedTelemetryModelSchema.nullish(),
session: RelatedTelemetrySessionModelSchema,
}))

View File

@ -1,10 +1,11 @@
import * as z from "zod"
import * as imports from "./schemas"
import { CompleteTelemetryEvent, RelatedTelemetryEventModelSchema } from "./index"
import { CompleteTelemetry, RelatedTelemetryModelSchema, CompleteTelemetryEvent, RelatedTelemetryEventModelSchema } from "./index"
export const TelemetrySessionModelSchema = z.object({
id: z.string(),
workspaceId: z.string(),
telemetryId: z.string().nullish(),
hostname: z.string().nullish(),
browser: z.string().nullish(),
os: z.string().nullish(),
@ -20,6 +21,7 @@ export const TelemetrySessionModelSchema = z.object({
})
export interface CompleteTelemetrySession extends z.infer<typeof TelemetrySessionModelSchema> {
telemetry?: CompleteTelemetry | null
telemetryEvent: CompleteTelemetryEvent[]
}
@ -29,5 +31,6 @@ export interface CompleteTelemetrySession extends z.infer<typeof TelemetrySessio
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedTelemetrySessionModelSchema: z.ZodSchema<CompleteTelemetrySession> = z.lazy(() => TelemetrySessionModelSchema.extend({
telemetry: RelatedTelemetryModelSchema.nullish(),
telemetryEvent: RelatedTelemetryEventModelSchema.array(),
}))

View File

@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas"
import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteUser, RelatedUserModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema } from "./index"
import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteUser, RelatedUserModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema } from "./index"
// Helper schema for JSON fields
type Literal = boolean | number | string
@ -30,6 +30,7 @@ export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema>
notifications: CompleteNotification[]
monitors: CompleteMonitor[]
monitorStatusPages: CompleteMonitorStatusPage[]
telemetryList: CompleteTelemetry[]
selectedUsers: CompleteUser[]
workspaceDailyUsage: CompleteWorkspaceDailyUsage[]
workspaceAuditLog: CompleteWorkspaceAuditLog[]
@ -46,6 +47,7 @@ export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.laz
notifications: RelatedNotificationModelSchema.array(),
monitors: RelatedMonitorModelSchema.array(),
monitorStatusPages: RelatedMonitorStatusPageModelSchema.array(),
telemetryList: RelatedTelemetryModelSchema.array(),
selectedUsers: RelatedUserModelSchema.array(),
workspaceDailyUsage: RelatedWorkspaceDailyUsageModelSchema.array(),
workspaceAuditLog: RelatedWorkspaceAuditLogModelSchema.array(),

View File

@ -1,20 +1,20 @@
import { z } from 'zod';
import { router, workspaceProcedure } from '../trpc';
import { OpenApiMetaInfo, router, workspaceProcedure } from '../trpc';
import { OPENAPI_TAG } from '../../utils/const';
import { WorkspaceAuditLogModelSchema } from '../../prisma/zod';
import { prisma } from '../../model/_client';
import { fetchDataByCursor } from '../../utils/prisma';
import { OpenApiMeta } from 'trpc-openapi';
export const auditLogRouter = router({
fetchByCursor: workspaceProcedure
.meta({
openapi: {
.meta(
buildAuditLogOpenapi({
method: 'GET',
path: '/fetchByCursor',
tags: [OPENAPI_TAG.AUDIT_LOG],
description: 'Fetch workspace audit log',
},
})
})
)
.input(
z.object({
limit: z.number().min(1).max(100).default(50),
@ -47,3 +47,14 @@ export const auditLogRouter = router({
};
}),
});
function buildAuditLogOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
return {
openapi: {
tags: [OPENAPI_TAG.AUDIT_LOG],
protect: true,
...meta,
path: `/audit${meta.path}`,
},
};
}

View File

@ -1,18 +1,18 @@
import { z } from 'zod';
import { router, workspaceProcedure } from '../trpc';
import { OpenApiMetaInfo, router, workspaceProcedure } from '../trpc';
import { OPENAPI_TAG } from '../../utils/const';
import { prisma } from '../../model/_client';
import { OpenApiMeta } from 'trpc-openapi';
export const billingRouter = router({
usage: workspaceProcedure
.meta({
openapi: {
.meta(
buildBillingOpenapi({
method: 'GET',
path: '/usage',
tags: [OPENAPI_TAG.BILLING],
description: 'get workspace usage',
},
})
})
)
.input(
z.object({
startAt: z.number(),
@ -51,3 +51,14 @@ export const billingRouter = router({
};
}),
});
function buildBillingOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
return {
openapi: {
tags: [OPENAPI_TAG.BILLING],
protect: true,
...meta,
path: `/billing${meta.path}`,
},
};
}

View File

@ -8,6 +8,7 @@ import { globalRouter } from './global';
import { serverStatusRouter } from './serverStatus';
import { auditLogRouter } from './auditLog';
import { billingRouter } from './billing';
import { telemetryRouter } from './telemetry';
export const appRouter = router({
global: globalRouter,
@ -16,6 +17,7 @@ export const appRouter = router({
website: websiteRouter,
notification: notificationRouter,
monitor: monitorRouter,
telemetry: telemetryRouter,
serverStatus: serverStatusRouter,
auditLog: auditLogRouter,
billing: billingRouter,

View File

@ -0,0 +1,83 @@
import { z } from 'zod';
import {
OpenApiMetaInfo,
router,
workspaceOwnerProcedure,
workspaceProcedure,
} from '../trpc';
import { OPENAPI_TAG } from '../../utils/const';
import { prisma } from '../../model/_client';
import { TelemetryModelSchema } from '../../prisma/zod';
import { OpenApiMeta } from 'trpc-openapi';
export const telemetryRouter = router({
all: workspaceProcedure
.meta(
buildTelemetryOpenapi({
method: 'GET',
path: '/all',
})
)
.output(z.array(TelemetryModelSchema))
.query(async ({ input }) => {
const { workspaceId } = input;
const res = await prisma.telemetry.findMany({
where: {
workspaceId,
},
orderBy: {
updatedAt: 'desc',
},
});
return res;
}),
upsert: workspaceOwnerProcedure
.meta(
buildTelemetryOpenapi({
method: 'POST',
path: '/upsert',
})
)
.input(
z.object({
telemetryId: z.string().optional(),
name: z.string(),
})
)
.output(TelemetryModelSchema)
.mutation(async ({ input }) => {
const { workspaceId, telemetryId, name } = input;
if (telemetryId) {
return prisma.telemetry.update({
where: {
id: telemetryId,
workspaceId,
},
data: {
name,
},
});
} else {
return prisma.telemetry.create({
data: {
workspaceId,
name,
},
});
}
}),
});
function buildTelemetryOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
return {
openapi: {
tags: [OPENAPI_TAG.TELEMETRY],
protect: true,
...meta,
path: `/workspace/{workspaceId}${meta.path}`,
},
};
}

View File

@ -109,4 +109,5 @@ export enum OPENAPI_TAG {
MONITOR = 'Monitor',
AUDIT_LOG = 'AuditLog',
BILLING = 'Billing',
TELEMETRY = 'Telemetry',
}