@@ -666,11 +718,15 @@ interface DateTimeSelectionV2Props {
showAutoRefresh: boolean;
hideShareModal?: boolean;
showOldExplorerCTA?: boolean;
+ showResetButton?: boolean;
+ defaultRelativeTime?: Time;
}
DateTimeSelection.defaultProps = {
hideShareModal: false,
showOldExplorerCTA: false,
+ showResetButton: false,
+ defaultRelativeTime: RelativeTimeMap['6hr'] as Time,
};
interface DispatchProps {
updateTimeInterval: (
diff --git a/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts
new file mode 100644
index 0000000000..d2eb2c09e0
--- /dev/null
+++ b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts
@@ -0,0 +1,49 @@
+import { Color } from '@signozhq/design-tokens';
+import uPlot from 'uplot';
+
+const bucketIncr = 5;
+
+function heatmapPlugin(): uPlot.Plugin {
+ function fillStyle(count: number): string {
+ const colors = [Color.BG_CHERRY_500, Color.BG_SLATE_400];
+ return colors[count - 1];
+ }
+
+ return {
+ hooks: {
+ draw: (u: uPlot): void => {
+ const { ctx, data } = u;
+
+ const yData = (data[3] as unknown) as number[][];
+ const yQtys = (data[4] as unknown) as number[][];
+ const yHgt = Math.floor(
+ u.valToPos(bucketIncr, 'y', true) - u.valToPos(0, 'y', true),
+ );
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
+ ctx.clip();
+
+ yData.forEach((yVals, xi) => {
+ const xPos = Math.floor(u.valToPos(data[0][xi], 'x', true));
+
+ // const maxCount = yQtys[xi].reduce(
+ // (acc, val) => Math.max(val, acc),
+ // -Infinity,
+ // );
+
+ yVals.forEach((yVal, yi) => {
+ const yPos = Math.floor(u.valToPos(yVal, 'y', true));
+
+ ctx.fillStyle = fillStyle(yQtys[xi][yi]);
+ ctx.fillRect(xPos - 4, yPos, 30, yHgt);
+ });
+ });
+
+ ctx.restore();
+ },
+ },
+ };
+}
+export default heatmapPlugin;
diff --git a/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts
new file mode 100644
index 0000000000..b740fb2b2c
--- /dev/null
+++ b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts
@@ -0,0 +1,632 @@
+import uPlot from 'uplot';
+
+export function pointWithin(
+ px: number,
+ py: number,
+ rlft: number,
+ rtop: number,
+ rrgt: number,
+ rbtm: number,
+): boolean {
+ return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm;
+}
+const MAX_OBJECTS = 10;
+const MAX_LEVELS = 4;
+
+export class Quadtree {
+ x: number;
+
+ y: number;
+
+ w: number;
+
+ h: number;
+
+ l: number;
+
+ o: any[];
+
+ q: Quadtree[] | null;
+
+ constructor(x: number, y: number, w: number, h: number, l?: number) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ this.l = l || 0;
+ this.o = [];
+ this.q = null;
+ }
+
+ split(): void {
+ const w = this.w / 2;
+ const h = this.h / 2;
+ const l = this.l + 1;
+
+ this.q = [
+ // top right
+ new Quadtree(this.x + w, this.y, w, h, l),
+ // top left
+ new Quadtree(this.x, this.y, w, h, l),
+ // bottom left
+ new Quadtree(this.x, this.y + h, w, h, l),
+ // bottom right
+ new Quadtree(this.x + w, this.y + h, w, h, l),
+ ];
+ }
+
+ quads(
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ cb: (quad: Quadtree) => void,
+ ): void {
+ const { q } = this;
+ const hzMid = this.x + this.w / 2;
+ const vtMid = this.y + this.h / 2;
+ const startIsNorth = y < vtMid;
+ const startIsWest = x < hzMid;
+ const endIsEast = x + w > hzMid;
+ const endIsSouth = y + h > vtMid;
+ if (q) {
+ // top-right quad
+ if (startIsNorth && endIsEast) {
+ cb(q[0]);
+ }
+ // top-left quad
+ if (startIsWest && startIsNorth) {
+ cb(q[1]);
+ }
+ // bottom-left quad
+ if (startIsWest && endIsSouth) {
+ cb(q[2]);
+ }
+ // bottom-right quad
+ if (endIsEast && endIsSouth) {
+ cb(q[3]);
+ }
+ }
+ }
+
+ add(o: any): void {
+ if (this.q != null) {
+ this.quads(o.x, o.y, o.w, o.h, (q) => {
+ q.add(o);
+ });
+ } else {
+ const os = this.o;
+
+ os.push(o);
+
+ if (os.length > MAX_OBJECTS && this.l < MAX_LEVELS) {
+ this.split();
+
+ for (let i = 0; i < os.length; i++) {
+ const oi = os[i];
+
+ this.quads(oi.x, oi.y, oi.w, oi.h, (q) => {
+ q.add(oi);
+ });
+ }
+
+ this.o.length = 0;
+ }
+ }
+ }
+
+ get(x: number, y: number, w: number, h: number, cb: (o: any) => void): void {
+ const os = this.o;
+
+ for (let i = 0; i < os.length; i++) {
+ cb(os[i]);
+ }
+
+ if (this.q != null) {
+ this.quads(x, y, w, h, (q) => {
+ q.get(x, y, w, h, cb);
+ });
+ }
+ }
+
+ clear(): void {
+ this.o.length = 0;
+ this.q = null;
+ }
+}
+
+Object.assign(Quadtree.prototype, {
+ split: Quadtree.prototype.split,
+ quads: Quadtree.prototype.quads,
+ add: Quadtree.prototype.add,
+ get: Quadtree.prototype.get,
+ clear: Quadtree.prototype.clear,
+});
+
+const { round, min, ceil } = Math;
+
+function roundDec(val: number, dec: number): number {
+ return Math.round(val * 10 ** dec) / 10 ** dec;
+}
+
+export const SPACE_BETWEEN = 1;
+export const SPACE_AROUND = 2;
+export const SPACE_EVENLY = 3;
+export const inf = Infinity;
+
+const coord = (i: number, offs: number, iwid: number, gap: number): number =>
+ roundDec(offs + i * (iwid + gap), 6);
+
+export function distr(
+ numItems: number,
+ sizeFactor: number,
+ justify: number,
+ onlyIdx: number | null,
+ each: (i: number, offPct: number, dimPct: number) => void,
+): void {
+ const space = 1 - sizeFactor;
+
+ let gap = 0;
+ if (justify === SPACE_BETWEEN) {
+ gap = space / (numItems - 1);
+ } else if (justify === SPACE_AROUND) {
+ gap = space / numItems;
+ } else if (justify === SPACE_EVENLY) {
+ gap = space / (numItems + 1);
+ }
+
+ if (Number.isNaN(gap) || gap === Infinity) gap = 0;
+
+ let offs = 0;
+ if (justify === SPACE_AROUND) {
+ offs = gap / 2;
+ } else if (justify === SPACE_EVENLY) {
+ offs = gap;
+ }
+
+ const iwid = sizeFactor / numItems;
+ const iwidRounded = roundDec(iwid, 6);
+
+ if (onlyIdx == null) {
+ for (let i = 0; i < numItems; i++)
+ each(i, coord(i, offs, iwid, gap), iwidRounded);
+ } else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), iwidRounded);
+}
+
+function timelinePlugin(opts: any): any {
+ const { mode, count, fill, stroke, laneWidthOption, showGrid } = opts;
+
+ const pxRatio = devicePixelRatio;
+
+ const laneWidth = laneWidthOption ?? 0.9;
+
+ const laneDistr = SPACE_BETWEEN;
+
+ const font = `${round(14 * pxRatio)}px Geist Mono`;
+
+ function walk(
+ yIdx: number | null,
+ count: number,
+ dim: number,
+ draw: (iy: number, y0: number, hgt: number) => void,
+ ): void {
+ distr(
+ count,
+ laneWidth,
+ laneDistr,
+ yIdx,
+ (i: number, offPct: number, dimPct: number) => {
+ const laneOffPx = dim * offPct;
+ const laneWidPx = dim * dimPct;
+
+ draw(i, laneOffPx, laneWidPx);
+ },
+ );
+ }
+
+ const size = opts.size ?? [0.6, Infinity];
+ const align = opts.align ?? 0;
+
+ const gapFactor = 1 - size[0];
+ const maxWidth = (size[1] ?? inf) * pxRatio;
+
+ const fillPaths = new Map();
+ const strokePaths = new Map();
+
+ function drawBoxes(ctx: CanvasRenderingContext2D): void {
+ fillPaths.forEach((fillPath, fillStyle) => {
+ ctx.fillStyle = fillStyle;
+ ctx.fill(fillPath);
+ });
+
+ strokePaths.forEach((strokePath, strokeStyle) => {
+ ctx.strokeStyle = strokeStyle;
+ ctx.stroke(strokePath);
+ });
+
+ fillPaths.clear();
+ strokePaths.clear();
+ }
+ let qt: Quadtree;
+
+ function putBox(
+ ctx: CanvasRenderingContext2D,
+ rect: (path: Path2D, x: number, y: number, w: number, h: number) => void,
+ xOff: number,
+ yOff: number,
+ lft: number,
+ top: number,
+ wid: number,
+ hgt: number,
+ strokeWidth: number,
+ iy: number,
+ ix: number,
+ value: number | null,
+ ): void {
+ const fillStyle = fill(iy + 1, ix, value);
+ let fillPath = fillPaths.get(fillStyle);
+
+ if (fillPath == null) fillPaths.set(fillStyle, (fillPath = new Path2D()));
+
+ rect(fillPath, lft, top, wid, hgt);
+
+ if (strokeWidth) {
+ const strokeStyle = stroke(iy + 1, ix, value);
+ let strokePath = strokePaths.get(strokeStyle);
+
+ if (strokePath == null)
+ strokePaths.set(strokeStyle, (strokePath = new Path2D()));
+
+ rect(
+ strokePath,
+ lft + strokeWidth / 2,
+ top + strokeWidth / 2,
+ wid - strokeWidth,
+ hgt - strokeWidth,
+ );
+ }
+
+ qt.add({
+ x: round(lft - xOff),
+ y: round(top - yOff),
+ w: wid,
+ h: hgt,
+ sidx: iy + 1,
+ didx: ix,
+ });
+ }
+
+ // eslint-disable-next-line sonarjs/cognitive-complexity
+ function drawPaths(u: uPlot, sidx: number, idx0: number, idx1: number): null {
+ uPlot.orient(
+ u,
+ sidx,
+ (
+ series,
+ dataX,
+ dataY,
+ scaleX,
+ scaleY,
+ valToPosX,
+ valToPosY,
+ xOff,
+ yOff,
+ xDim,
+ yDim,
+ moveTo,
+ lineTo,
+ rect,
+ ) => {
+ const strokeWidth = round((series.width || 0) * pxRatio);
+
+ u.ctx.save();
+ rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
+ u.ctx.clip();
+
+ walk(sidx - 1, count, yDim, (iy: number, y0: number, hgt: number) => {
+ // draw spans
+ if (mode === 1) {
+ for (let ix = 0; ix < dataY.length; ix++) {
+ if (dataY[ix] != null) {
+ const lft = round(valToPosX(dataX[ix], scaleX, xDim, xOff));
+
+ let nextIx = ix;
+ // eslint-disable-next-line no-empty
+ while (dataY[++nextIx] === undefined && nextIx < dataY.length) {}
+
+ // to now (not to end of chart)
+ const rgt =
+ nextIx === dataY.length
+ ? xOff + xDim + strokeWidth
+ : round(valToPosX(dataX[nextIx], scaleX, xDim, xOff));
+
+ putBox(
+ u.ctx,
+ rect,
+ xOff,
+ yOff,
+ lft,
+ round(yOff + y0),
+ rgt - lft,
+ round(hgt),
+ strokeWidth,
+ iy,
+ ix,
+ dataY[ix],
+ );
+
+ ix = nextIx - 1;
+ }
+ }
+ }
+ // draw matrix
+ else {
+ const colWid =
+ valToPosX(dataX[1], scaleX, xDim, xOff) -
+ valToPosX(dataX[0], scaleX, xDim, xOff);
+ const gapWid = colWid * gapFactor;
+ const barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth);
+ let xShift;
+ if (align === 1) {
+ xShift = 0;
+ } else if (align === -1) {
+ xShift = barWid;
+ } else {
+ xShift = barWid / 2;
+ }
+
+ for (let ix = idx0; ix <= idx1; ix++) {
+ if (dataY[ix] != null) {
+ // TODO: all xPos can be pre-computed once for all series in aligned set
+ const lft = valToPosX(dataX[ix], scaleX, xDim, xOff);
+
+ putBox(
+ u.ctx,
+ rect,
+ xOff,
+ yOff,
+ round(lft - xShift),
+ round(yOff + y0),
+ barWid,
+ round(hgt),
+ strokeWidth,
+ iy,
+ ix,
+ dataY[ix],
+ );
+ }
+ }
+ }
+ });
+
+ // eslint-disable-next-line no-param-reassign
+ u.ctx.lineWidth = strokeWidth;
+ drawBoxes(u.ctx);
+
+ u.ctx.restore();
+ },
+ );
+
+ return null;
+ }
+ const yMids = Array(count).fill(0);
+ function drawPoints(u: uPlot, sidx: number): boolean {
+ u.ctx.save();
+ u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
+ u.ctx.clip();
+
+ const { ctx } = u;
+ ctx.font = font;
+ ctx.fillStyle = 'black';
+ ctx.textAlign = mode === 1 ? 'left' : 'center';
+ ctx.textBaseline = 'middle';
+
+ uPlot.orient(
+ u,
+ sidx,
+ (
+ series,
+ dataX,
+ dataY,
+ scaleX,
+ scaleY,
+ valToPosX,
+ valToPosY,
+ xOff,
+ yOff,
+ xDim,
+ ) => {
+ const strokeWidth = round((series.width || 0) * pxRatio);
+ const textOffset = mode === 1 ? strokeWidth + 2 : 0;
+
+ const y = round(yOff + yMids[sidx - 1]);
+ if (opts.displayTimelineValue) {
+ for (let ix = 0; ix < dataY.length; ix++) {
+ if (dataY[ix] != null) {
+ const x = valToPosX(dataX[ix], scaleX, xDim, xOff) + textOffset;
+ u.ctx.fillText(String(dataY[ix]), x, y);
+ }
+ }
+ }
+ },
+ );
+
+ u.ctx.restore();
+
+ return false;
+ }
+
+ const hovered = Array(count).fill(null);
+
+ const ySplits = Array(count).fill(0);
+
+ const fmtDate = uPlot.fmtDate('{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}');
+ let legendTimeValueEl: HTMLElement | null = null;
+
+ return {
+ hooks: {
+ init: (u: uPlot): void => {
+ legendTimeValueEl = u.root.querySelector('.u-series:first-child .u-value');
+ },
+ drawClear: (u: uPlot): void => {
+ qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
+
+ qt.clear();
+
+ // force-clear the path cache to cause drawBars() to rebuild new quadtree
+ u.series.forEach((s: any) => {
+ // eslint-disable-next-line no-param-reassign
+ s._paths = null;
+ });
+ },
+ setCursor: (u: {
+ posToVal: (arg0: any, arg1: string) => any;
+ cursor: { left: any };
+ scales: { x: { time: any } };
+ }): any => {
+ if (mode === 1 && legendTimeValueEl) {
+ const val = u.posToVal(u.cursor.left, 'x');
+ legendTimeValueEl.textContent = u.scales.x.time
+ ? fmtDate(new Date(val * 1e3))
+ : val.toFixed(2);
+ }
+ },
+ },
+ // eslint-disable-next-line sonarjs/cognitive-complexity
+ opts: (u: { series: { label: any }[] }, opts: any): any => {
+ uPlot.assign(opts, {
+ cursor: {
+ // x: false,
+ y: false,
+ dataIdx: (
+ u: { cursor: { left: number } },
+ seriesIdx: number,
+ closestIdx: any,
+ ) => {
+ if (seriesIdx === 0) return closestIdx;
+
+ const cx = round(u.cursor.left * pxRatio);
+
+ if (cx >= 0) {
+ const cy = yMids[seriesIdx - 1];
+
+ hovered[seriesIdx - 1] = null;
+
+ qt.get(cx, cy, 1, 1, (o: { x: any; y: any; w: any; h: any }) => {
+ if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h))
+ hovered[seriesIdx - 1] = o;
+ });
+ }
+
+ return hovered[seriesIdx - 1]?.didx;
+ },
+ points: {
+ fill: 'rgba(0,0,0,0.3)',
+ bbox: (u: any, seriesIdx: number) => {
+ const hRect = hovered[seriesIdx - 1];
+
+ return {
+ left: hRect ? round(hRect.x / devicePixelRatio) : -10,
+ top: hRect ? round(hRect.y / devicePixelRatio) : -10,
+ width: hRect ? round(hRect.w / devicePixelRatio) : 0,
+ height: hRect ? round(hRect.h / devicePixelRatio) : 0,
+ };
+ },
+ },
+ },
+ scales: {
+ x: {
+ range(u: { data: number[][] }, min: number, max: number) {
+ if (mode === 2) {
+ const colWid = u.data[0][1] - u.data[0][0];
+ const scalePad = colWid / 2;
+
+ // eslint-disable-next-line no-param-reassign
+ if (min <= u.data[0][0]) min = u.data[0][0] - scalePad;
+
+ const lastIdx = u.data[0].length - 1;
+
+ // eslint-disable-next-line no-param-reassign
+ if (max >= u.data[0][lastIdx]) max = u.data[0][lastIdx] + scalePad;
+ }
+
+ return [min, max];
+ },
+ },
+ y: {
+ range: [0, 1],
+ },
+ },
+ });
+
+ uPlot.assign(opts.axes[0], {
+ splits:
+ mode === 2
+ ? (
+ u: { data: any[][] },
+ scaleMin: number,
+ scaleMax: number,
+ foundIncr: number,
+ ): any => {
+ const splits = [];
+
+ const dataIncr = u.data[0][1] - u.data[0][0];
+ const skipFactor = ceil(foundIncr / dataIncr);
+
+ for (let i = 0; i < u.data[0].length; i += skipFactor) {
+ const v = u.data[0][i];
+
+ if (v >= scaleMin && v <= scaleMax) splits.push(v);
+ }
+
+ return splits;
+ }
+ : null,
+ grid: {
+ show: showGrid ?? mode !== 2,
+ },
+ });
+
+ uPlot.assign(opts.axes[1], {
+ splits: (u: {
+ bbox: { height: any };
+ posToVal: (arg0: number, arg1: string) => any;
+ }) => {
+ walk(null, count, u.bbox.height, (iy: any, y0: number, hgt: number) => {
+ // vertical midpoints of each series' timeline (stored relative to .u-over)
+ yMids[iy] = round(y0 + hgt / 2);
+ ySplits[iy] = u.posToVal(yMids[iy] / pxRatio, 'y');
+ });
+
+ return ySplits;
+ },
+ values: () =>
+ Array(count)
+ .fill(null)
+ .map((v, i) => u.series[i + 1].label),
+ gap: 15,
+ size: 70,
+ grid: { show: false },
+ ticks: { show: false },
+
+ side: 3,
+ });
+
+ opts.series.forEach((s: any, i: number) => {
+ if (i > 0) {
+ uPlot.assign(s, {
+ // width: 0,
+ // pxAlign: false,
+ // stroke: "rgba(255,0,0,0.5)",
+ paths: drawPaths,
+ points: {
+ show: drawPoints,
+ },
+ });
+ }
+ });
+ },
+ };
+}
+
+export default timelinePlugin;
diff --git a/frontend/src/pages/AlertDetails/AlertDetails.styles.scss b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss
new file mode 100644
index 0000000000..62eeb96ae0
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss
@@ -0,0 +1,189 @@
+@mixin flex-center {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.alert-details-tabs {
+ .top-level-tab.periscope-tab {
+ padding: 2px 0;
+ }
+ .ant-tabs {
+ &-nav {
+ margin-bottom: 0 !important;
+ &::before {
+ border-bottom: 1px solid var(--bg-slate-500) !important;
+ }
+ }
+ &-tab {
+ &[data-node-key='TriggeredAlerts'] {
+ margin-left: 16px;
+ }
+ &:not(:first-of-type) {
+ margin-left: 24px !important;
+ }
+ .periscope-tab {
+ font-size: 14px;
+ color: var(--text-vanilla-100);
+ line-height: 20px;
+ letter-spacing: -0.07px;
+ gap: 10px;
+ }
+ [aria-selected='false'] {
+ .periscope-tab {
+ color: var(--text-vanilla-400);
+ }
+ }
+ }
+ }
+}
+
+.alert-details {
+ margin-top: 10px;
+ .divider {
+ border-color: var(--bg-slate-500);
+ margin: 16px 0;
+ }
+ .breadcrumb-divider {
+ margin-top: 10px;
+ }
+ &__breadcrumb {
+ ol {
+ align-items: center;
+ }
+ padding-left: 16px;
+ .breadcrumb-item {
+ color: var(--text-vanilla-400);
+ font-size: 14px;
+ line-height: 20px;
+ letter-spacing: 0.25px;
+ padding: 0;
+ }
+
+ .ant-breadcrumb-separator,
+ .breadcrumb-item--last {
+ color: var(--text-vanilla-500);
+ font-family: 'Geist Mono';
+ }
+ }
+ .tabs-and-filters {
+ margin: 1rem 0;
+
+ .ant-tabs {
+ &-ink-bar {
+ background-color: transparent;
+ }
+ &-nav {
+ &-wrap {
+ padding: 0 16px 16px 16px;
+ }
+ &::before {
+ border-bottom: none !important;
+ }
+ }
+ &-tab {
+ margin-left: 0 !important;
+ padding: 0;
+
+ &-btn {
+ padding: 6px 17px;
+ color: var(--text-vanilla-400) !important;
+ letter-spacing: -0.07px;
+ font-size: 14px;
+
+ &[aria-selected='true'] {
+ color: var(--text-vanilla-100) !important;
+ }
+ }
+ &-active {
+ background: var(--bg-slate-400, #1d212d);
+ }
+ }
+ &-extra-content {
+ padding: 0 16px 16px;
+ }
+ &-nav-list {
+ border: 1px solid var(--bg-slate-400);
+ background: var(--bg-ink-400);
+ border-radius: 2px;
+ }
+ }
+
+ .tab-item {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ }
+ .filters {
+ @include flex-center;
+ gap: 16px;
+ .reset-button {
+ @include flex-center;
+ }
+ }
+ }
+}
+
+.lightMode {
+ .alert-details {
+ &-tabs {
+ .ant-tabs-nav {
+ &::before {
+ border-bottom: 1px solid var(--bg-vanilla-300) !important;
+ }
+ }
+ }
+ &__breadcrumb {
+ .ant-breadcrumb-link {
+ color: var(--text-ink-400);
+ }
+ .ant-breadcrumb-separator,
+ span.ant-breadcrumb-link {
+ color: var(--text-ink-500);
+ }
+ }
+ .tabs-and-filters {
+ .ant-tabs {
+ &-nav-list {
+ border: 1px solid var(--bg-vanilla-300);
+ background: var(--bg-vanilla-300);
+ }
+ &-tab {
+ &-btn {
+ &[aria-selected='true'] {
+ color: var(--text-robin-500) !important;
+ }
+ color: var(--text-ink-400) !important;
+ }
+ &-active {
+ background: var(--bg-vanilla-100);
+ }
+ }
+ }
+ }
+ .divider {
+ border-color: var(--bg-vanilla-300);
+ }
+ }
+
+ .alert-details-tabs {
+ .ant-tabs {
+ &-nav {
+ &::before {
+ border: none !important;
+ }
+ }
+ &-tab {
+ .periscope-tab {
+ color: var(--text-ink-300);
+ }
+ [aria-selected='true'] {
+ .periscope-tab {
+ color: var(--text-ink-400);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/src/pages/AlertDetails/AlertDetails.tsx b/frontend/src/pages/AlertDetails/AlertDetails.tsx
new file mode 100644
index 0000000000..c79478fb77
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertDetails.tsx
@@ -0,0 +1,123 @@
+import './AlertDetails.styles.scss';
+
+import { Breadcrumb, Button, Divider } from 'antd';
+import { Filters } from 'components/AlertDetailsFilters/Filters';
+import NotFound from 'components/NotFound';
+import RouteTab from 'components/RouteTab';
+import Spinner from 'components/Spinner';
+import ROUTES from 'constants/routes';
+import history from 'lib/history';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useLocation } from 'react-router-dom';
+
+import AlertHeader from './AlertHeader/AlertHeader';
+import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
+import { AlertDetailsStatusRendererProps } from './types';
+
+function AlertDetailsStatusRenderer({
+ isLoading,
+ isError,
+ isRefetching,
+ data,
+}: AlertDetailsStatusRendererProps): JSX.Element {
+ const alertRuleDetails = useMemo(() => data?.payload?.data, [data]);
+ const { t } = useTranslation('common');
+
+ if (isLoading || isRefetching) {
+ return
;
+ }
+
+ if (isError) {
+ return
{data?.error || t('something_went_wrong')}
;
+ }
+
+ return
;
+}
+
+function BreadCrumbItem({
+ title,
+ isLast,
+ route,
+}: {
+ title: string | null;
+ isLast?: boolean;
+ route?: string;
+}): JSX.Element {
+ if (isLast) {
+ return
{title}
;
+ }
+ const handleNavigate = (): void => {
+ if (!route) {
+ return;
+ }
+ history.push(ROUTES.LIST_ALL_ALERT);
+ };
+
+ return (
+
+ );
+}
+
+BreadCrumbItem.defaultProps = {
+ isLast: false,
+ route: '',
+};
+
+function AlertDetails(): JSX.Element {
+ const { pathname } = useLocation();
+ const { routes } = useRouteTabUtils();
+
+ const {
+ isLoading,
+ isRefetching,
+ isError,
+ ruleId,
+ isValidRuleId,
+ alertDetailsResponse,
+ } = useGetAlertRuleDetails();
+
+ if (
+ isError ||
+ !isValidRuleId ||
+ (alertDetailsResponse && alertDetailsResponse.statusCode !== 200)
+ ) {
+ return
;
+ }
+
+ return (
+
+
+ ),
+ },
+ {
+ title:
,
+ },
+ ]}
+ />
+
+
+
+
+
+ }
+ />
+
+
+ );
+}
+
+export default AlertDetails;
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss
new file mode 100644
index 0000000000..edd94a5bcd
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss
@@ -0,0 +1,63 @@
+.alert-action-buttons {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: var(--bg-slate-400);
+ .ant-divider-vertical {
+ height: 16px;
+ border-color: var(--bg-slate-400);
+ margin: 0;
+ }
+ .dropdown-icon {
+ margin-right: 4px;
+ }
+}
+.dropdown-menu {
+ border-radius: 4px;
+ box-shadow: none;
+ background: linear-gradient(
+ 138.7deg,
+ rgba(18, 19, 23, 0.8) 0%,
+ rgba(18, 19, 23, 0.9) 98.68%
+ );
+
+ .dropdown-divider {
+ margin: 0;
+ }
+
+ .delete-button {
+ border: none;
+ display: flex;
+ align-items: center;
+ width: 100%;
+
+ &,
+ & span {
+ &:hover {
+ background: var(--bg-slate-400);
+ color: var(--bg-cherry-400);
+ }
+ color: var(--bg-cherry-400);
+ font-size: 14px;
+ }
+ }
+}
+
+.lightMode {
+ .alert-action-buttons {
+ .ant-divider-vertical {
+ border-color: var(--bg-vanilla-300);
+ }
+ }
+ .dropdown-menu {
+ background: inherit;
+ .delete-button {
+ &,
+ &span {
+ &:hover {
+ background: var(--bg-vanilla-300);
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx
new file mode 100644
index 0000000000..186a34676b
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx
@@ -0,0 +1,111 @@
+import './ActionButtons.styles.scss';
+
+import { Color } from '@signozhq/design-tokens';
+import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd';
+import { QueryParams } from 'constants/query';
+import ROUTES from 'constants/routes';
+import { useIsDarkMode } from 'hooks/useDarkMode';
+import useUrlQuery from 'hooks/useUrlQuery';
+import history from 'lib/history';
+import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react';
+import {
+ useAlertRuleDelete,
+ useAlertRuleDuplicate,
+ useAlertRuleStatusToggle,
+} from 'pages/AlertDetails/hooks';
+import CopyToClipboard from 'periscope/components/CopyToClipboard';
+import { useAlertRule } from 'providers/Alert';
+import React from 'react';
+import { CSSProperties } from 'styled-components';
+import { AlertDef } from 'types/api/alerts/def';
+
+import { AlertHeaderProps } from '../AlertHeader';
+
+const menuItemStyle: CSSProperties = {
+ fontSize: '14px',
+ letterSpacing: '0.14px',
+};
+function AlertActionButtons({
+ ruleId,
+ alertDetails,
+}: {
+ ruleId: string;
+ alertDetails: AlertHeaderProps['alertDetails'];
+}): JSX.Element {
+ const { isAlertRuleDisabled } = useAlertRule();
+ const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
+
+ const { handleAlertDuplicate } = useAlertRuleDuplicate({
+ alertDetails: (alertDetails as unknown) as AlertDef,
+ });
+ const { handleAlertDelete } = useAlertRuleDelete({ ruleId: Number(ruleId) });
+
+ const params = useUrlQuery();
+
+ const handleRename = React.useCallback(() => {
+ params.set(QueryParams.ruleId, String(ruleId));
+ history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
+ }, [params, ruleId]);
+
+ const menu: MenuProps['items'] = React.useMemo(
+ () => [
+ {
+ key: 'rename-rule',
+ label: 'Rename',
+ icon:
,
+ onClick: (): void => handleRename(),
+ style: menuItemStyle,
+ },
+ {
+ key: 'duplicate-rule',
+ label: 'Duplicate',
+ icon:
,
+ onClick: (): void => handleAlertDuplicate(),
+ style: menuItemStyle,
+ },
+ { type: 'divider' },
+ {
+ key: 'delete-rule',
+ label: 'Delete',
+ icon:
,
+ onClick: (): void => handleAlertDelete(),
+ style: {
+ ...menuItemStyle,
+ color: Color.BG_CHERRY_400,
+ },
+ },
+ ],
+ [handleAlertDelete, handleAlertDuplicate, handleRename],
+ );
+ const isDarkMode = useIsDarkMode();
+
+ return (
+
+
+ {isAlertRuleDisabled !== undefined && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default AlertActionButtons;
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss
new file mode 100644
index 0000000000..10a05f2258
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss
@@ -0,0 +1,50 @@
+.alert-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 0 16px;
+
+ &__info-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ height: 54px;
+
+ .top-section {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ .alert-title-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ .alert-title {
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--text-vanilla-100);
+ line-height: 24px;
+ letter-spacing: -0.08px;
+ }
+ }
+ }
+ .bottom-section {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ }
+ }
+}
+
+.lightMode {
+ .alert-info {
+ &__info-wrapper {
+ .top-section {
+ .alert-title-wrapper {
+ .alert-title {
+ color: var(--text-ink-100);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx
new file mode 100644
index 0000000000..f4ff7b933b
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx
@@ -0,0 +1,66 @@
+import './AlertHeader.styles.scss';
+
+import { useAlertRule } from 'providers/Alert';
+import { useEffect, useMemo } from 'react';
+
+import AlertActionButtons from './ActionButtons/ActionButtons';
+import AlertLabels from './AlertLabels/AlertLabels';
+import AlertSeverity from './AlertSeverity/AlertSeverity';
+import AlertState from './AlertState/AlertState';
+
+export type AlertHeaderProps = {
+ alertDetails: {
+ state: string;
+ alert: string;
+ id: string;
+ labels: Record
;
+ disabled: boolean;
+ };
+};
+function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
+ const { state, alert, labels, disabled } = alertDetails;
+
+ const labelsWithoutSeverity = useMemo(
+ () =>
+ Object.fromEntries(
+ Object.entries(labels).filter(([key]) => key !== 'severity'),
+ ),
+ [labels],
+ );
+
+ const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule();
+
+ useEffect(() => {
+ if (isAlertRuleDisabled === undefined) {
+ setIsAlertRuleDisabled(disabled);
+ }
+ }, [disabled, setIsAlertRuleDisabled, isAlertRuleDisabled]);
+
+ return (
+
+
+
+
+
+
+ {/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
+ {/*
*/}
+
+
+
+
+
+ );
+}
+
+export default AlertHeader;
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss
new file mode 100644
index 0000000000..3468bad7ec
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss
@@ -0,0 +1,5 @@
+.alert-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 6px;
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx
new file mode 100644
index 0000000000..bdc5eaa019
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx
@@ -0,0 +1,31 @@
+import './AlertLabels.styles.scss';
+
+import KeyValueLabel from 'periscope/components/KeyValueLabel';
+import SeeMore from 'periscope/components/SeeMore';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AlertLabelsProps = {
+ labels: Record;
+ initialCount?: number;
+};
+
+function AlertLabels({
+ labels,
+ initialCount = 2,
+}: AlertLabelsProps): JSX.Element {
+ return (
+
+
+ {Object.entries(labels).map(([key, value]) => (
+
+ ))}
+
+
+ );
+}
+
+AlertLabels.defaultProps = {
+ initialCount: 2,
+};
+
+export default AlertLabels;
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss
new file mode 100644
index 0000000000..ba0226a11d
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss
@@ -0,0 +1,40 @@
+@mixin severity-styles($background, $text-color) {
+ .alert-severity__icon {
+ background: $background;
+ }
+ .alert-severity__text {
+ color: $text-color;
+ }
+}
+
+.alert-severity {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ overflow: hidden;
+ &__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 14px;
+ width: 14px;
+ border-radius: 3.5px;
+ }
+ &__text {
+ color: var(--text-sakura-400);
+ font-size: 14px;
+ line-height: 18px;
+ }
+
+ &--critical,
+ &--error {
+ @include severity-styles(rgba(245, 108, 135, 0.2), var(--text-sakura-400));
+ }
+ &--warning {
+ @include severity-styles(rgba(255, 215, 120, 0.2), var(--text-amber-400));
+ }
+ &--info {
+ @include severity-styles(rgba(113, 144, 249, 0.2), var(--text-robin-400));
+ }
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx
new file mode 100644
index 0000000000..90e7c14de4
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx
@@ -0,0 +1,42 @@
+import './AlertSeverity.styles.scss';
+
+import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
+import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
+import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
+import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
+
+export default function AlertSeverity({
+ severity,
+}: {
+ severity: string;
+}): JSX.Element {
+ const severityConfig: Record> = {
+ critical: {
+ text: 'Critical',
+ className: 'alert-severity--critical',
+ icon: ,
+ },
+ error: {
+ text: 'Error',
+ className: 'alert-severity--error',
+ icon: ,
+ },
+ warning: {
+ text: 'Warning',
+ className: 'alert-severity--warning',
+ icon: ,
+ },
+ info: {
+ text: 'Info',
+ className: 'alert-severity--info',
+ icon: ,
+ },
+ };
+ const severityDetails = severityConfig[severity];
+ return (
+
+
{severityDetails.icon}
+
{severityDetails.text}
+
+ );
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss
new file mode 100644
index 0000000000..582494e54a
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss
@@ -0,0 +1,10 @@
+.alert-state {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ &__label {
+ font-size: 14px;
+ line-height: 18px;
+ letter-spacing: -0.07px;
+ }
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx
new file mode 100644
index 0000000000..d2be316d8a
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx
@@ -0,0 +1,73 @@
+import './AlertState.styles.scss';
+
+import { Color } from '@signozhq/design-tokens';
+import { useIsDarkMode } from 'hooks/useDarkMode';
+import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react';
+
+type AlertStateProps = {
+ state: string;
+ showLabel?: boolean;
+};
+
+export default function AlertState({
+ state,
+ showLabel,
+}: AlertStateProps): JSX.Element {
+ let icon;
+ let label;
+ const isDarkMode = useIsDarkMode();
+ switch (state) {
+ case 'no-data':
+ icon = (
+
+ );
+ label = No Data;
+ break;
+
+ case 'disabled':
+ icon = (
+
+ );
+ label = Muted;
+ break;
+ case 'firing':
+ icon = (
+
+ );
+ label = Firing;
+ break;
+
+ case 'normal':
+ case 'inactive':
+ icon = (
+
+ );
+ label = Resolved;
+ break;
+
+ default:
+ icon = null;
+ }
+
+ return (
+
+ {icon} {showLabel &&
{label}
}
+
+ );
+}
+
+AlertState.defaultProps = {
+ showLabel: false,
+};
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss
new file mode 100644
index 0000000000..97549bf21d
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss
@@ -0,0 +1,22 @@
+.alert-status-info {
+ gap: 6px;
+ color: var(--text-vanilla-400);
+ &__icon {
+ display: flex;
+ align-items: baseline;
+ }
+ &,
+ &__details {
+ display: flex;
+ align-items: center;
+ }
+ &__details {
+ gap: 3px;
+ }
+}
+
+.lightMode {
+ .alert-status-info {
+ color: var(--text-ink-400);
+ }
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx
new file mode 100644
index 0000000000..dd06d107bb
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx
@@ -0,0 +1,54 @@
+import './AlertStatus.styles.scss';
+
+import { Color } from '@signozhq/design-tokens';
+import { CircleCheck, Siren } from 'lucide-react';
+import { useMemo } from 'react';
+import { getDurationFromNow } from 'utils/timeUtils';
+
+import { AlertStatusProps, StatusConfig } from './types';
+
+export default function AlertStatus({
+ status,
+ timestamp,
+}: AlertStatusProps): JSX.Element {
+ const statusConfig: StatusConfig = useMemo(
+ () => ({
+ firing: {
+ icon: ,
+ text: 'Firing since',
+ extraInfo: timestamp ? (
+ <>
+ ⎯
+ {getDurationFromNow(timestamp)}
+ >
+ ) : null,
+ className: 'alert-status-info--firing',
+ },
+ resolved: {
+ icon: (
+
+ ),
+ text: 'Resolved',
+ extraInfo: null,
+ className: 'alert-status-info--resolved',
+ },
+ }),
+ [timestamp],
+ );
+
+ const currentStatus = statusConfig[status];
+
+ return (
+
+
{currentStatus.icon}
+
+
{currentStatus.text}
+ {currentStatus.extraInfo}
+
+
+ );
+}
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts
new file mode 100644
index 0000000000..c297480f38
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts
@@ -0,0 +1,18 @@
+export type AlertStatusProps =
+ | { status: 'firing'; timestamp: number }
+ | { status: 'resolved'; timestamp?: number };
+
+export type StatusConfig = {
+ firing: {
+ icon: JSX.Element;
+ text: string;
+ extraInfo: JSX.Element | null;
+ className: string;
+ };
+ resolved: {
+ icon: JSX.Element;
+ text: string;
+ extraInfo: JSX.Element | null;
+ className: string;
+ };
+};
diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx
new file mode 100644
index 0000000000..fc6219b195
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/hooks.tsx
@@ -0,0 +1,525 @@
+import { FilterValue, SorterResult } from 'antd/es/table/interface';
+import { TablePaginationConfig, TableProps } from 'antd/lib';
+import deleteAlerts from 'api/alerts/delete';
+import get from 'api/alerts/get';
+import getAll from 'api/alerts/getAll';
+import patchAlert from 'api/alerts/patch';
+import ruleStats from 'api/alerts/ruleStats';
+import save from 'api/alerts/save';
+import timelineGraph from 'api/alerts/timelineGraph';
+import timelineTable from 'api/alerts/timelineTable';
+import topContributors from 'api/alerts/topContributors';
+import { TabRoutes } from 'components/RouteTab/types';
+import { QueryParams } from 'constants/query';
+import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
+import ROUTES from 'constants/routes';
+import AlertHistory from 'container/AlertHistory';
+import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
+import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
+import { urlKey } from 'container/AllError/utils';
+import useAxiosError from 'hooks/useAxiosError';
+import { useNotifications } from 'hooks/useNotifications';
+import useUrlQuery from 'hooks/useUrlQuery';
+import createQueryParams from 'lib/createQueryParams';
+import GetMinMax from 'lib/getMinMax';
+import history from 'lib/history';
+import { History, Table } from 'lucide-react';
+import EditRules from 'pages/EditRules';
+import { OrderPreferenceItems } from 'pages/Logs/config';
+import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
+import { useAlertRule } from 'providers/Alert';
+import { useCallback, useMemo } from 'react';
+import { useMutation, useQuery, useQueryClient } from 'react-query';
+import { useSelector } from 'react-redux';
+import { generatePath, useLocation } from 'react-router-dom';
+import { AppState } from 'store/reducers';
+import { ErrorResponse, SuccessResponse } from 'types/api';
+import {
+ AlertDef,
+ AlertRuleStatsPayload,
+ AlertRuleTimelineGraphResponsePayload,
+ AlertRuleTimelineTableResponse,
+ AlertRuleTimelineTableResponsePayload,
+ AlertRuleTopContributorsPayload,
+} from 'types/api/alerts/def';
+import { PayloadProps } from 'types/api/alerts/get';
+import { GlobalReducer } from 'types/reducer/globalTime';
+import { nanoToMilli } from 'utils/timeUtils';
+
+export const useAlertHistoryQueryParams = (): {
+ ruleId: string | null;
+ startTime: number;
+ endTime: number;
+ hasStartAndEndParams: boolean;
+ params: URLSearchParams;
+} => {
+ const params = useUrlQuery();
+
+ const globalTime = useSelector(
+ (state) => state.globalTime,
+ );
+ const startTime = params.get(QueryParams.startTime);
+ const endTime = params.get(QueryParams.endTime);
+
+ const intStartTime = parseInt(startTime || '0', 10);
+ const intEndTime = parseInt(endTime || '0', 10);
+ const hasStartAndEndParams = !!intStartTime && !!intEndTime;
+
+ const { maxTime, minTime } = useMemo(() => {
+ if (hasStartAndEndParams)
+ return GetMinMax('custom', [intStartTime, intEndTime]);
+ return GetMinMax(globalTime.selectedTime);
+ }, [hasStartAndEndParams, intStartTime, intEndTime, globalTime.selectedTime]);
+
+ const ruleId = params.get(QueryParams.ruleId);
+
+ return {
+ ruleId,
+ startTime: Math.floor(nanoToMilli(minTime)),
+ endTime: Math.floor(nanoToMilli(maxTime)),
+ hasStartAndEndParams,
+ params,
+ };
+};
+export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
+ const urlQuery = useUrlQuery();
+
+ const getRouteUrl = (tab: AlertDetailsTab): string => {
+ let route = '';
+ let params = urlQuery.toString();
+ const ruleIdKey = QueryParams.ruleId;
+ const relativeTimeKey = QueryParams.relativeTime;
+
+ switch (tab) {
+ case AlertDetailsTab.OVERVIEW:
+ route = ROUTES.ALERT_OVERVIEW;
+ break;
+ case AlertDetailsTab.HISTORY:
+ params = `${ruleIdKey}=${urlQuery.get(
+ ruleIdKey,
+ )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
+ route = ROUTES.ALERT_HISTORY;
+ break;
+ default:
+ return '';
+ }
+
+ return `${generatePath(route)}?${params}`;
+ };
+
+ const routes = [
+ {
+ Component: EditRules,
+ name: (
+
+ ),
+ route: getRouteUrl(AlertDetailsTab.OVERVIEW),
+ key: ROUTES.ALERT_OVERVIEW,
+ },
+ {
+ Component: AlertHistory,
+ name: (
+
+
+ History
+
+ ),
+ route: getRouteUrl(AlertDetailsTab.HISTORY),
+ key: ROUTES.ALERT_HISTORY,
+ },
+ ];
+
+ return { routes };
+};
+type Props = {
+ ruleId: string | null;
+ isValidRuleId: boolean;
+ alertDetailsResponse:
+ | SuccessResponse
+ | ErrorResponse
+ | undefined;
+ isLoading: boolean;
+ isRefetching: boolean;
+ isError: boolean;
+};
+
+export const useGetAlertRuleDetails = (): Props => {
+ const { ruleId } = useAlertHistoryQueryParams();
+
+ const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
+
+ const {
+ isLoading,
+ data: alertDetailsResponse,
+ isRefetching,
+ isError,
+ } = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], {
+ queryFn: () =>
+ get({
+ id: parseInt(ruleId || '', 10),
+ }),
+ enabled: isValidRuleId,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ return {
+ ruleId,
+ isLoading,
+ alertDetailsResponse,
+ isRefetching,
+ isError,
+ isValidRuleId,
+ };
+};
+
+type GetAlertRuleDetailsApiProps = {
+ isLoading: boolean;
+ isRefetching: boolean;
+ isError: boolean;
+ isValidRuleId: boolean;
+ ruleId: string | null;
+};
+
+type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
+ data:
+ | SuccessResponse
+ | ErrorResponse
+ | undefined;
+};
+
+export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => {
+ const { ruleId, startTime, endTime } = useAlertHistoryQueryParams();
+
+ const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
+
+ const { isLoading, isRefetching, isError, data } = useQuery(
+ [REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime],
+ {
+ queryFn: () =>
+ ruleStats({
+ id: parseInt(ruleId || '', 10),
+ start: startTime,
+ end: endTime,
+ }),
+ enabled: isValidRuleId && !!startTime && !!endTime,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ },
+ );
+
+ return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
+};
+
+type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & {
+ data:
+ | SuccessResponse
+ | ErrorResponse
+ | undefined;
+};
+
+export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopContributorsProps => {
+ const { ruleId, startTime, endTime } = useAlertHistoryQueryParams();
+
+ const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
+
+ const { isLoading, isRefetching, isError, data } = useQuery(
+ [REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime],
+ {
+ queryFn: () =>
+ topContributors({
+ id: parseInt(ruleId || '', 10),
+ start: startTime,
+ end: endTime,
+ }),
+ enabled: isValidRuleId,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ },
+ );
+
+ return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
+};
+
+type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & {
+ data:
+ | SuccessResponse
+ | ErrorResponse
+ | undefined;
+};
+
+export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => {
+ const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams();
+ const { updatedOrder, offset } = useMemo(
+ () => ({
+ updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC,
+ offset: parseInt(params.get(urlKey.offset) ?? '1', 10),
+ }),
+ [params],
+ );
+
+ const timelineFilter = params.get('timelineFilter');
+
+ const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
+ const hasStartAndEnd = startTime !== null && endTime !== null;
+
+ const { isLoading, isRefetching, isError, data } = useQuery(
+ [
+ REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE,
+ ruleId,
+ startTime,
+ endTime,
+ timelineFilter,
+ updatedOrder,
+ offset,
+ ],
+ {
+ queryFn: () =>
+ timelineTable({
+ id: parseInt(ruleId || '', 10),
+ start: startTime,
+ end: endTime,
+ limit: TIMELINE_TABLE_PAGE_SIZE,
+ order: updatedOrder,
+ offset,
+
+ ...(timelineFilter && timelineFilter !== TimelineFilter.ALL
+ ? {
+ state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal',
+ }
+ : {}),
+ }),
+ enabled: isValidRuleId && hasStartAndEnd,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ },
+ );
+
+ return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
+};
+
+export const useTimelineTable = ({
+ totalItems,
+}: {
+ totalItems: number;
+}): {
+ paginationConfig: TablePaginationConfig;
+ onChangeHandler: (
+ pagination: TablePaginationConfig,
+ sorter: any,
+ filters: any,
+ extra: any,
+ ) => void;
+} => {
+ const { pathname } = useLocation();
+
+ const { search } = useLocation();
+
+ const params = useMemo(() => new URLSearchParams(search), [search]);
+
+ const offset = params.get('offset') ?? '0';
+
+ const onChangeHandler: TableProps['onChange'] = useCallback(
+ (
+ pagination: TablePaginationConfig,
+ filters: Record,
+ sorter:
+ | SorterResult[]
+ | SorterResult,
+ ) => {
+ if (!Array.isArray(sorter)) {
+ const { pageSize = 0, current = 0 } = pagination;
+ const { order } = sorter;
+ const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
+ const params = new URLSearchParams(window.location.search);
+
+ history.replace(
+ `${pathname}?${createQueryParams({
+ ...Object.fromEntries(params),
+ order: updatedOrder,
+ offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE,
+ pageSize,
+ })}`,
+ );
+ }
+ },
+ [pathname],
+ );
+
+ const offsetInt = parseInt(offset, 10);
+ const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE);
+ const pageSizeInt = parseInt(pageSize, 10);
+
+ const paginationConfig: TablePaginationConfig = {
+ pageSize: pageSizeInt,
+ showTotal: PaginationInfoText,
+ current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1,
+ showSizeChanger: false,
+ hideOnSinglePage: true,
+ total: totalItems,
+ };
+
+ return { paginationConfig, onChangeHandler };
+};
+
+export const useAlertRuleStatusToggle = ({
+ ruleId,
+}: {
+ ruleId: string;
+}): {
+ handleAlertStateToggle: (state: boolean) => void;
+} => {
+ const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule();
+ const { notifications } = useNotifications();
+
+ const queryClient = useQueryClient();
+ const handleError = useAxiosError();
+
+ const { mutate: toggleAlertState } = useMutation(
+ [REACT_QUERY_KEY.TOGGLE_ALERT_STATE, ruleId],
+ patchAlert,
+ {
+ onMutate: () => {
+ setIsAlertRuleDisabled((prev) => !prev);
+ },
+ onSuccess: () => {
+ notifications.success({
+ message: `Alert has been ${isAlertRuleDisabled ? 'enabled' : 'disabled'}.`,
+ });
+ },
+ onError: (error) => {
+ queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS]);
+ handleError(error);
+ },
+ },
+ );
+
+ const handleAlertStateToggle = (): void => {
+ const args = {
+ id: parseInt(ruleId, 10),
+ data: { disabled: !isAlertRuleDisabled },
+ };
+ toggleAlertState(args);
+ };
+
+ return { handleAlertStateToggle };
+};
+
+export const useAlertRuleDuplicate = ({
+ alertDetails,
+}: {
+ alertDetails: AlertDef;
+}): {
+ handleAlertDuplicate: () => void;
+} => {
+ const { notifications } = useNotifications();
+
+ const params = useUrlQuery();
+
+ const { refetch } = useQuery(REACT_QUERY_KEY.GET_ALL_ALLERTS, {
+ queryFn: getAll,
+ cacheTime: 0,
+ });
+ const handleError = useAxiosError();
+ const { mutate: duplicateAlert } = useMutation(
+ [REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
+ save,
+ {
+ onSuccess: async () => {
+ notifications.success({
+ message: `Success`,
+ });
+
+ const { data: allAlertsData } = await refetch();
+
+ if (
+ allAlertsData &&
+ allAlertsData.payload &&
+ allAlertsData.payload.length > 0
+ ) {
+ const clonedAlert =
+ allAlertsData.payload[allAlertsData.payload.length - 1];
+ params.set(QueryParams.ruleId, String(clonedAlert.id));
+ history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
+ }
+ },
+ onError: handleError,
+ },
+ );
+
+ const handleAlertDuplicate = (): void => {
+ const args = {
+ data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') },
+ };
+ duplicateAlert(args);
+ };
+
+ return { handleAlertDuplicate };
+};
+
+export const useAlertRuleDelete = ({
+ ruleId,
+}: {
+ ruleId: number;
+}): {
+ handleAlertDelete: () => void;
+} => {
+ const { notifications } = useNotifications();
+ const handleError = useAxiosError();
+
+ const { mutate: deleteAlert } = useMutation(
+ [REACT_QUERY_KEY.REMOVE_ALERT_RULE, ruleId],
+ deleteAlerts,
+ {
+ onSuccess: async () => {
+ notifications.success({
+ message: `Success`,
+ });
+
+ history.push(ROUTES.LIST_ALL_ALERT);
+ },
+ onError: handleError,
+ },
+ );
+
+ const handleAlertDelete = (): void => {
+ const args = { id: ruleId };
+ deleteAlert(args);
+ };
+
+ return { handleAlertDelete };
+};
+
+type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & {
+ data:
+ | SuccessResponse
+ | ErrorResponse
+ | undefined;
+};
+
+export const useGetAlertRuleDetailsTimelineGraphData = (): GetAlertRuleDetailsTimelineGraphProps => {
+ const { ruleId, startTime, endTime } = useAlertHistoryQueryParams();
+
+ const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
+ const hasStartAndEnd = startTime !== null && endTime !== null;
+
+ const { isLoading, isRefetching, isError, data } = useQuery(
+ [REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime],
+ {
+ queryFn: () =>
+ timelineGraph({
+ id: parseInt(ruleId || '', 10),
+ start: startTime,
+ end: endTime,
+ }),
+ enabled: isValidRuleId && hasStartAndEnd,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ },
+ );
+
+ return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
+};
diff --git a/frontend/src/pages/AlertDetails/index.tsx b/frontend/src/pages/AlertDetails/index.tsx
new file mode 100644
index 0000000000..aa6eb0b819
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/index.tsx
@@ -0,0 +1,3 @@
+import AlertDetails from './AlertDetails';
+
+export default AlertDetails;
diff --git a/frontend/src/pages/AlertDetails/types.ts b/frontend/src/pages/AlertDetails/types.ts
new file mode 100644
index 0000000000..f68fa9c512
--- /dev/null
+++ b/frontend/src/pages/AlertDetails/types.ts
@@ -0,0 +1,6 @@
+export type AlertDetailsStatusRendererProps = {
+ isLoading: boolean;
+ isError: boolean;
+ isRefetching: boolean;
+ data: any;
+};
diff --git a/frontend/src/pages/AlertHistory/index.tsx b/frontend/src/pages/AlertHistory/index.tsx
new file mode 100644
index 0000000000..7a7b0d01d8
--- /dev/null
+++ b/frontend/src/pages/AlertHistory/index.tsx
@@ -0,0 +1,3 @@
+import AlertHistory from 'container/AlertHistory';
+
+export default AlertHistory;
diff --git a/frontend/src/pages/AlertList/index.tsx b/frontend/src/pages/AlertList/index.tsx
index 1bf3d9a6ea..19d746e8f0 100644
--- a/frontend/src/pages/AlertList/index.tsx
+++ b/frontend/src/pages/AlertList/index.tsx
@@ -1,10 +1,14 @@
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
+import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
+import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import TriggeredAlerts from 'container/TriggeredAlerts';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
+import { GalleryVerticalEnd, Pyramid } from 'lucide-react';
+import AlertDetails from 'pages/AlertDetails';
import { useLocation } from 'react-router-dom';
function AllAlertList(): JSX.Element {
@@ -12,15 +16,40 @@ function AllAlertList(): JSX.Element {
const location = useLocation();
const tab = urlQuery.get('tab');
+ const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
+ const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
+
+ const search = urlQuery.get('search');
+
const items: TabsProps['items'] = [
- { label: 'Alert Rules', key: 'AlertRules', children: },
{
- label: 'Triggered Alerts',
+ label: (
+
+
+ Triggered Alerts
+
+ ),
key: 'TriggeredAlerts',
children: ,
},
{
- label: 'Configuration',
+ label: (
+
+ ),
+ key: 'AlertRules',
+ children:
+ isAlertHistory || isAlertOverview ? : ,
+ },
+ {
+ label: (
+
+
+ Configuration
+
+ ),
key: 'Configuration',
children: ,
},
@@ -33,8 +62,16 @@ function AllAlertList(): JSX.Element {
activeKey={tab || 'AlertRules'}
onChange={(tab): void => {
urlQuery.set('tab', tab);
- history.replace(`${location.pathname}?${urlQuery.toString()}`);
+ let params = `tab=${tab}`;
+
+ if (search) {
+ params += `&search=${search}`;
+ }
+ history.replace(`/alerts?${params}`);
}}
+ className={`${
+ isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
+ }`}
/>
);
}
diff --git a/frontend/src/pages/EditRules/EditRules.styles.scss b/frontend/src/pages/EditRules/EditRules.styles.scss
index 412cddd1ad..a01a6e7ab7 100644
--- a/frontend/src/pages/EditRules/EditRules.styles.scss
+++ b/frontend/src/pages/EditRules/EditRules.styles.scss
@@ -1,32 +1,33 @@
.edit-rules-container {
- display: flex;
- justify-content: center;
- align-items: center;
- margin-top: 5rem;
+ padding: 0 16px;
+ &--error {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 5rem;
+ }
}
-
.edit-rules-card {
- width: 20rem;
- padding: 1rem;
+ width: 20rem;
+ padding: 1rem;
}
.content {
- font-style: normal;
+ font-style: normal;
font-weight: 300;
font-size: 18px;
line-height: 20px;
display: flex;
align-items: center;
- justify-content: center;
- text-align: center;
+ justify-content: center;
+ text-align: center;
margin: 0;
}
.btn-container {
- display: flex;
- justify-content: center;
- align-items: center;
- margin-top: 2rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 2rem;
}
-
diff --git a/frontend/src/pages/EditRules/index.tsx b/frontend/src/pages/EditRules/index.tsx
index cccfc6aee2..372a8a199e 100644
--- a/frontend/src/pages/EditRules/index.tsx
+++ b/frontend/src/pages/EditRules/index.tsx
@@ -4,6 +4,7 @@ import { Button, Card } from 'antd';
import get from 'api/alerts/get';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
+import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
@@ -21,19 +22,21 @@ import {
function EditRules(): JSX.Element {
const params = useUrlQuery();
- const ruleId = params.get('ruleId');
+ const ruleId = params.get(QueryParams.ruleId);
const { t } = useTranslation('common');
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, data, isRefetching, isError } = useQuery(
- ['ruleId', ruleId],
+ [REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId],
{
queryFn: () =>
get({
id: parseInt(ruleId || '', 10),
}),
enabled: isValidRuleId,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
},
);
@@ -62,7 +65,7 @@ function EditRules(): JSX.Element {
(data?.payload?.data === undefined && !isLoading)
) {
return (
-
+
{data?.message === errorMessageReceivedFromBackend
@@ -84,10 +87,12 @@ function EditRules(): JSX.Element {
}
return (
-
+
+
+
);
}
diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss
new file mode 100644
index 0000000000..7a55632ae6
--- /dev/null
+++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss
@@ -0,0 +1,39 @@
+.copy-to-clipboard {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 14px;
+ padding: 4px 6px;
+ width: 100px;
+
+ &:hover {
+ background-color: transparent !important;
+ }
+
+ .ant-btn-icon {
+ margin: 0 !important;
+ }
+ & > * {
+ color: var(--text-vanilla-400);
+ font-weight: 400;
+ line-height: 20px;
+ letter-spacing: -0.07px;
+ }
+
+ &--success {
+ & span,
+ &:hover {
+ color: var(--bg-forest-400);
+ }
+ }
+}
+
+.lightMode {
+ .copy-to-clipboard {
+ &:not(&--success) {
+ & > * {
+ color: var(--text-ink-400);
+ }
+ }
+ }
+}
diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx
new file mode 100644
index 0000000000..598f6e5a3f
--- /dev/null
+++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx
@@ -0,0 +1,54 @@
+import './CopyToClipboard.styles.scss';
+
+import { Color } from '@signozhq/design-tokens';
+import { Button } from 'antd';
+import { useIsDarkMode } from 'hooks/useDarkMode';
+import { CircleCheck, Link2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useCopyToClipboard } from 'react-use';
+
+function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element {
+ const [state, copyToClipboard] = useCopyToClipboard();
+ const [success, setSuccess] = useState(false);
+ const isDarkMode = useIsDarkMode();
+
+ useEffect(() => {
+ let timer: string | number | NodeJS.Timeout | undefined;
+ if (state.value) {
+ setSuccess(true);
+ timer = setTimeout(() => setSuccess(false), 1000);
+ }
+
+ return (): void => clearTimeout(timer);
+ }, [state]);
+
+ if (success) {
+ return (
+ }
+ className="copy-to-clipboard copy-to-clipboard--success"
+ >
+ Copied
+
+ );
+ }
+
+ return (
+
+ }
+ onClick={(): void => copyToClipboard(textToCopy)}
+ className="copy-to-clipboard"
+ >
+ Copy link
+
+ );
+}
+
+export default CopyToClipboard;
diff --git a/frontend/src/periscope/components/CopyToClipboard/index.tsx b/frontend/src/periscope/components/CopyToClipboard/index.tsx
new file mode 100644
index 0000000000..7b6b62c1b5
--- /dev/null
+++ b/frontend/src/periscope/components/CopyToClipboard/index.tsx
@@ -0,0 +1,3 @@
+import CopyToClipboard from './CopyToClipboard';
+
+export default CopyToClipboard;
diff --git a/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx
new file mode 100644
index 0000000000..7d6c6eb5a1
--- /dev/null
+++ b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx
@@ -0,0 +1,46 @@
+import Spinner from 'components/Spinner';
+import { useTranslation } from 'react-i18next';
+
+interface DataStateRendererProps {
+ isLoading: boolean;
+ isRefetching: boolean;
+ isError: boolean;
+ data: T | null;
+ errorMessage?: string;
+ loadingMessage?: string;
+ children: (data: T) => React.ReactNode;
+}
+
+/**
+ * TODO(shaheer): add empty state and optionally accept empty state custom component
+ * TODO(shaheer): optionally accept custom error state component
+ * TODO(shaheer): optionally accept custom loading state component
+ */
+function DataStateRenderer({
+ isLoading,
+ isRefetching,
+ isError,
+ data,
+ errorMessage,
+ loadingMessage,
+ children,
+}: DataStateRendererProps): JSX.Element {
+ const { t } = useTranslation('common');
+
+ if (isLoading || isRefetching || !data) {
+ return ;
+ }
+
+ if (isError || data === null) {
+ return {errorMessage ?? t('something_went_wrong')}
;
+ }
+
+ return <>{children(data)}>;
+}
+
+DataStateRenderer.defaultProps = {
+ errorMessage: '',
+ loadingMessage: 'Loading...',
+};
+
+export default DataStateRenderer;
diff --git a/frontend/src/periscope/components/DataStateRenderer/index.tsx b/frontend/src/periscope/components/DataStateRenderer/index.tsx
new file mode 100644
index 0000000000..e4afdfa3bd
--- /dev/null
+++ b/frontend/src/periscope/components/DataStateRenderer/index.tsx
@@ -0,0 +1,3 @@
+import DataStateRenderer from './DataStateRenderer';
+
+export default DataStateRenderer;
diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss
new file mode 100644
index 0000000000..88ae57f4e8
--- /dev/null
+++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss
@@ -0,0 +1,37 @@
+.key-value-label {
+ display: flex;
+ align-items: center;
+ border: 1px solid var(--bg-slate-400);
+ border-radius: 2px;
+ flex-wrap: wrap;
+
+ &__key,
+ &__value {
+ padding: 1px 6px;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 18px;
+ letter-spacing: -0.005em;
+ }
+ &__key {
+ background: var(--bg-ink-400);
+ border-radius: 2px 0 0 2px;
+ }
+ &__value {
+ background: var(--bg-slate-400);
+ }
+ color: var(--text-vanilla-400);
+}
+
+.lightMode {
+ .key-value-label {
+ border-color: var(--bg-vanilla-400);
+ color: var(--text-ink-400);
+ &__key {
+ background: var(--bg-vanilla-300);
+ }
+ &__value {
+ background: var(--bg-vanilla-200);
+ }
+ }
+}
diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx
new file mode 100644
index 0000000000..aa14dd6380
--- /dev/null
+++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx
@@ -0,0 +1,18 @@
+import './KeyValueLabel.styles.scss';
+
+type KeyValueLabelProps = { badgeKey: string; badgeValue: string };
+
+export default function KeyValueLabel({
+ badgeKey,
+ badgeValue,
+}: KeyValueLabelProps): JSX.Element | null {
+ if (!badgeKey || !badgeValue) {
+ return null;
+ }
+ return (
+
+
{badgeKey}
+
{badgeValue}
+
+ );
+}
diff --git a/frontend/src/periscope/components/KeyValueLabel/index.tsx b/frontend/src/periscope/components/KeyValueLabel/index.tsx
new file mode 100644
index 0000000000..7341e057e8
--- /dev/null
+++ b/frontend/src/periscope/components/KeyValueLabel/index.tsx
@@ -0,0 +1,3 @@
+import KeyValueLabel from './KeyValueLabel';
+
+export default KeyValueLabel;
diff --git a/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx
new file mode 100644
index 0000000000..205e1d3db8
--- /dev/null
+++ b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx
@@ -0,0 +1,24 @@
+import { Typography } from 'antd';
+
+function PaginationInfoText(
+ total: number,
+ [start, end]: number[],
+): JSX.Element {
+ return (
+
+
+ {start} — {end}
+
+ of {total}
+
+ );
+}
+
+export default PaginationInfoText;
diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss
new file mode 100644
index 0000000000..002b04294b
--- /dev/null
+++ b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss
@@ -0,0 +1,26 @@
+.see-more-button {
+ background: none;
+ padding: 2px;
+ font-size: 14px;
+ line-height: 18px;
+ letter-spacing: -0.005em;
+ color: var(--text-vanilla-400);
+ border: none;
+ cursor: pointer;
+}
+
+.see-more-popover-content {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ width: 300px;
+}
+
+.lightMode {
+ .see-more-button {
+ color: var(--text-ink-400);
+ }
+ .see-more-popover-content {
+ background: var(--bg-vanilla-100);
+ }
+}
diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.tsx b/frontend/src/periscope/components/SeeMore/SeeMore.tsx
new file mode 100644
index 0000000000..f94da8a564
--- /dev/null
+++ b/frontend/src/periscope/components/SeeMore/SeeMore.tsx
@@ -0,0 +1,48 @@
+import './SeeMore.styles.scss';
+
+import { Color } from '@signozhq/design-tokens';
+import { Popover } from 'antd';
+import { useIsDarkMode } from 'hooks/useDarkMode';
+
+type SeeMoreProps = {
+ children: JSX.Element[];
+ initialCount?: number;
+ moreLabel: string;
+};
+
+function SeeMore({
+ children,
+ initialCount = 2,
+ moreLabel,
+}: SeeMoreProps): JSX.Element {
+ const remainingCount = children.length - initialCount;
+ const isDarkMode = useIsDarkMode();
+
+ return (
+ <>
+ {children.slice(0, initialCount)}
+ {remainingCount > 0 && (
+
+ {children.slice(initialCount)}
+
+ }
+ >
+
+
+ )}
+ >
+ );
+}
+
+SeeMore.defaultProps = {
+ initialCount: 2,
+};
+
+export default SeeMore;
diff --git a/frontend/src/periscope/components/SeeMore/index.tsx b/frontend/src/periscope/components/SeeMore/index.tsx
new file mode 100644
index 0000000000..9ee14a54c9
--- /dev/null
+++ b/frontend/src/periscope/components/SeeMore/index.tsx
@@ -0,0 +1,3 @@
+import SeeMore from './SeeMore';
+
+export default SeeMore;
diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss
new file mode 100644
index 0000000000..59b5156cdd
--- /dev/null
+++ b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss
@@ -0,0 +1,48 @@
+.tabs-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+
+ .tab {
+ &.ant-btn-default {
+ box-shadow: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-vanilla-400);
+ background: var(--bg-ink-400);
+ font-size: 14px;
+ line-height: 20px;
+ letter-spacing: -0.07px;
+ padding: 6px 24px;
+ border-color: var(--bg-slate-400);
+ justify-content: center;
+ }
+ &.reset-button {
+ .ant-btn-icon {
+ margin: 0;
+ }
+ padding: 6px 12px;
+ }
+ &.selected {
+ color: var(--text-vanilla-100);
+ background: var(--bg-slate-400);
+ }
+ }
+}
+
+.lightMode {
+ .tabs-wrapper {
+ .tab {
+ &.ant-btn-default {
+ color: var(--text-ink-400);
+ background: var(--bg-vanilla-300);
+ border-color: var(--bg-vanilla-300);
+ }
+ &.selected {
+ color: var(--text-robin-500);
+ background: var(--bg-vanilla-100);
+ }
+ }
+ }
+}
diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.tsx b/frontend/src/periscope/components/Tabs2/Tabs2.tsx
new file mode 100644
index 0000000000..051d80365e
--- /dev/null
+++ b/frontend/src/periscope/components/Tabs2/Tabs2.tsx
@@ -0,0 +1,80 @@
+import './Tabs2.styles.scss';
+
+import { Color } from '@signozhq/design-tokens';
+import { Button } from 'antd';
+import { TimelineFilter } from 'container/AlertHistory/types';
+import { Undo } from 'lucide-react';
+import { useState } from 'react';
+
+interface Tab {
+ value: string;
+ label: string | JSX.Element;
+ disabled?: boolean;
+ icon?: string | JSX.Element;
+}
+
+interface TimelineTabsProps {
+ tabs: Tab[];
+ onSelectTab?: (selectedTab: TimelineFilter) => void;
+ initialSelectedTab?: string;
+ hasResetButton?: boolean;
+ buttonMinWidth?: string;
+}
+
+function Tabs2({
+ tabs,
+ onSelectTab,
+ initialSelectedTab,
+ hasResetButton,
+ buttonMinWidth = '114px',
+}: TimelineTabsProps): JSX.Element {
+ const [selectedTab, setSelectedTab] = useState
(
+ initialSelectedTab || tabs[0].value,
+ );
+
+ const handleTabClick = (tabValue: string): void => {
+ setSelectedTab(tabValue);
+ if (onSelectTab) {
+ onSelectTab(tabValue as TimelineFilter);
+ }
+ };
+
+ return (
+
+ {hasResetButton && selectedTab !== tabs[0].value && (
+
+ )}
+
+ {tabs.map((tab) => (
+
+ ))}
+
+
+ );
+}
+
+Tabs2.defaultProps = {
+ initialSelectedTab: '',
+ onSelectTab: (): void => {},
+ hasResetButton: false,
+ buttonMinWidth: '114px',
+};
+
+export default Tabs2;
diff --git a/frontend/src/periscope/components/Tabs2/index.tsx b/frontend/src/periscope/components/Tabs2/index.tsx
new file mode 100644
index 0000000000..0338314a3a
--- /dev/null
+++ b/frontend/src/periscope/components/Tabs2/index.tsx
@@ -0,0 +1,3 @@
+import Tabs2 from './Tabs2';
+
+export default Tabs2;
diff --git a/frontend/src/providers/Alert.tsx b/frontend/src/providers/Alert.tsx
new file mode 100644
index 0000000000..337eec9ba5
--- /dev/null
+++ b/frontend/src/providers/Alert.tsx
@@ -0,0 +1,43 @@
+import React, { createContext, useContext, useState } from 'react';
+
+interface AlertRuleContextType {
+ isAlertRuleDisabled: boolean | undefined;
+ setIsAlertRuleDisabled: React.Dispatch<
+ React.SetStateAction
+ >;
+}
+
+const AlertRuleContext = createContext(
+ undefined,
+);
+
+function AlertRuleProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}): JSX.Element {
+ const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState<
+ boolean | undefined
+ >(undefined);
+
+ const value = React.useMemo(
+ () => ({ isAlertRuleDisabled, setIsAlertRuleDisabled }),
+ [isAlertRuleDisabled],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useAlertRule = (): AlertRuleContextType => {
+ const context = useContext(AlertRuleContext);
+ if (context === undefined) {
+ throw new Error('useAlertRule must be used within an AlertRuleProvider');
+ }
+ return context;
+};
+
+export default AlertRuleProvider;
diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts
index c773cb78a2..9393ccd5a0 100644
--- a/frontend/src/types/api/alerts/def.ts
+++ b/frontend/src/types/api/alerts/def.ts
@@ -38,7 +38,71 @@ export interface RuleCondition {
alertOnAbsent?: boolean | undefined;
absentFor?: number | undefined;
}
-
export interface Labels {
[key: string]: string;
}
+
+export interface AlertRuleStats {
+ totalCurrentTriggers: number;
+ totalPastTriggers: number;
+ currentTriggersSeries: CurrentTriggersSeries;
+ pastTriggersSeries: CurrentTriggersSeries | null;
+ currentAvgResolutionTime: number;
+ pastAvgResolutionTime: number;
+ currentAvgResolutionTimeSeries: CurrentTriggersSeries;
+ pastAvgResolutionTimeSeries: any | null;
+}
+
+interface CurrentTriggersSeries {
+ labels: Labels;
+ labelsArray: any | null;
+ values: StatsTimeSeriesItem[];
+}
+
+export interface StatsTimeSeriesItem {
+ timestamp: number;
+ value: string;
+}
+
+export type AlertRuleStatsPayload = {
+ data: AlertRuleStats;
+};
+
+export interface AlertRuleTopContributors {
+ fingerprint: number;
+ labels: Labels;
+ count: number;
+ relatedLogsLink: string;
+ relatedTracesLink: string;
+}
+export type AlertRuleTopContributorsPayload = {
+ data: AlertRuleTopContributors[];
+};
+
+export interface AlertRuleTimelineTableResponse {
+ ruleID: string;
+ ruleName: string;
+ overallState: string;
+ overallStateChanged: boolean;
+ state: string;
+ stateChanged: boolean;
+ unixMilli: number;
+ labels: Labels;
+ fingerprint: number;
+ value: number;
+ relatedTracesLink: string;
+ relatedLogsLink: string;
+}
+export type AlertRuleTimelineTableResponsePayload = {
+ data: { items: AlertRuleTimelineTableResponse[]; total: number };
+};
+type AlertState = 'firing' | 'normal' | 'no-data' | 'muted';
+
+export interface AlertRuleTimelineGraphResponse {
+ start: number;
+ end: number;
+ state: AlertState;
+}
+export type AlertRuleTimelineGraphResponsePayload = {
+ data: AlertRuleTimelineGraphResponse[];
+};
diff --git a/frontend/src/types/api/alerts/ruleStats.ts b/frontend/src/types/api/alerts/ruleStats.ts
new file mode 100644
index 0000000000..2669a4c6be
--- /dev/null
+++ b/frontend/src/types/api/alerts/ruleStats.ts
@@ -0,0 +1,7 @@
+import { AlertDef } from './def';
+
+export interface RuleStatsProps {
+ id: AlertDef['id'];
+ start: number;
+ end: number;
+}
diff --git a/frontend/src/types/api/alerts/timelineGraph.ts b/frontend/src/types/api/alerts/timelineGraph.ts
new file mode 100644
index 0000000000..99e9601f1e
--- /dev/null
+++ b/frontend/src/types/api/alerts/timelineGraph.ts
@@ -0,0 +1,7 @@
+import { AlertDef } from './def';
+
+export interface GetTimelineGraphRequestProps {
+ id: AlertDef['id'];
+ start: number;
+ end: number;
+}
diff --git a/frontend/src/types/api/alerts/timelineTable.ts b/frontend/src/types/api/alerts/timelineTable.ts
new file mode 100644
index 0000000000..b2e27a4d1c
--- /dev/null
+++ b/frontend/src/types/api/alerts/timelineTable.ts
@@ -0,0 +1,13 @@
+import { TagFilter } from '../queryBuilder/queryBuilderData';
+import { AlertDef } from './def';
+
+export interface GetTimelineTableRequestProps {
+ id: AlertDef['id'];
+ start: number;
+ end: number;
+ offset: number;
+ limit: number;
+ order: string;
+ filters?: TagFilter;
+ state?: string;
+}
diff --git a/frontend/src/types/api/alerts/topContributors.ts b/frontend/src/types/api/alerts/topContributors.ts
new file mode 100644
index 0000000000..74acb4b871
--- /dev/null
+++ b/frontend/src/types/api/alerts/topContributors.ts
@@ -0,0 +1,7 @@
+import { AlertDef } from './def';
+
+export interface TopContributorsProps {
+ id: AlertDef['id'];
+ start: number;
+ end: number;
+}
diff --git a/frontend/src/utils/calculateChange.ts b/frontend/src/utils/calculateChange.ts
new file mode 100644
index 0000000000..4e3d912f0d
--- /dev/null
+++ b/frontend/src/utils/calculateChange.ts
@@ -0,0 +1,31 @@
+export function calculateChange(
+ totalCurrentTriggers: number | undefined,
+ totalPastTriggers: number | undefined,
+): { changePercentage: number; changeDirection: number } {
+ if (
+ totalCurrentTriggers === undefined ||
+ totalPastTriggers === undefined ||
+ [0, '0'].includes(totalPastTriggers)
+ ) {
+ return { changePercentage: 0, changeDirection: 0 };
+ }
+
+ let changePercentage =
+ ((totalCurrentTriggers - totalPastTriggers) / totalPastTriggers) * 100;
+
+ let changeDirection = 0;
+
+ if (changePercentage < 0) {
+ changeDirection = -1;
+ } else if (changePercentage > 0) {
+ changeDirection = 1;
+ }
+
+ changePercentage = Math.abs(changePercentage);
+ changePercentage = Math.round(changePercentage);
+
+ return {
+ changePercentage,
+ changeDirection,
+ };
+}
diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts
index 1845e77941..8a35121f57 100644
--- a/frontend/src/utils/permission/index.ts
+++ b/frontend/src/utils/permission/index.ts
@@ -64,6 +64,8 @@ export const routePermission: Record = {
ERROR_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
HOME_PAGE: ['ADMIN', 'EDITOR', 'VIEWER'],
LIST_ALL_ALERT: ['ADMIN', 'EDITOR', 'VIEWER'],
+ ALERT_HISTORY: ['ADMIN', 'EDITOR', 'VIEWER'],
+ ALERT_OVERVIEW: ['ADMIN'],
LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'],
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'],
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts
index 277c0c04af..5eb795bf45 100644
--- a/frontend/src/utils/timeUtils.ts
+++ b/frontend/src/utils/timeUtils.ts
@@ -1,8 +1,11 @@
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
+import duration from 'dayjs/plugin/duration';
dayjs.extend(customParseFormat);
+dayjs.extend(duration);
+
export function toUTCEpoch(time: number): number {
const x = new Date();
return time + x.getTimezoneOffset() * 60 * 1000;
@@ -28,3 +31,97 @@ export const getRemainingDays = (billingEndDate: number): number => {
return Math.ceil(timeDifference / (1000 * 60 * 60 * 24));
};
+
+/**
+ * Calculates the duration from the given epoch timestamp to the current time.
+ *
+ *
+ * @param {number} epochTimestamp
+ * @returns {string} - human readable string representing the duration from the given epoch timestamp to the current time e.g. "3d 14h"
+ */
+export const getDurationFromNow = (epochTimestamp: number): string => {
+ const now = dayjs();
+ const inputTime = dayjs(epochTimestamp);
+ const duration = dayjs.duration(now.diff(inputTime));
+
+ const days = duration.days();
+ const hours = duration.hours();
+ const minutes = duration.minutes();
+ const seconds = duration.seconds();
+
+ let result = '';
+ if (days > 0) result += `${days}d `;
+ if (hours > 0) result += `${hours}h `;
+ if (minutes > 0) result += `${minutes}m `;
+ if (seconds > 0) result += `${seconds}s`;
+
+ return result.trim();
+};
+
+/**
+ * Formats an epoch timestamp into a human-readable date and time string.
+ *
+ * @param {number} epoch - The epoch timestamp to format.
+ * @returns {string} - The formatted date and time string in the format "MMM D, YYYY ⎯ HH:MM:SS".
+ */
+export function formatEpochTimestamp(epoch: number): string {
+ const date = new Date(epoch);
+
+ const optionsDate: Intl.DateTimeFormatOptions = {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ };
+
+ const optionsTime: Intl.DateTimeFormatOptions = {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ };
+
+ const formattedDate = date.toLocaleDateString('en-US', optionsDate);
+ const formattedTime = date.toLocaleTimeString('en-US', optionsTime);
+
+ return `${formattedDate} ⎯ ${formattedTime}`;
+}
+
+/**
+ * Converts a given number of seconds into a human-readable format.
+ * @param {number} seconds The number of seconds to convert.
+ * @returns {string} The formatted time string, either in days (e.g., "1.2d"), hours (e.g., "1.2h"), minutes (e.g., "~7m"), or seconds (e.g., "~45s").
+ */
+
+export function formatTime(seconds: number): string {
+ const days = seconds / 86400;
+
+ if (days >= 1) {
+ return `${days.toFixed(1)}d`;
+ }
+
+ const hours = seconds / 3600;
+ if (hours >= 1) {
+ return `${hours.toFixed(1)}h`;
+ }
+
+ const minutes = seconds / 60;
+ if (minutes >= 1) {
+ return `${minutes.toFixed(1)}m`;
+ }
+
+ return `${seconds.toFixed(1)}s`;
+}
+
+export const nanoToMilli = (nanoseconds: number): number =>
+ nanoseconds / 1_000_000;
+
+export const epochToTimeString = (epochMs: number): string => {
+ console.log({ epochMs });
+ const date = new Date(epochMs);
+ const options: Intl.DateTimeFormatOptions = {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ };
+ return date.toLocaleTimeString('en-US', options);
+};