diff --git a/src/client/App.tsx b/src/client/App.tsx index a42aec5..7d4844c 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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(() => { } /> } /> } /> + } /> } /> ) : ( diff --git a/src/client/components/telemetry/TelemetryList.tsx b/src/client/components/telemetry/TelemetryList.tsx new file mode 100644 index 0000000..5aae552 --- /dev/null +++ b/src/client/components/telemetry/TelemetryList.tsx @@ -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 ( +
+ + +
+ } + /> + + + + handleAddTelemetry()} + onCancel={() => setIsModalOpen(false)} + > +
+
+ + ); +}); +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 => { + return [ + { + dataIndex: 'name', + title: t('Name'), + }, + { + key: 'action', + render: (_, record) => { + return ( +
+ + +
+ ); + }, + }, + ] as ColumnsType; + }, []); + + return ( + + ); +}); +TelemetryListTable.displayName = 'TelemetryListTable'; diff --git a/src/client/pages/Layout.tsx b/src/client/pages/Layout.tsx index 3a627c2..22c2c93 100644 --- a/src/client/pages/Layout.tsx +++ b/src/client/pages/Layout.tsx @@ -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 = ( { label={t('Servers')} onClick={() => setOpenDraw(false)} /> + {alphaMode && ( + setOpenDraw(false)} + /> + )} + { + diff --git a/src/client/pages/Telemetry/index.tsx b/src/client/pages/Telemetry/index.tsx new file mode 100644 index 0000000..76b7c71 --- /dev/null +++ b/src/client/pages/Telemetry/index.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { TelemetryList } from '../../components/telemetry/TelemetryList'; + +export const TelemetryPage: React.FC = React.memo(() => { + return ; +}); +TelemetryPage.displayName = 'TelemetryPage'; diff --git a/src/server/prisma/migrations/20240217104338_add_named_telemetry_list/migration.sql b/src/server/prisma/migrations/20240217104338_add_named_telemetry_list/migration.sql new file mode 100644 index 0000000..7d4644a --- /dev/null +++ b/src/server/prisma/migrations/20240217104338_add_named_telemetry_list/migration.sql @@ -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; diff --git a/src/server/prisma/schema.prisma b/src/server/prisma/schema.prisma index ef2352c..f6a338b 100644 --- a/src/server/prisma/schema.prisma +++ b/src/server/prisma/schema.prisma @@ -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]) diff --git a/src/server/prisma/zod/index.ts b/src/server/prisma/zod/index.ts index e9c5a6f..6a65c69 100644 --- a/src/server/prisma/zod/index.ts +++ b/src/server/prisma/zod/index.ts @@ -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" diff --git a/src/server/prisma/zod/telemetry.ts b/src/server/prisma/zod/telemetry.ts new file mode 100644 index 0000000..42d179d --- /dev/null +++ b/src/server/prisma/zod/telemetry.ts @@ -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 { + 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 = z.lazy(() => TelemetryModelSchema.extend({ + workspace: RelatedWorkspaceModelSchema, + sessions: RelatedTelemetrySessionModelSchema.array(), + events: RelatedTelemetryEventModelSchema.array(), +})) diff --git a/src/server/prisma/zod/telemetryevent.ts b/src/server/prisma/zod/telemetryevent.ts index 4aeb4f0..0ff0839 100644 --- a/src/server/prisma/zod/telemetryevent.ts +++ b/src/server/prisma/zod/telemetryevent.ts @@ -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 = 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 { + telemetry?: CompleteTelemetry | null session: CompleteTelemetrySession } @@ -32,5 +34,6 @@ export interface CompleteTelemetryEvent extends z.infer = z.lazy(() => TelemetryEventModelSchema.extend({ + telemetry: RelatedTelemetryModelSchema.nullish(), session: RelatedTelemetrySessionModelSchema, })) diff --git a/src/server/prisma/zod/telemetrysession.ts b/src/server/prisma/zod/telemetrysession.ts index f6ef3d0..f05edf9 100644 --- a/src/server/prisma/zod/telemetrysession.ts +++ b/src/server/prisma/zod/telemetrysession.ts @@ -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 { + telemetry?: CompleteTelemetry | null telemetryEvent: CompleteTelemetryEvent[] } @@ -29,5 +31,6 @@ export interface CompleteTelemetrySession extends z.infer = z.lazy(() => TelemetrySessionModelSchema.extend({ + telemetry: RelatedTelemetryModelSchema.nullish(), telemetryEvent: RelatedTelemetryEventModelSchema.array(), })) diff --git a/src/server/prisma/zod/workspace.ts b/src/server/prisma/zod/workspace.ts index 73662fe..2e842f1 100644 --- a/src/server/prisma/zod/workspace.ts +++ b/src/server/prisma/zod/workspace.ts @@ -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 notifications: CompleteNotification[] monitors: CompleteMonitor[] monitorStatusPages: CompleteMonitorStatusPage[] + telemetryList: CompleteTelemetry[] selectedUsers: CompleteUser[] workspaceDailyUsage: CompleteWorkspaceDailyUsage[] workspaceAuditLog: CompleteWorkspaceAuditLog[] @@ -46,6 +47,7 @@ export const RelatedWorkspaceModelSchema: z.ZodSchema = z.laz notifications: RelatedNotificationModelSchema.array(), monitors: RelatedMonitorModelSchema.array(), monitorStatusPages: RelatedMonitorStatusPageModelSchema.array(), + telemetryList: RelatedTelemetryModelSchema.array(), selectedUsers: RelatedUserModelSchema.array(), workspaceDailyUsage: RelatedWorkspaceDailyUsageModelSchema.array(), workspaceAuditLog: RelatedWorkspaceAuditLogModelSchema.array(), diff --git a/src/server/trpc/routers/auditLog.ts b/src/server/trpc/routers/auditLog.ts index de6efcd..a3a9315 100644 --- a/src/server/trpc/routers/auditLog.ts +++ b/src/server/trpc/routers/auditLog.ts @@ -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}`, + }, + }; +} diff --git a/src/server/trpc/routers/billing.ts b/src/server/trpc/routers/billing.ts index 07e35f0..d07f7a9 100644 --- a/src/server/trpc/routers/billing.ts +++ b/src/server/trpc/routers/billing.ts @@ -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}`, + }, + }; +} diff --git a/src/server/trpc/routers/index.ts b/src/server/trpc/routers/index.ts index 8f5da64..93b753d 100644 --- a/src/server/trpc/routers/index.ts +++ b/src/server/trpc/routers/index.ts @@ -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, diff --git a/src/server/trpc/routers/telemetry.ts b/src/server/trpc/routers/telemetry.ts new file mode 100644 index 0000000..df66c08 --- /dev/null +++ b/src/server/trpc/routers/telemetry.ts @@ -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}`, + }, + }; +} diff --git a/src/server/utils/const.ts b/src/server/utils/const.ts index 8b72749..92274f7 100644 --- a/src/server/utils/const.ts +++ b/src/server/utils/const.ts @@ -109,4 +109,5 @@ export enum OPENAPI_TAG { MONITOR = 'Monitor', AUDIT_LOG = 'AuditLog', BILLING = 'Billing', + TELEMETRY = 'Telemetry', }