mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Store createdBy user ids on alert documents (#1058)
We now store `createdBy` in the `Alert` document when the alert is created. This new field references the `User` document of the currently logged in user. The property is optional to support existing alerts in the database. This information is also displayed in various places where alert details are displayed. Older documents without the new field should display nothing, resulting in the existing UX.
This commit is contained in:
parent
345ff7e26f
commit
c0b188c1c5
18 changed files with 323 additions and 123 deletions
|
|
@ -5,6 +5,6 @@
|
|||
"fixed": [["@hyperdx/api", "@hyperdx/app"]],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "v2",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch"
|
||||
}
|
||||
|
|
|
|||
6
.changeset/fluffy-squids-share.md
Normal file
6
.changeset/fluffy-squids-share.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/api": minor
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
Track the user id who created alerts and display the information in the UI.
|
||||
|
|
@ -44,13 +44,14 @@ export type AlertInput = {
|
|||
};
|
||||
};
|
||||
|
||||
const makeAlert = (alert: AlertInput): Partial<IAlert> => {
|
||||
const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
|
||||
return {
|
||||
channel: alert.channel,
|
||||
interval: alert.interval,
|
||||
source: alert.source,
|
||||
threshold: alert.threshold,
|
||||
thresholdType: alert.thresholdType,
|
||||
...(userId && { createdBy: userId }),
|
||||
|
||||
// Message template
|
||||
// If they're undefined/null, set it to null so we clear out the field
|
||||
|
|
@ -71,6 +72,7 @@ const makeAlert = (alert: AlertInput): Partial<IAlert> => {
|
|||
export const createAlert = async (
|
||||
teamId: ObjectId,
|
||||
alertInput: z.infer<typeof alertSchema>,
|
||||
userId: ObjectId,
|
||||
) => {
|
||||
if (alertInput.source === AlertSource.TILE) {
|
||||
if ((await Dashboard.findById(alertInput.dashboardId)) == null) {
|
||||
|
|
@ -85,7 +87,7 @@ export const createAlert = async (
|
|||
}
|
||||
|
||||
return new Alert({
|
||||
...makeAlert(alertInput),
|
||||
...makeAlert(alertInput, userId),
|
||||
team: teamId,
|
||||
}).save();
|
||||
};
|
||||
|
|
@ -127,7 +129,7 @@ export const getTeamDashboardAlertsByTile = async (teamId: ObjectId) => {
|
|||
const alerts = await Alert.find({
|
||||
source: AlertSource.TILE,
|
||||
team: teamId,
|
||||
});
|
||||
}).populate('createdBy', 'email name');
|
||||
return groupBy(alerts, 'tileId');
|
||||
};
|
||||
|
||||
|
|
@ -139,7 +141,7 @@ export const getDashboardAlertsByTile = async (
|
|||
dashboard: dashboardId,
|
||||
source: AlertSource.TILE,
|
||||
team: teamId,
|
||||
});
|
||||
}).populate('createdBy', 'email name');
|
||||
return groupBy(alerts, 'tileId');
|
||||
};
|
||||
|
||||
|
|
@ -147,19 +149,26 @@ export const createOrUpdateDashboardAlerts = async (
|
|||
dashboardId: ObjectId | string,
|
||||
teamId: ObjectId,
|
||||
alertsByTile: Record<string, AlertInput>,
|
||||
userId?: ObjectId,
|
||||
) => {
|
||||
return Promise.all(
|
||||
Object.entries(alertsByTile).map(async ([tileId, alert]) => {
|
||||
return await Alert.findOneAndUpdate(
|
||||
{
|
||||
dashboard: dashboardId,
|
||||
tileId,
|
||||
source: AlertSource.TILE,
|
||||
team: teamId,
|
||||
},
|
||||
alert,
|
||||
{ new: true, upsert: true },
|
||||
);
|
||||
const filter = {
|
||||
dashboard: dashboardId,
|
||||
tileId,
|
||||
source: AlertSource.TILE,
|
||||
team: teamId,
|
||||
};
|
||||
const oldAlert = await Alert.findOne(filter);
|
||||
const alertValues =
|
||||
oldAlert && oldAlert.createdBy
|
||||
? makeAlert(alert)
|
||||
: makeAlert(alert, userId);
|
||||
|
||||
return await Alert.findOneAndUpdate(filter, alertValues, {
|
||||
new: true,
|
||||
upsert: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -190,10 +199,11 @@ export const getAlertsEnhanced = async (teamId: ObjectId) => {
|
|||
return Alert.find({ team: teamId }).populate<{
|
||||
savedSearch: ISavedSearch;
|
||||
dashboard: IDashboard;
|
||||
createdBy?: IUser;
|
||||
silenced?: IAlert['silenced'] & {
|
||||
by: IUser;
|
||||
};
|
||||
}>(['savedSearch', 'dashboard', 'silenced.by']);
|
||||
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
|
||||
};
|
||||
|
||||
export const deleteAlert = async (id: string, teamId: ObjectId) => {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export async function getDashboard(dashboardId: string, teamId: ObjectId) {
|
|||
export async function createDashboard(
|
||||
teamId: ObjectId,
|
||||
dashboard: z.infer<typeof DashboardWithoutIdSchema>,
|
||||
userId?: ObjectId,
|
||||
) {
|
||||
const newDashboard = await new Dashboard({
|
||||
...dashboard,
|
||||
|
|
@ -70,6 +71,7 @@ export async function createDashboard(
|
|||
newDashboard._id,
|
||||
teamId,
|
||||
pickAlertsByTile(dashboard.tiles),
|
||||
userId,
|
||||
);
|
||||
|
||||
return newDashboard;
|
||||
|
|
@ -89,6 +91,7 @@ export async function updateDashboard(
|
|||
dashboardId: string,
|
||||
teamId: ObjectId,
|
||||
updates: Partial<z.infer<typeof DashboardWithoutIdSchema>>,
|
||||
userId?: ObjectId,
|
||||
) {
|
||||
const oldDashboard = await getDashboard(dashboardId, teamId);
|
||||
|
||||
|
|
@ -114,13 +117,15 @@ export async function updateDashboard(
|
|||
// Update related alerts
|
||||
// - Delete
|
||||
const newAlertIds = new Set(
|
||||
updates.tiles?.map(t => t.config.alert?.id).filter(Boolean),
|
||||
updates.tiles
|
||||
?.map(t => (t.config.alert as any)?._id?.toString())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const deletedAlertIds: string[] = [];
|
||||
|
||||
const deletedAlertIds: string[] = [];
|
||||
if (oldDashboard.tiles) {
|
||||
for (const tile of oldDashboard.tiles) {
|
||||
const alertId = tile.config.alert?.id;
|
||||
const alertId = (tile.config.alert as any)?._id?.toString();
|
||||
if (alertId && !newAlertIds.has(alertId)) {
|
||||
deletedAlertIds.push(alertId);
|
||||
}
|
||||
|
|
@ -137,6 +142,7 @@ export async function updateDashboard(
|
|||
dashboardId,
|
||||
teamId,
|
||||
pickAlertsByTile(updates.tiles),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { SavedSearchSchema } from '@hyperdx/common-utils/dist/types';
|
||||
import { groupBy } from 'lodash';
|
||||
import { groupBy, pick } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { deleteSavedSearchAlerts } from '@/controllers/alerts';
|
||||
import Alert from '@/models/alert';
|
||||
import { SavedSearch } from '@/models/savedSearch';
|
||||
import type { IUser } from '@/models/user';
|
||||
|
||||
type SavedSearchWithoutId = Omit<z.infer<typeof SavedSearchSchema>, 'id'>;
|
||||
|
||||
|
|
@ -13,15 +14,16 @@ export async function getSavedSearches(teamId: string) {
|
|||
const alerts = await Alert.find(
|
||||
{ team: teamId, savedSearch: { $exists: true, $ne: null } },
|
||||
{ __v: 0 },
|
||||
);
|
||||
).populate('createdBy', 'email name');
|
||||
|
||||
const alertsBySavedSearchId = groupBy(alerts, 'savedSearch');
|
||||
|
||||
return savedSearches.map(savedSearch => ({
|
||||
...savedSearch.toJSON(),
|
||||
alerts: alertsBySavedSearchId[savedSearch._id.toString()]
|
||||
?.map(alert => alert.toJSON())
|
||||
.map(({ _id, ...alert }) => ({ id: _id, ...alert })), // Remap _id to id
|
||||
alerts: alertsBySavedSearchId[savedSearch._id.toString()]?.map(alert => {
|
||||
const { _id, ...restAlert } = alert.toJSON();
|
||||
return { id: _id, ...restAlert };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface IAlert {
|
|||
team: ObjectId;
|
||||
threshold: number;
|
||||
thresholdType: AlertThresholdType;
|
||||
createdBy?: ObjectId;
|
||||
|
||||
// Message template
|
||||
name?: string | null;
|
||||
|
|
@ -102,6 +103,11 @@ const AlertSchema = new Schema<IAlert>(
|
|||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: Team.modelName,
|
||||
},
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: false,
|
||||
},
|
||||
|
||||
// Message template
|
||||
name: {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
makeTile,
|
||||
randomMongoId,
|
||||
} from '@/fixtures';
|
||||
import Alert from '@/models/alert';
|
||||
|
||||
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
|
||||
|
||||
|
|
@ -97,6 +98,53 @@ describe('alerts router', () => {
|
|||
expect(allAlerts.body.data[0].threshold).toBe(10);
|
||||
});
|
||||
|
||||
it('preserves createdBy field during updates', async () => {
|
||||
const { agent, user } = await getLoggedInAgent(server);
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
// Create an alert
|
||||
const alert = await agent
|
||||
.post('/alerts')
|
||||
.send(
|
||||
makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
threshold: 5,
|
||||
}),
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
// Verify alert was created and contains the expected data
|
||||
expect(alert.body.data.threshold).toBe(5);
|
||||
|
||||
// Get the alert directly from database to verify createdBy was set
|
||||
const alertFromDb = await Alert.findById(alert.body.data._id);
|
||||
expect(alertFromDb).toBeDefined();
|
||||
expect(alertFromDb!.createdBy).toEqual(user._id);
|
||||
expect(alertFromDb!.threshold).toBe(5);
|
||||
|
||||
// Update the alert with a different threshold
|
||||
const updatedAlert = await agent
|
||||
.put(`/alerts/${alert.body.data._id}`)
|
||||
.send({
|
||||
...alert.body.data,
|
||||
dashboardId: dashboard.body.id, // because alert.body.data stores 'dashboard' instead of 'dashboardId'
|
||||
threshold: 15, // Change threshold
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(updatedAlert.body.data.threshold).toBe(15);
|
||||
|
||||
// Get the alert from database again to verify createdBy is preserved
|
||||
const alertFromDbAfterUpdate = await Alert.findById(alert.body.data._id);
|
||||
expect(alertFromDbAfterUpdate).toBeDefined();
|
||||
expect(alertFromDbAfterUpdate!.createdBy).toEqual(user._id); // ✅ createdBy should still be the original user
|
||||
expect(alertFromDbAfterUpdate!.threshold).toBe(15); // ✅ threshold should be updated
|
||||
});
|
||||
|
||||
it('has alerts attached to dashboards', async () => {
|
||||
const { agent } = await getLoggedInAgent(server);
|
||||
|
||||
|
|
|
|||
|
|
@ -114,4 +114,38 @@ describe('savedSearch router', () => {
|
|||
expect(savedSearches.body.length).toBe(0);
|
||||
expect(await Alert.findById(alert.body.data._id)).toBeNull();
|
||||
});
|
||||
|
||||
it('sets createdBy on alerts created from a saved search and populates it in list', async () => {
|
||||
const { agent, user } = await getLoggedInAgent(server);
|
||||
|
||||
// Create a saved search
|
||||
const savedSearch = await agent
|
||||
.post('/saved-search')
|
||||
.send(MOCK_SAVED_SEARCH)
|
||||
.expect(200);
|
||||
|
||||
// Create an alert associated to the saved search
|
||||
const alert = await agent
|
||||
.post('/alerts')
|
||||
.send(
|
||||
makeSavedSearchAlertInput({
|
||||
savedSearchId: savedSearch.body._id,
|
||||
}),
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
// Verify createdBy was set on the alert document
|
||||
const alertFromDb = await Alert.findById(alert.body.data._id);
|
||||
expect(alertFromDb).toBeDefined();
|
||||
expect(alertFromDb!.createdBy).toEqual(user._id);
|
||||
|
||||
// Verify GET /saved-search returns alerts with createdBy populated
|
||||
const savedSearches = await agent.get('/saved-search').expect(200);
|
||||
expect(savedSearches.body.length).toBe(1);
|
||||
expect(savedSearches.body[0].alerts).toBeDefined();
|
||||
expect(savedSearches.body[0].alerts.length).toBe(1);
|
||||
expect(savedSearches.body[0].alerts[0].id).toBeDefined();
|
||||
expect(savedSearches.body[0].alerts[0].createdBy).toBeDefined();
|
||||
expect(savedSearches.body[0].alerts[0].createdBy.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ router.get('/', async (req, res, next) => {
|
|||
until: alert.silenced.until,
|
||||
}
|
||||
: undefined,
|
||||
createdBy: alert.createdBy
|
||||
? _.pick(alert.createdBy, ['email', 'name'])
|
||||
: undefined,
|
||||
channel: _.pick(alert.channel, ['type']),
|
||||
...(alert.dashboard && {
|
||||
dashboardId: alert.dashboard._id,
|
||||
|
|
@ -95,13 +98,14 @@ router.post(
|
|||
validateRequest({ body: alertSchema }),
|
||||
async (req, res, next) => {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
const userId = req.user?._id;
|
||||
if (teamId == null || userId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
try {
|
||||
const alertInput = req.body;
|
||||
return res.json({
|
||||
data: await createAlert(teamId, alertInput),
|
||||
data: await createAlert(teamId, alertInput, userId),
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
|
|
|
|||
|
|
@ -40,11 +40,11 @@ router.post(
|
|||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { teamId } = getNonNullUserWithTeam(req);
|
||||
const { teamId, userId } = getNonNullUserWithTeam(req);
|
||||
|
||||
const dashboard = req.body;
|
||||
|
||||
const newDashboard = await createDashboard(teamId, dashboard);
|
||||
const newDashboard = await createDashboard(teamId, dashboard, userId);
|
||||
|
||||
res.json(newDashboard.toJSON());
|
||||
} catch (e) {
|
||||
|
|
@ -63,7 +63,7 @@ router.patch(
|
|||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { teamId } = getNonNullUserWithTeam(req);
|
||||
const { teamId, userId } = getNonNullUserWithTeam(req);
|
||||
const { id: dashboardId } = req.params;
|
||||
|
||||
const dashboard = await getDashboard(dashboardId, teamId);
|
||||
|
|
@ -78,6 +78,7 @@ router.patch(
|
|||
dashboardId,
|
||||
teamId,
|
||||
updates,
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json(updatedDashboard);
|
||||
|
|
|
|||
|
|
@ -390,12 +390,13 @@ router.get('/', async (req, res, next) => {
|
|||
*/
|
||||
router.post('/', async (req, res, next) => {
|
||||
const teamId = req.user?.team;
|
||||
if (teamId == null) {
|
||||
const userId = req.user?._id;
|
||||
if (teamId == null || userId == null) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
try {
|
||||
const alertInput = req.body;
|
||||
const createdAlert = await createAlert(teamId, alertInput);
|
||||
const createdAlert = await createAlert(teamId, alertInput, userId);
|
||||
|
||||
return res.json({
|
||||
data: translateAlertDocumentToExternalAlert(createdAlert),
|
||||
|
|
|
|||
|
|
@ -692,17 +692,22 @@ describe('checkAlerts', () => {
|
|||
source: source.id,
|
||||
tags: ['test'],
|
||||
}).save();
|
||||
const alert = await createAlert(team._id, {
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
const mockUserId = new mongoose.Types.ObjectId();
|
||||
const alert = await createAlert(
|
||||
team._id,
|
||||
{
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
});
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const enhancedAlert: any = await Alert.findById(alert._id).populate([
|
||||
'team',
|
||||
|
|
@ -867,18 +872,23 @@ describe('checkAlerts', () => {
|
|||
},
|
||||
],
|
||||
}).save();
|
||||
const alert = await createAlert(team._id, {
|
||||
source: AlertSource.TILE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
const mockUserId = new mongoose.Types.ObjectId();
|
||||
const alert = await createAlert(
|
||||
team._id,
|
||||
{
|
||||
source: AlertSource.TILE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
dashboardId: dashboard.id,
|
||||
tileId: '17quud',
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
dashboardId: dashboard.id,
|
||||
tileId: '17quud',
|
||||
});
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const enhancedAlert: any = await Alert.findById(alert._id).populate([
|
||||
'team',
|
||||
|
|
@ -1035,18 +1045,23 @@ describe('checkAlerts', () => {
|
|||
},
|
||||
],
|
||||
}).save();
|
||||
const alert = await createAlert(team._id, {
|
||||
source: AlertSource.TILE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
const mockUserId = new mongoose.Types.ObjectId();
|
||||
const alert = await createAlert(
|
||||
team._id,
|
||||
{
|
||||
source: AlertSource.TILE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
dashboardId: dashboard.id,
|
||||
tileId: '17quud',
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
dashboardId: dashboard.id,
|
||||
tileId: '17quud',
|
||||
});
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const enhancedAlert: any = await Alert.findById(alert._id).populate([
|
||||
'team',
|
||||
|
|
@ -1185,18 +1200,23 @@ describe('checkAlerts', () => {
|
|||
},
|
||||
],
|
||||
}).save();
|
||||
const alert = await createAlert(team._id, {
|
||||
source: AlertSource.TILE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
const mockUserId = new mongoose.Types.ObjectId();
|
||||
const alert = await createAlert(
|
||||
team._id,
|
||||
{
|
||||
source: AlertSource.TILE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
dashboardId: dashboard.id,
|
||||
tileId: '17quud',
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
dashboardId: dashboard.id,
|
||||
tileId: '17quud',
|
||||
});
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const enhancedAlert: any = await Alert.findById(alert._id).populate([
|
||||
'team',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createServer } from 'http';
|
||||
import mongoose from 'mongoose';
|
||||
import ms from 'ms';
|
||||
|
||||
import * as config from '@/config';
|
||||
|
|
@ -130,18 +131,23 @@ describe('Single Invocation Alert Test', () => {
|
|||
}).save();
|
||||
|
||||
// Create alert
|
||||
const alert = await createAlert(team._id, {
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
const mockUserId = new mongoose.Types.ObjectId();
|
||||
const alert = await createAlert(
|
||||
team._id,
|
||||
{
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
name: 'Test Alert Name',
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
name: 'Test Alert Name',
|
||||
});
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Insert test logs that will trigger the alert
|
||||
const now = new Date('2023-11-16T22:12:00.000Z');
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
|
|||
<div className="text-slate-400 fs-8 d-flex gap-2">
|
||||
{alertType}
|
||||
{notificationMethod}
|
||||
{alert.createdBy && (
|
||||
<>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span>
|
||||
Created by {alert.createdBy.name || alert.createdBy.email}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import { AlertPreviewChart } from './components/AlertPreviewChart';
|
|||
import { AlertChannelForm } from './components/Alerts';
|
||||
import { SQLInlineEditorControlled } from './components/SQLInlineEditor';
|
||||
import api from './api';
|
||||
import { SearchConfig } from './types';
|
||||
import { AlertWithCreatedBy, SearchConfig } from './types';
|
||||
import { optionsToSelectData } from './utils';
|
||||
|
||||
const SavedSearchAlertFormSchema = z
|
||||
|
|
@ -76,7 +76,7 @@ const AlertForm = ({
|
|||
where?: SearchCondition | null;
|
||||
whereLanguage?: SearchConditionLanguage | null;
|
||||
select?: string | null;
|
||||
defaultValues?: null | Alert;
|
||||
defaultValues?: null | AlertWithCreatedBy;
|
||||
loading?: boolean;
|
||||
deleteLoading?: boolean;
|
||||
hasSavedSearch?: boolean;
|
||||
|
|
@ -185,6 +185,22 @@ const AlertForm = ({
|
|||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{defaultValues?.createdBy && (
|
||||
<Paper px="md" py="sm" bg="dark.6" radius="xs" mt="sm">
|
||||
<Text size="xxs" opacity={0.5} mb={4}>
|
||||
Created by
|
||||
</Text>
|
||||
<Text size="sm" opacity={0.8}>
|
||||
{defaultValues.createdBy.name || defaultValues.createdBy.email}
|
||||
</Text>
|
||||
{defaultValues.createdBy.name && (
|
||||
<Text size="xs" opacity={0.6}>
|
||||
{defaultValues.createdBy.email}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Group mt="lg" justify="space-between" gap="xs">
|
||||
<div>
|
||||
{defaultValues && (
|
||||
|
|
|
|||
|
|
@ -768,39 +768,50 @@ export default function EditTimeChartForm({
|
|||
<Paper my="sm">
|
||||
<Stack gap="xs">
|
||||
<Paper px="md" py="sm" bg="dark.6" radius="xs">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" opacity={0.7}>
|
||||
Alert when the value
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.thresholdType`}
|
||||
control={control}
|
||||
/>
|
||||
<NumberInput
|
||||
min={MINIMUM_THRESHOLD_VALUE}
|
||||
size="xs"
|
||||
w={80}
|
||||
control={control}
|
||||
name={`alert.threshold`}
|
||||
/>
|
||||
over
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.interval`}
|
||||
control={control}
|
||||
/>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
window via
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.channel.type`}
|
||||
control={control}
|
||||
/>
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" opacity={0.7}>
|
||||
Alert when the value
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(
|
||||
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
|
||||
)}
|
||||
size="xs"
|
||||
name={`alert.thresholdType`}
|
||||
control={control}
|
||||
/>
|
||||
<NumberInput
|
||||
min={MINIMUM_THRESHOLD_VALUE}
|
||||
size="xs"
|
||||
w={80}
|
||||
control={control}
|
||||
name={`alert.threshold`}
|
||||
/>
|
||||
over
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.interval`}
|
||||
control={control}
|
||||
/>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
window via
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.channel.type`}
|
||||
control={control}
|
||||
/>
|
||||
</Group>
|
||||
{(alert as any)?.createdBy && (
|
||||
<Text size="xs" opacity={0.6}>
|
||||
Created by{' '}
|
||||
{(alert as any).createdBy?.name ||
|
||||
(alert as any).createdBy?.email}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
|
||||
Send to
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
|
||||
import { hdxServer } from './api';
|
||||
import { IS_LOCAL_MODE } from './config';
|
||||
import { SavedSearchWithEnhancedAlerts } from './types';
|
||||
|
||||
export function useSavedSearches() {
|
||||
return useQuery({
|
||||
|
|
@ -17,7 +18,9 @@ export function useSavedSearches() {
|
|||
if (IS_LOCAL_MODE) {
|
||||
return [];
|
||||
} else {
|
||||
return hdxServer('saved-search').json<SavedSearch[]>();
|
||||
return hdxServer('saved-search').json<
|
||||
SavedSearchWithEnhancedAlerts[]
|
||||
>();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -25,7 +28,10 @@ export function useSavedSearches() {
|
|||
|
||||
export function useSavedSearch(
|
||||
{ id }: { id: string },
|
||||
options: Omit<Partial<UseQueryOptions<SavedSearch[], Error>>, 'select'> = {},
|
||||
options: Omit<
|
||||
Partial<UseQueryOptions<SavedSearchWithEnhancedAlerts[], Error>>,
|
||||
'select'
|
||||
> = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['saved-search'],
|
||||
|
|
@ -33,7 +39,7 @@ export function useSavedSearch(
|
|||
if (IS_LOCAL_MODE) {
|
||||
return [];
|
||||
}
|
||||
return hdxServer('saved-search').json<SavedSearch[]>();
|
||||
return hdxServer('saved-search').json<SavedSearchWithEnhancedAlerts[]>();
|
||||
},
|
||||
select: data => data.find(s => s.id === id),
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -53,10 +53,25 @@ export type AlertsPageItem = Alert & {
|
|||
history: AlertHistory[];
|
||||
dashboard?: ServerDashboard;
|
||||
savedSearch?: SavedSearch;
|
||||
createdBy?: {
|
||||
email: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AlertWithCreatedBy = Alert & {
|
||||
createdBy?: {
|
||||
email: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type SavedSearch = z.infer<typeof SavedSearchSchema>;
|
||||
|
||||
export type SavedSearchWithEnhancedAlerts = Omit<SavedSearch, 'alerts'> & {
|
||||
alerts?: AlertWithCreatedBy[];
|
||||
};
|
||||
|
||||
export type SearchConfig = {
|
||||
select?: string | null;
|
||||
source?: string | null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue