feat: Add Zod-based typing for API responses across frontend and backend (#1892)

## Summary

Define API response Zod schemas in common-utils for alerts, webhooks, team, installation, and me endpoints. Apply Response<T> typing on backend route handlers with explicit Mongoose-to-JSON serialization (ObjectId, Date, Map). Replace all `any` types and `as Promise<T>` casts in frontend TanStack Query hooks with proper generics.

### How to test locally or on Vercel

1. `yarn dev`
2. Interact with app, ensuring nothing is broken

### References

- Linear Issue: HDX-3464
This commit is contained in:
Aaron Knudtson 2026-03-13 09:55:26 -04:00 committed by GitHub
parent 9682eb4d51
commit 359b58744e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 498 additions and 240 deletions

View file

@ -0,0 +1,7 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/api': patch
'@hyperdx/app': patch
---
fix: add explicit api typing to all api routes and frontend hooks

View file

@ -1,5 +1,5 @@
import type { AlertsApiResponse } from '@hyperdx/common-utils/dist/types';
import express from 'express';
import _ from 'lodash';
import { ObjectId } from 'mongodb';
import { z } from 'zod';
import { processRequest, validateRequest } from 'zod-express-middleware';
@ -17,7 +17,8 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
const router = express.Router();
router.get('/', async (req, res, next) => {
type AlertsExpRes = express.Response<AlertsApiResponse>;
router.get('/', async (req, res: AlertsExpRes, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
@ -26,7 +27,7 @@ router.get('/', async (req, res, next) => {
const alerts = await getAlertsEnhanced(teamId);
const data = await Promise.all(
const data: AlertsApiResponse['data'] = await Promise.all(
alerts.map(async alert => {
const history = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
@ -34,50 +35,79 @@ router.get('/', async (req, res, next) => {
});
return {
history,
_id: alert._id.toString(),
interval: alert.interval,
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
scheduleStartAt: alert.scheduleStartAt?.toISOString() ?? undefined,
threshold: alert.threshold,
thresholdType: alert.thresholdType,
channel: { type: alert.channel.type ?? undefined },
state: alert.state,
source: alert.source,
tileId: alert.tileId,
name: alert.name,
message: alert.message,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
createdAt: (alert as any).createdAt?.toISOString?.() ?? '',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
updatedAt: (alert as any).updatedAt?.toISOString?.() ?? '',
history: history.map(h => ({
counts: h.counts,
createdAt: h.createdAt.toISOString(),
state: h.state,
lastValues: h.lastValues.map(lv => ({
startTime: lv.startTime.toISOString(),
count: lv.count,
})),
})),
silenced: alert.silenced
? {
by: alert.silenced.by?.email,
at: alert.silenced.at,
until: alert.silenced.until,
by: alert.silenced.by?.email ?? '',
at: alert.silenced.at.toISOString(),
until: alert.silenced.until.toISOString(),
}
: undefined,
createdBy: alert.createdBy
? _.pick(alert.createdBy, ['email', 'name'])
? {
email: alert.createdBy.email,
name: alert.createdBy.name,
}
: undefined,
dashboardId: alert.dashboard
? alert.dashboard._id.toString()
: undefined,
savedSearchId: alert.savedSearch
? alert.savedSearch._id.toString()
: undefined,
dashboard: alert.dashboard
? {
_id: alert.dashboard._id.toString(),
name: alert.dashboard.name,
updatedAt:
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(alert as any).dashboard?.updatedAt?.toISOString?.() ?? '',
tags: alert.dashboard.tags,
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => ({
id: tile.id,
config: { name: tile.config.name },
})),
}
: undefined,
savedSearch: alert.savedSearch
? {
_id: alert.savedSearch._id.toString(),
createdAt:
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(alert as any).savedSearch?.createdAt?.toISOString?.() ?? '',
name: alert.savedSearch.name,
updatedAt:
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(alert as any).savedSearch?.updatedAt?.toISOString?.() ?? '',
tags: alert.savedSearch.tags,
}
: undefined,
channel: _.pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
dashboard: {
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => _.pick(tile, ['id', 'config.name'])),
..._.pick(alert.dashboard, ['_id', 'name', 'updatedAt', 'tags']),
},
}),
...(alert.savedSearch && {
savedSearchId: alert.savedSearch._id,
savedSearch: _.pick(alert.savedSearch, [
'_id',
'createdAt',
'name',
'updatedAt',
'tags',
]),
}),
..._.pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
'source',
'tileId',
'createdAt',
'updatedAt',
]),
};
}),
);

View file

@ -1,3 +1,7 @@
import {
type MeApiResponse,
MeApiResponseSchema,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { AI_API_KEY, ANTHROPIC_API_KEY, USAGE_STATS_ENABLED } from '@/config';
@ -6,7 +10,7 @@ import { Api404Error } from '@/utils/errors';
const router = express.Router();
router.get('/', async (req, res, next) => {
router.get('/', async (req, res: express.Response<MeApiResponse>, next) => {
try {
if (req.user == null) {
throw new Api404Error('Request without user found');
@ -22,14 +26,17 @@ router.get('/', async (req, res, next) => {
} = req.user;
const team = await getTeam(teamId);
if (team == null) {
throw new Api404Error(`Team not found for user ${id}`);
}
return res.json({
accessKey,
createdAt,
createdAt: createdAt.toISOString(),
email,
id,
id: id.toString(),
name,
team,
team: MeApiResponseSchema.shape.team.parse(team.toJSON()),
usageStatsEnabled: USAGE_STATS_ENABLED,
aiAssistantEnabled: !!(AI_API_KEY || ANTHROPIC_API_KEY),
});

View file

@ -1,3 +1,4 @@
import type { InstallationApiResponse } from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { serializeError } from 'serialize-error';
import { z } from 'zod';
@ -53,7 +54,8 @@ router.get('/health', async (req, res) => {
});
});
router.get('/installation', async (req, res, next) => {
type InstallationEspRes = express.Response<InstallationApiResponse>;
router.get('/installation', async (_, res: InstallationEspRes, next) => {
try {
const _isTeamExisting = await isTeamExisting();
return res.json({

View file

@ -1,3 +1,11 @@
import type {
RotateApiKeyApiResponse,
TeamApiResponse,
TeamInvitationsApiResponse,
TeamMembersApiResponse,
TeamTagsApiResponse,
UpdateClickHouseSettingsApiResponse,
} from '@hyperdx/common-utils/dist/types';
import { TeamClickHouseSettingsSchema } from '@hyperdx/common-utils/dist/types';
import crypto from 'crypto';
import express from 'express';
@ -23,7 +31,8 @@ import { objectIdSchema } from '@/utils/zod';
const router = express.Router();
router.get('/', async (req, res, next) => {
type TeamApiExpRes = express.Response<TeamApiResponse>;
router.get('/', async (req, res: TeamApiExpRes, next) => {
try {
const teamId = req.user?.team;
const userId = req.user?._id;
@ -46,20 +55,37 @@ router.get('/', async (req, res, next) => {
throw new Error(`Team ${teamId} not found for user ${userId}`);
}
res.json(team.toJSON());
// createdAt comes from Mongoose timestamps but is not on the ITeam interface
const createdAt =
'createdAt' in team && team.createdAt instanceof Date
? team.createdAt.toISOString()
: '';
res.json({
_id: team._id.toString(),
allowedAuthMethods:
'allowedAuthMethods' in team ? team.allowedAuthMethods : undefined,
apiKey: team.apiKey,
name: team.name,
createdAt,
});
} catch (e) {
next(e);
}
});
router.patch('/apiKey', async (req, res, next) => {
type RotateApiKeyExpRes = express.Response<RotateApiKeyApiResponse>;
router.patch('/apiKey', async (req, res: RotateApiKeyExpRes, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
throw new Error(`User ${req.user?._id} not associated with a team`);
}
const team = await rotateTeamApiKey(teamId);
res.json({ newApiKey: team?.apiKey });
if (team?.apiKey == null) {
throw new Error(`Failed to rotate API key for team ${teamId}`);
}
res.json({ newApiKey: team.apiKey });
} catch (e) {
next(e);
}
@ -92,7 +118,11 @@ router.patch(
processRequest({
body: TeamClickHouseSettingsSchema,
}),
async (req, res, next) => {
async (
req,
res: express.Response<UpdateClickHouseSettingsApiResponse>,
next,
) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
@ -105,14 +135,7 @@ router.patch(
const team = await updateTeamClickhouseSettings(teamId, req.body);
res.json(
Object.entries(req.body).reduce((acc, cur) => {
return {
...acc,
[cur[0]]: team?.[cur[0]],
};
}, {} as any),
);
res.json(pick(team, Object.keys(req.body)));
} catch (e) {
next(e);
}
@ -176,7 +199,8 @@ router.post(
},
);
router.get('/invitations', async (req, res, next) => {
type TeamInviteExpressRes = express.Response<TeamInvitationsApiResponse>;
router.get('/invitations', async (req, res: TeamInviteExpressRes, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
@ -193,8 +217,8 @@ router.get('/invitations', async (req, res, next) => {
);
res.json({
data: teamInvites.map(ti => ({
_id: ti._id,
createdAt: ti.createdAt,
_id: ti._id.toString(),
createdAt: ti.createdAt.toISOString(),
email: ti.email,
name: ti.name,
url: `${config.FRONTEND_URL}/join-team?token=${ti.token}`,
@ -225,7 +249,8 @@ router.delete(
},
);
router.get('/members', async (req, res, next) => {
type TeamMembersExpRes = express.Response<TeamMembersApiResponse>;
router.get('/members', async (req, res: TeamMembersExpRes, next) => {
try {
const teamId = req.user?.team;
const userId = req.user?._id;
@ -281,7 +306,8 @@ router.delete(
},
);
router.get('/tags', async (req, res, next) => {
type TeamTagsExpRes = express.Response<TeamTagsApiResponse>;
router.get('/tags', async (req, res: TeamTagsExpRes, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {

View file

@ -1,3 +1,9 @@
import type {
WebhookCreateApiResponse,
WebhooksApiResponse,
WebhookTestApiResponse,
WebhookUpdateApiResponse,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { ObjectId } from 'mongodb';
import mongoose from 'mongoose';
@ -23,7 +29,7 @@ router.get(
]),
}),
}),
async (req, res, next) => {
async (req, res: express.Response<WebhooksApiResponse>, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
@ -35,7 +41,7 @@ router.get(
{ __v: 0, team: 0 },
);
res.json({
data: webhooks,
data: webhooks.map(w => w.toJSON({ flattenMaps: true })),
});
} catch (err) {
next(err);
@ -75,7 +81,11 @@ router.post(
url: z.string().url(),
}),
}),
async (req, res, next) => {
async (
req,
res: express.Response<WebhookCreateApiResponse | { message: string }>,
next,
) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
@ -100,7 +110,7 @@ router.post(
});
await webhook.save();
res.json({
data: webhook,
data: webhook.toJSON({ flattenMaps: true }),
});
} catch (err) {
next(err);
@ -128,7 +138,11 @@ router.put(
url: z.string().url(),
}),
}),
async (req, res, next) => {
async (
req,
res: express.Response<WebhookUpdateApiResponse | { message: string }>,
next,
) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
@ -177,8 +191,14 @@ router.put(
{ new: true, select: { __v: 0, team: 0 } },
);
if (!updatedWebhook) {
return res.status(404).json({
message: 'Webhook not found after update',
});
}
res.json({
data: updatedWebhook,
data: updatedWebhook.toJSON({ flattenMaps: true }),
});
} catch (err) {
next(err);
@ -222,7 +242,7 @@ router.post(
url: z.string().url(),
}),
}),
async (req, res, next) => {
async (req, res: express.Response<WebhookTestApiResponse>, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {

View file

@ -3,9 +3,21 @@ import type { HTTPError, Options, ResponsePromise } from 'ky';
import ky from 'ky-universal';
import type {
Alert,
AlertsApiResponse,
InstallationApiResponse,
MeApiResponse,
PresetDashboard,
PresetDashboardFilter,
Team,
RotateApiKeyApiResponse,
TeamApiResponse,
TeamInvitationsApiResponse,
TeamMembersApiResponse,
TeamTagsApiResponse,
UpdateClickHouseSettingsApiResponse,
WebhookCreateApiResponse,
WebhooksApiResponse,
WebhookTestApiResponse,
WebhookUpdateApiResponse,
} from '@hyperdx/common-utils/dist/types';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
@ -16,8 +28,6 @@ import {
fetchLocalDashboards,
getLocalDashboardTags,
} from './dashboard';
import type { AlertsPageItem } from './types';
type ServicesResponse = {
data: Record<
string,
@ -30,12 +40,6 @@ type ServicesResponse = {
>;
};
type AlertsResponse = {
data: AlertsPageItem[];
};
type ApiAlertInput = Alert;
export function loginHook(request: Request, options: any, response: Response) {
// marketing pages
const WHITELIST_PATHS = [
@ -76,7 +80,7 @@ export const hdxServer = (
const api = {
useCreateAlert() {
return useMutation<any, Error, ApiAlertInput>({
return useMutation<{ data: Alert }, Error, Alert>({
mutationFn: async alert =>
server('alerts', {
method: 'POST',
@ -85,7 +89,7 @@ const api = {
});
},
useUpdateAlert() {
return useMutation<any, Error, { id: string } & ApiAlertInput>({
return useMutation<{ data: Alert }, Error, { id: string } & Alert>({
mutationFn: async alert =>
server(`alerts/${alert.id}`, {
method: 'PUT',
@ -94,28 +98,31 @@ const api = {
});
},
useDeleteAlert() {
return useMutation<any, Error, string>({
mutationFn: async (alertId: string) =>
server(`alerts/${alertId}`, {
return useMutation<void, Error, string>({
mutationFn: async (alertId: string) => {
await server(`alerts/${alertId}`, {
method: 'DELETE',
}),
});
},
});
},
useSilenceAlert() {
return useMutation<any, Error, { alertId: string; mutedUntil: string }>({
mutationFn: async ({ alertId, mutedUntil }) =>
server(`alerts/${alertId}/silenced`, {
return useMutation<void, Error, { alertId: string; mutedUntil: string }>({
mutationFn: async ({ alertId, mutedUntil }) => {
await server(`alerts/${alertId}/silenced`, {
method: 'POST',
json: { mutedUntil },
}),
});
},
});
},
useUnsilenceAlert() {
return useMutation<any, Error, string>({
mutationFn: async (alertId: string) =>
server(`alerts/${alertId}/silenced`, {
return useMutation<void, Error, string>({
mutationFn: async (alertId: string) => {
await server(`alerts/${alertId}/silenced`, {
method: 'DELETE',
}),
});
},
});
},
useDashboards(options?: UseQueryOptions<Dashboard[] | null, Error>) {
@ -136,14 +143,14 @@ const api = {
tags,
}: {
name: string;
charts: any;
query: any;
tags: any;
charts: Dashboard['tiles'];
query: string;
tags: string[];
}) =>
hdxServer(`dashboards`, {
method: 'POST',
json: { name, charts, query, tags },
}).json(),
}).json<Dashboard>(),
});
},
useUpdateDashboard() {
@ -157,22 +164,23 @@ const api = {
}: {
id: string;
name: string;
charts: any;
query: any;
tags: any;
charts: Dashboard['tiles'];
query: string;
tags: string[];
}) =>
hdxServer(`dashboards/${id}`, {
method: 'PUT',
json: { name, charts, query, tags },
}).json(),
}).json<Dashboard>(),
});
},
useDeleteDashboard() {
return useMutation({
mutationFn: async ({ id }: { id: string }) =>
hdxServer(`dashboards/${id}`, {
mutationFn: async ({ id }: { id: string }) => {
await hdxServer(`dashboards/${id}`, {
method: 'DELETE',
}).json(),
});
},
});
},
usePresetDashboardFilters(
@ -186,30 +194,34 @@ const api = {
hdxServer(`dashboards/preset/${presetDashboard}/filters/`, {
method: 'GET',
searchParams: { sourceId },
}).json() as Promise<PresetDashboardFilter[]>,
}).json<PresetDashboardFilter[]>(),
enabled: !!sourceId && enabled,
});
},
useCreatePresetDashboardFilter() {
return useMutation({
return useMutation<PresetDashboardFilter, Error, PresetDashboardFilter>({
mutationFn: async (filter: PresetDashboardFilter) =>
hdxServer(`dashboards/preset/${filter.presetDashboard}/filter`, {
method: 'POST',
json: { filter },
}).json(),
}).json<PresetDashboardFilter>(),
});
},
useUpdatePresetDashboardFilter() {
return useMutation({
return useMutation<PresetDashboardFilter, Error, PresetDashboardFilter>({
mutationFn: async (filter: PresetDashboardFilter) =>
hdxServer(`dashboards/preset/${filter.presetDashboard}/filter`, {
method: 'PUT',
json: { filter },
}).json(),
}).json<PresetDashboardFilter>(),
});
},
useDeletePresetDashboardFilter() {
return useMutation({
return useMutation<
PresetDashboardFilter,
Error,
{ id: string; presetDashboard: PresetDashboard }
>({
mutationFn: async ({
id,
presetDashboard,
@ -219,13 +231,13 @@ const api = {
}) =>
hdxServer(`dashboards/preset/${presetDashboard}/filter/${id}`, {
method: 'DELETE',
}).json(),
}).json<PresetDashboardFilter>(),
});
},
useAlerts() {
return useQuery({
queryKey: [`alerts`],
queryFn: () => hdxServer(`alerts`).json() as Promise<AlertsResponse>,
queryFn: () => hdxServer(`alerts`).json<AlertsApiResponse>(),
});
},
useServices() {
@ -234,34 +246,39 @@ const api = {
queryFn: () =>
hdxServer(`chart/services`, {
method: 'GET',
}).json() as Promise<ServicesResponse>,
}).json<ServicesResponse>(),
});
},
useRotateTeamApiKey() {
return useMutation<any, Error | HTTPError>({
return useMutation<RotateApiKeyApiResponse, Error | HTTPError>({
mutationFn: async () =>
hdxServer(`team/apiKey`, {
method: 'PATCH',
}).json(),
}).json<RotateApiKeyApiResponse>(),
});
},
useDeleteTeamMember() {
return useMutation<any, Error | HTTPError, { userId: string }>({
return useMutation<
{ message: string },
Error | HTTPError,
{ userId: string }
>({
mutationFn: async ({ userId }: { userId: string }) =>
hdxServer(`team/member/${userId}`, {
method: 'DELETE',
}).json(),
}).json<{ message: string }>(),
});
},
useTeamInvitations() {
return useQuery<any>({
return useQuery<TeamInvitationsApiResponse>({
queryKey: [`team/invitations`],
queryFn: () => hdxServer(`team/invitations`).json(),
queryFn: () =>
hdxServer(`team/invitations`).json<TeamInvitationsApiResponse>(),
});
},
useSaveTeamInvitation() {
return useMutation<
any,
{ url: string },
Error | HTTPError,
{ name?: string; email: string }
>({
@ -272,7 +289,7 @@ const api = {
name,
email,
},
}).json(),
}).json<{ url: string }>(),
});
},
useDeleteTeamInvitation() {
@ -280,28 +297,28 @@ const api = {
mutationFn: async ({ id }: { id: string }) =>
hdxServer(`team/invitation/${id}`, {
method: 'DELETE',
}).json(),
}).json<{ message: string }>(),
});
},
useInstallation() {
return useQuery<any, Error>({
return useQuery<InstallationApiResponse | undefined, Error>({
queryKey: [`installation`],
queryFn: () => {
if (IS_LOCAL_MODE) {
return;
}
return hdxServer(`installation`).json();
return hdxServer(`installation`).json<InstallationApiResponse>();
},
});
},
useMe() {
return useQuery<any>({
return useQuery<MeApiResponse | null>({
queryKey: [`me`],
queryFn: () => {
if (IS_LOCAL_MODE) {
return null;
}
return hdxServer(`me`).json();
return hdxServer(`me`).json<MeApiResponse>();
},
});
},
@ -312,37 +329,29 @@ const api = {
if (IS_LOCAL_MODE) {
return null;
}
type TeamResponse = Pick<
Team,
'allowedAuthMethods' | 'apiKey' | 'name'
> & {
createdAt: string;
};
return hdxServer(`team`).json<TeamResponse>();
return hdxServer(`team`).json<TeamApiResponse>();
},
retry: 1,
});
},
useTeamMembers() {
return useQuery<any>({
return useQuery<TeamMembersApiResponse>({
queryKey: [`team/members`],
queryFn: () => hdxServer(`team/members`).json(),
queryFn: () => hdxServer(`team/members`).json<TeamMembersApiResponse>(),
});
},
useSetTeamName() {
return useMutation<any, HTTPError, { name: string }>({
return useMutation<{ name: string }, HTTPError, { name: string }>({
mutationFn: async ({ name }) =>
hdxServer(`team/name`, {
method: 'PATCH',
json: { name },
}).json(),
}).json<{ name: string }>(),
});
},
useUpdateClickhouseSettings() {
return useMutation<
any,
UpdateClickHouseSettingsApiResponse,
HTTPError,
{
searchRowLimit?: number;
@ -354,7 +363,7 @@ const api = {
hdxServer(`team/clickhouse-settings`, {
method: 'PATCH',
json: settings,
}).json(),
}).json<UpdateClickHouseSettingsApiResponse>(),
});
},
useTags() {
@ -362,12 +371,12 @@ const api = {
queryKey: [`team/tags`],
queryFn: IS_LOCAL_MODE
? async () => ({ data: getLocalDashboardTags() })
: () => hdxServer(`team/tags`).json<{ data: string[] }>(),
: () => hdxServer(`team/tags`).json<TeamTagsApiResponse>(),
});
},
useSaveWebhook() {
return useMutation<
any,
WebhookCreateApiResponse,
Error | HTTPError,
{
service: string;
@ -387,14 +396,6 @@ const api = {
queryParams,
headers,
body,
}: {
service: string;
url: string;
name: string;
description: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}) =>
hdxServer(`webhooks`, {
method: 'POST',
@ -407,12 +408,12 @@ const api = {
headers: headers || {},
body,
},
}).json(),
}).json<WebhookCreateApiResponse>(),
});
},
useUpdateWebhook() {
return useMutation<
any,
WebhookUpdateApiResponse,
Error | HTTPError,
{
id: string;
@ -434,15 +435,6 @@ const api = {
queryParams,
headers,
body,
}: {
id: string;
service: string;
url: string;
name: string;
description: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}) =>
hdxServer(`webhooks/${id}`, {
method: 'PUT',
@ -455,21 +447,25 @@ const api = {
headers: headers || {},
body,
},
}).json(),
}).json<WebhookUpdateApiResponse>(),
});
},
useWebhooks(services: string[]) {
return useQuery<any, Error>({
return useQuery<WebhooksApiResponse, Error>({
queryKey: [...services],
queryFn: () =>
hdxServer('webhooks', {
method: 'GET',
searchParams: [...services.map(service => ['service', service])],
}).json(),
}).json<WebhooksApiResponse>(),
});
},
useDeleteWebhook() {
return useMutation<any, Error | HTTPError, { id: string }>({
return useMutation<
Record<string, never>,
Error | HTTPError,
{ id: string }
>({
mutationFn: async ({ id }: { id: string }) =>
hdxServer(`webhooks/${id}`, {
method: 'DELETE',
@ -478,7 +474,7 @@ const api = {
},
useTestWebhook() {
return useMutation<
any,
WebhookTestApiResponse,
Error | HTTPError,
{
service: string;
@ -488,19 +484,7 @@ const api = {
body?: string;
}
>({
mutationFn: async ({
service,
url,
queryParams,
headers,
body,
}: {
service: string;
url: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}) =>
mutationFn: async ({ service, url, queryParams, headers, body }) =>
hdxServer(`webhooks/test`, {
method: 'POST',
json: {
@ -510,20 +494,16 @@ const api = {
headers: headers || {},
body,
},
}).json(),
}).json<WebhookTestApiResponse>(),
});
},
useRegisterPassword() {
return useMutation({
mutationFn: async ({
email,
password,
confirmPassword,
}: {
email: string;
password: string;
confirmPassword: string;
}) =>
return useMutation<
{ status: string },
Error,
{ email: string; password: string; confirmPassword: string }
>({
mutationFn: async ({ email, password, confirmPassword }) =>
hdxServer(`register/password`, {
method: 'POST',
json: {
@ -531,20 +511,16 @@ const api = {
password,
confirmPassword,
},
}).json(),
}).json<{ status: string }>(),
});
},
useTestConnection() {
return useMutation({
mutationFn: async ({
host,
username,
password,
}: {
host: string;
username: string;
password: string;
}) =>
return useMutation<
{ success: boolean; error?: string },
Error,
{ host: string; username: string; password: string }
>({
mutationFn: async ({ host, username, password }) =>
hdxServer(`clickhouse-proxy/test`, {
method: 'POST',
json: {
@ -552,7 +528,7 @@ const api = {
username,
password,
},
}).json() as Promise<{ success: boolean; error?: string }>,
}).json<{ success: boolean; error?: string }>(),
});
},
};

View file

@ -899,11 +899,9 @@ export const RawLogTable = memo(
isLast={isLast}
onRemoveColumn={
onRemoveColumn &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(header.column.columnDef.meta as any)?.column
? () => {
onRemoveColumn(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(header.column.columnDef.meta as any)
?.column,
);

View file

@ -228,7 +228,7 @@ export default function TeamMembersSection() {
<Table.Tbody>
{!isLoadingMembers &&
Array.isArray(members?.data) &&
members?.data.map((member: any) => (
members?.data.map(member => (
<Table.Tr key={member.email}>
<Table.Td>
<div>
@ -284,8 +284,8 @@ export default function TeamMembersSection() {
</Table.Tr>
))}
{!isLoadingInvitations &&
Array.isArray(invitations.data) &&
invitations.data.map((invitation: any) => (
Array.isArray(invitations?.data) &&
invitations.data.map(invitation => (
<Table.Tr key={invitation.email} className="mt-2">
<Table.Td>
<span className="text-white fw-bold fs-7">

View file

@ -59,7 +59,7 @@ export function useMetadataWithSettings() {
useEffect(() => {
if (me?.team?.metadataMaxRowsToRead && !settingsApplied.current) {
metadata.setClickHouseSettings({
max_rows_to_read: me.team.metadataMaxRowsToRead,
max_rows_to_read: String(me.team.metadataMaxRowsToRead),
});
settingsApplied.current = true;
}

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
import {
Alert,
AlertHistory,
AlertsPageItem as _AlertsPageItem,
BuilderChartConfig,
DashboardSchema,
Filter,
@ -36,16 +36,7 @@ export type LogStreamModel = KeyValuePairs & {
trace_id?: string;
};
export type AlertsPageItem = Alert & {
_id: string;
history: AlertHistory[];
dashboard?: ServerDashboard;
savedSearch?: SavedSearch;
createdBy?: {
email: string;
name?: string;
};
};
export type AlertsPageItem = _AlertsPageItem;
export type AlertWithCreatedBy = Alert & {
createdBy?: {

View file

@ -250,20 +250,22 @@ export enum WebhookService {
IncidentIO = 'incidentio',
}
// Base webhook interface (matches backend IWebhook but with JSON-serialized types)
// Base webhook schema (matches backend IWebhook but with JSON-serialized types)
// When making changes here, consider if they need to be made to the external API schema as well (packages/api/src/utils/zod.ts).
export interface IWebhook {
_id: string;
createdAt: string;
name: string;
service: WebhookService;
updatedAt: string;
url?: string;
description?: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}
export const WebhookSchema = z.object({
_id: z.string(),
createdAt: z.string(),
name: z.string(),
service: z.nativeEnum(WebhookService),
updatedAt: z.string(),
url: z.string().optional(),
description: z.string().optional(),
queryParams: z.record(z.string(), z.string()).optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.string().optional(),
});
export type IWebhook = z.infer<typeof WebhookSchema>;
// Webhook API response type (excludes team field for security)
export type WebhookApiData = Omit<IWebhook, 'team'>;
@ -434,12 +436,14 @@ export const AlertSchema = z.union([
export type Alert = z.infer<typeof AlertSchema>;
export type AlertHistory = {
counts: number;
createdAt: string;
lastValues: { startTime: string; count: number }[];
state: AlertState;
};
export const AlertHistorySchema = z.object({
counts: z.number(),
createdAt: z.string(),
lastValues: z.array(z.object({ startTime: z.string(), count: z.number() })),
state: z.nativeEnum(AlertState),
});
export type AlertHistory = z.infer<typeof AlertHistorySchema>;
// --------------------------
// FILTERS
@ -1119,3 +1123,200 @@ export const AssistantResponseConfig = z.discriminatedUnion('displayType', [
export type AssistantResponseConfigSchema = z.infer<
typeof AssistantResponseConfig
>;
// --------------------------
// API RESPONSE SCHEMAS
// --------------------------
// Alerts
export const AlertsPageItemSchema = z.object({
_id: z.string(),
interval: AlertIntervalSchema,
scheduleOffsetMinutes: z.number().optional(),
scheduleStartAt: z.union([z.string(), z.date()]).nullish(),
threshold: z.number(),
thresholdType: z.nativeEnum(AlertThresholdType),
channel: z.object({ type: z.string().optional() }),
state: z.nativeEnum(AlertState).optional(),
source: z.nativeEnum(AlertSource).optional(),
dashboardId: z.string().optional(),
savedSearchId: z.string().optional(),
tileId: z.string().optional(),
name: z.string().nullish(),
message: z.string().nullish(),
createdAt: z.string(),
updatedAt: z.string(),
history: z.array(AlertHistorySchema),
dashboard: z
.object({
_id: z.string(),
name: z.string(),
updatedAt: z.string(),
tags: z.array(z.string()),
tiles: z.array(
z.object({
id: z.string(),
config: z.object({ name: z.string().optional() }),
}),
),
})
.optional(),
savedSearch: z
.object({
_id: z.string(),
createdAt: z.string(),
name: z.string(),
updatedAt: z.string(),
tags: z.array(z.string()),
})
.optional(),
createdBy: z
.object({
email: z.string(),
name: z.string().optional(),
})
.optional(),
silenced: z
.object({
by: z.string(),
at: z.string(),
until: z.string(),
})
.optional(),
});
export type AlertsPageItem = z.infer<typeof AlertsPageItemSchema>;
export const AlertsApiResponseSchema = z.object({
data: z.array(AlertsPageItemSchema),
});
export type AlertsApiResponse = z.infer<typeof AlertsApiResponseSchema>;
// Webhooks
export const WebhooksApiResponseSchema = z.object({
data: z.array(WebhookSchema),
});
export type WebhooksApiResponse = z.infer<typeof WebhooksApiResponseSchema>;
export const WebhookCreateApiResponseSchema = z.object({
data: WebhookSchema,
});
export type WebhookCreateApiResponse = z.infer<
typeof WebhookCreateApiResponseSchema
>;
export const WebhookUpdateApiResponseSchema = z.object({
data: WebhookSchema,
});
export type WebhookUpdateApiResponse = z.infer<
typeof WebhookUpdateApiResponseSchema
>;
export const WebhookTestApiResponseSchema = z.object({
message: z.string(),
});
export type WebhookTestApiResponse = z.infer<
typeof WebhookTestApiResponseSchema
>;
// Team
export const TeamApiResponseSchema = z.object({
_id: z.string(),
allowedAuthMethods: z.array(z.literal('password')).optional(),
apiKey: z.string(),
name: z.string(),
createdAt: z.string(),
});
export type TeamApiResponse = z.infer<typeof TeamApiResponseSchema>;
export const TeamMemberSchema = z.object({
_id: z.string(),
email: z.string(),
name: z.string().optional(),
hasPasswordAuth: z.boolean(),
isCurrentUser: z.boolean(),
groupName: z.string().optional(),
});
export type TeamMember = z.infer<typeof TeamMemberSchema>;
export const TeamMembersApiResponseSchema = z.object({
data: z.array(TeamMemberSchema),
});
export type TeamMembersApiResponse = z.infer<
typeof TeamMembersApiResponseSchema
>;
export const TeamInvitationSchema = z.object({
_id: z.string(),
createdAt: z.string(),
email: z.string(),
name: z.string().optional(),
url: z.string(),
});
export type TeamInvitation = z.infer<typeof TeamInvitationSchema>;
export const TeamInvitationsApiResponseSchema = z.object({
data: z.array(TeamInvitationSchema),
});
export type TeamInvitationsApiResponse = z.infer<
typeof TeamInvitationsApiResponseSchema
>;
export const TeamTagsApiResponseSchema = z.object({
data: z.array(z.string()),
});
export type TeamTagsApiResponse = z.infer<typeof TeamTagsApiResponseSchema>;
export const UpdateClickHouseSettingsApiResponseSchema =
TeamClickHouseSettingsSchema.partial();
export type UpdateClickHouseSettingsApiResponse = z.infer<
typeof UpdateClickHouseSettingsApiResponseSchema
>;
export const RotateApiKeyApiResponseSchema = z.object({
newApiKey: z.string(),
});
export type RotateApiKeyApiResponse = z.infer<
typeof RotateApiKeyApiResponseSchema
>;
// Installation
export const InstallationApiResponseSchema = z.object({
isTeamExisting: z.boolean(),
});
export type InstallationApiResponse = z.infer<
typeof InstallationApiResponseSchema
>;
// Me
export const MeApiResponseSchema = z.object({
accessKey: z.string(),
createdAt: z.string(),
email: z.string(),
id: z.string(),
name: z.string(),
team: TeamSchema.pick({
id: true,
name: true,
allowedAuthMethods: true,
apiKey: true,
}).merge(TeamClickHouseSettingsSchema),
usageStatsEnabled: z.boolean(),
aiAssistantEnabled: z.boolean(),
});
export type MeApiResponse = z.infer<typeof MeApiResponseSchema>;