Compare commits

..

7 Commits

Author SHA1 Message Date
aks07
2f06afaf27 feat: handle click and hover with tooltip 2026-03-05 23:25:10 +05:30
aks07
f77c3cb23c feat: update span colors 2026-03-05 22:40:06 +05:30
aks07
9e3a8efcfc feat: zoom and drag added 2026-03-05 18:22:55 +05:30
aks07
8e325ba8b3 feat: added timeline v3 2026-03-05 12:31:17 +05:30
aks07
884f516766 feat: add text to spans 2026-03-03 12:20:06 +05:30
aks07
4bcbb4ffc3 feat: flamegraph canvas init 2026-03-02 19:21:23 +05:30
Srikanth Chekuri
95fc905448 chore: move savedview and integration types into their own types package (#10454)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
2026-02-28 12:55:26 +00:00
48 changed files with 2158 additions and 257 deletions

View File

@@ -0,0 +1,4 @@
.timeline-v3-container {
// flex: 1;
overflow: visible;
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { useMeasure } from 'react-use';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
getIntervals,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const isDarkMode = useIsDarkMode();
useEffect(() => {
const spread = endTimestamp - startTimestamp;
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
setIntervals(intervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={timelineHeight}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
<line
x1="0"
y1={timelineHeight}
x2={width}
y2={timelineHeight}
stroke={strokeColor}
strokeWidth="1"
/>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={2 * Math.floor(timelineHeight / 4)}
fill={strokeColor}
>
{interval.label}
</text>
<line
y1={3 * Math.floor(timelineHeight / 4)}
y2={timelineHeight + 0.5}
stroke={strokeColor}
strokeWidth="1"
/>
</g>
))}
</svg>
</div>
);
}
export default TimelineV3;

View File

@@ -0,0 +1,78 @@
import {
getMinimumIntervalsBasedOnWidth,
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
export { getMinimumIntervalsBasedOnWidth };
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (intervalSpread * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
let tempBaseSpread = baseSpread;
let elapsedIntervals = 0;
while (tempBaseSpread && intervals.length < 20) {
let intervalTime: number;
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
intervalTime = elapsedIntervals + tempBaseSpread;
tempBaseSpread = 0;
} else {
intervalTime = elapsedIntervals + intervalSpreadNormalized;
tempBaseSpread -= intervalSpreadNormalized;
}
elapsedIntervals = intervalTime;
const labelTime = offsetTimestamp + intervalTime;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (intervalTime / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -1,37 +1,76 @@
const themeColors = {
traceDetailColors: {
robin: '#3F5ECC',
dodgerBlue: '#2F80ED',
mediumOrchid: '#BB6BD9',
seaBuckthorn: '#F2994A',
turquoiseBlue: '#56CCF2',
festivalOrange: '#F2C94C',
silver: '#BDBDBD',
outrageousOrange: '#FF6633',
roseBud: '#FFB399',
canary: '#FFFF99',
deepSkyBlue: '#00B3E6',
goldTips: '#E6B333',
royalBlue: '#3366E6',
avocado: '#999966',
mintGreen: '#99FF99',
lima: '#80B300',
olive: '#809900',
beautyBush: '#E6B3B3',
danube: '#6680B3',
oliveDrab: '#66991A',
lavenderRose: '#FF99E6',
electricLime: '#CCFF1A',
turquoise: '#33FFCC',
gladeGreen: '#66994D',
hemlock: '#66664D',
vidaLoca: '#4D8000',
mediumAquamarine: '#66CDAA',
lavender: '#E6E6FA',
thistle: '#D8BFD8',
yellow: '#FFFF00',
purple: '#800080',
cyan: '#00FFFF',
c01: '#4C6FFF',
c02: '#FF6B57',
c03: '#2ECC71',
c04: '#F5B72E',
c05: '#9B59B6',
c06: '#00C2FF',
c07: '#FF8A00',
c08: '#1ABC9C',
c09: '#FF4D6D',
c10: '#7C3AED',
c11: '#22C55E',
c12: '#0EA5E9',
c13: '#E11D48',
c14: '#F97316',
c15: '#14B8A6',
c16: '#84CC16',
c17: '#A855F7',
c18: '#3B82F6',
c19: '#F43F5E',
c20: '#10B981',
c21: '#6366F1',
c22: '#F59E0B',
c23: '#06B6D4',
c24: '#D946EF',
c25: '#4ADE80',
c26: '#FB7185',
c27: '#38BDF8',
c28: '#C084FC',
c29: '#34D399',
c30: '#FBBF24',
c31: '#60A5FA',
c32: '#F87171',
c33: '#2DD4BF',
c34: '#A3E635',
c35: '#818CF8',
c36: '#F472B6',
c37: '#22D3EE',
c38: '#BE123C',
c39: '#65A30D',
c40: '#9333EA',
c41: '#0284C7',
c42: '#DC2626',
c43: '#059669',
c44: '#B45309',
c45: '#7DD3FC',
c46: '#A78BFA',
c47: '#4D7C0F',
c48: '#DB2777',
c49: '#0F766E',
c50: '#2563EB',
c51: '#FF3D00',
c52: '#00E676',
c53: '#FFC400',
c54: '#00E5FF',
c55: '#D633FF',
c56: '#9BE22D',
c57: '#FF5A5F',
c58: '#00B3E6',
c59: '#FFB300',
c60: '#3FA600',
c61: '#FF4DD2',
c62: '#1D4ED8',
c63: '#2F80ED',
c64: '#17C3B2',
},
chartcolors: {
// Blues (3)

View File

@@ -451,9 +451,6 @@ function K8sClustersList({
const handleRowClick = (record: K8sClustersRowData): void => {
if (groupBy.length === 0) {
if (!record.clusterNameRaw) {
return;
}
setSelectedRowData(null);
setselectedClusterName(record.clusterUID);
setSearchParams({
@@ -518,13 +515,9 @@ function K8sClustersList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.clusterNameRaw) {
setselectedClusterName(record.clusterUID);
}
setselectedClusterName(record.clusterUID);
},
className: record.clusterNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -714,10 +707,7 @@ function K8sClustersList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.clusterNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -47,7 +47,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sClustersRowData {
key: string;
clusterUID: string;
clusterNameRaw: string;
clusterName: React.ReactNode;
cpu: React.ReactNode;
memory: React.ReactNode;
@@ -176,7 +175,6 @@ export const formatDataForTable = (
data.map((cluster, index) => ({
key: index.toString(),
clusterUID: cluster.meta.k8s_cluster_name,
clusterNameRaw: cluster.meta.k8s_cluster_name || '',
clusterName: (
<Tooltip title={cluster.meta.k8s_cluster_name}>
{cluster.meta.k8s_cluster_name}

View File

@@ -457,9 +457,6 @@ function K8sDaemonSetsList({
const handleRowClick = (record: K8sDaemonSetsRowData): void => {
if (groupBy.length === 0) {
if (!record.daemonsetNameRaw) {
return;
}
setSelectedRowData(null);
setSelectedDaemonSetUID(record.daemonsetUID);
setSearchParams({
@@ -524,13 +521,9 @@ function K8sDaemonSetsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.daemonsetNameRaw) {
setSelectedDaemonSetUID(record.daemonsetUID);
}
setSelectedDaemonSetUID(record.daemonsetUID);
},
className: record.daemonsetNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -722,10 +715,7 @@ function K8sDaemonSetsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.daemonsetNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -82,7 +82,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sDaemonSetsRowData {
key: string;
daemonsetUID: string;
daemonsetNameRaw: string;
daemonsetName: React.ReactNode;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
@@ -277,7 +276,6 @@ export const formatDataForTable = (
data.map((daemonSet, index) => ({
key: index.toString(),
daemonsetUID: daemonSet.daemonSetName,
daemonsetNameRaw: daemonSet.meta.k8s_daemonset_name || '',
daemonsetName: (
<Tooltip title={daemonSet.meta.k8s_daemonset_name}>
{daemonSet.meta.k8s_daemonset_name || ''}

View File

@@ -463,9 +463,6 @@ function K8sDeploymentsList({
const handleRowClick = (record: K8sDeploymentsRowData): void => {
if (groupBy.length === 0) {
if (!record.deploymentNameRaw) {
return;
}
setSelectedRowData(null);
setselectedDeploymentUID(record.deploymentUID);
setSearchParams({
@@ -530,13 +527,9 @@ function K8sDeploymentsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.deploymentNameRaw) {
setselectedDeploymentUID(record.deploymentUID);
}
setselectedDeploymentUID(record.deploymentUID);
},
className: record.deploymentNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -729,10 +722,7 @@ function K8sDeploymentsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.deploymentNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -81,7 +81,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sDeploymentsRowData {
key: string;
deploymentUID: string;
deploymentNameRaw: string;
deploymentName: React.ReactNode;
available_pods: React.ReactNode;
desired_pods: React.ReactNode;
@@ -268,7 +267,6 @@ export const formatDataForTable = (
data.map((deployment, index) => ({
key: index.toString(),
deploymentUID: deployment.meta.k8s_deployment_name,
deploymentNameRaw: deployment.meta.k8s_deployment_name || '',
deploymentName: (
<Tooltip title={deployment.meta.k8s_deployment_name}>
{deployment.meta.k8s_deployment_name}

View File

@@ -337,11 +337,6 @@
cursor: pointer;
}
.disabled-row {
cursor: default;
opacity: 0.6;
}
.k8s-list-table {
.ant-table {
.ant-table-thead > tr > th {

View File

@@ -428,9 +428,6 @@ function K8sJobsList({
const handleRowClick = (record: K8sJobsRowData): void => {
if (groupBy.length === 0) {
if (!record.jobNameRaw) {
return;
}
setSelectedRowData(null);
setselectedJobUID(record.jobUID);
setSearchParams({
@@ -495,11 +492,9 @@ function K8sJobsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.jobNameRaw) {
setselectedJobUID(record.jobUID);
}
setselectedJobUID(record.jobUID);
},
className: record.jobNameRaw ? 'expanded-clickable-row' : 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -689,10 +684,7 @@ function K8sJobsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.jobNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -94,7 +94,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sJobsRowData {
key: string;
jobUID: string;
jobNameRaw: string;
jobName: React.ReactNode;
namespaceName: React.ReactNode;
successful_pods: React.ReactNode;
@@ -304,7 +303,6 @@ export const formatDataForTable = (
data.map((job, index) => ({
key: index.toString(),
jobUID: job.jobName,
jobNameRaw: job.meta.k8s_job_name || '',
jobName: (
<Tooltip title={job.meta.k8s_job_name}>
{job.meta.k8s_job_name || ''}

View File

@@ -459,9 +459,6 @@ function K8sNamespacesList({
const handleRowClick = (record: K8sNamespacesRowData): void => {
if (groupBy.length === 0) {
if (!record.namespaceNameRaw) {
return;
}
setSelectedRowData(null);
setselectedNamespaceUID(record.namespaceUID);
setSearchParams({
@@ -526,13 +523,9 @@ function K8sNamespacesList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.namespaceNameRaw) {
setselectedNamespaceUID(record.namespaceUID);
}
setselectedNamespaceUID(record.namespaceUID);
},
className: record.namespaceNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -723,10 +716,7 @@ function K8sNamespacesList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.namespaceNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -41,7 +41,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sNamespacesRowData {
key: string;
namespaceUID: string;
namespaceNameRaw: string;
namespaceName: string;
clusterName: string;
cpu: React.ReactNode;
@@ -162,7 +161,6 @@ export const formatDataForTable = (
data.map((namespace, index) => ({
key: index.toString(),
namespaceUID: namespace.namespaceName,
namespaceNameRaw: namespace.namespaceName || '',
namespaceName: namespace.namespaceName,
clusterName: namespace.meta.k8s_cluster_name,
cpu: (

View File

@@ -438,9 +438,6 @@ function K8sNodesList({
const handleRowClick = (record: K8sNodesRowData): void => {
if (groupBy.length === 0) {
if (!record.nodeNameRaw) {
return;
}
setSelectedRowData(null);
setSelectedNodeUID(record.nodeUID);
setSearchParams({
@@ -506,13 +503,9 @@ function K8sNodesList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.nodeNameRaw) {
setSelectedNodeUID(record.nodeUID);
}
setSelectedNodeUID(record.nodeUID);
},
className: record.nodeNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -702,10 +695,7 @@ function K8sNodesList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.nodeNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -53,7 +53,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sNodesRowData {
key: string;
nodeUID: string;
nodeNameRaw: string;
nodeName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
@@ -194,7 +193,6 @@ export const formatDataForTable = (
data.map((node, index) => ({
key: `${node.nodeUID}-${index}`,
nodeUID: node.nodeUID || '',
nodeNameRaw: node.meta.k8s_node_name || '',
nodeName: (
<Tooltip title={node.meta.k8s_node_name}>
{node.meta.k8s_node_name || ''}

View File

@@ -496,9 +496,6 @@ function K8sPodsList({
const handleRowClick = (record: K8sPodsRowData): void => {
if (groupBy.length === 0) {
if (!record.podNameRaw) {
return;
}
setSelectedPodUID(record.podUID);
setSearchParams({
...Object.fromEntries(searchParams.entries()),
@@ -619,11 +616,9 @@ function K8sPodsList({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.podNameRaw) {
setSelectedPodUID(record.podUID);
}
setSelectedPodUID(record.podUID);
},
className: record.podNameRaw ? 'expanded-clickable-row' : 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -758,10 +753,7 @@ function K8sPodsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.podNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -460,9 +460,6 @@ function K8sStatefulSetsList({
const handleRowClick = (record: K8sStatefulSetsRowData): void => {
if (groupBy.length === 0) {
if (!record.statefulsetNameRaw) {
return;
}
setSelectedRowData(null);
setselectedStatefulSetUID(record.statefulsetUID);
setSearchParams({
@@ -527,13 +524,9 @@ function K8sStatefulSetsList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.statefulsetNameRaw) {
setselectedStatefulSetUID(record.statefulsetUID);
}
setselectedStatefulSetUID(record.statefulsetUID);
},
className: record.statefulsetNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -725,10 +718,7 @@ function K8sStatefulSetsList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.statefulsetNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -82,7 +82,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sStatefulSetsRowData {
key: string;
statefulsetUID: string;
statefulsetNameRaw: string;
statefulsetName: React.ReactNode;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
@@ -277,7 +276,6 @@ export const formatDataForTable = (
data.map((statefulSet, index) => ({
key: index.toString(),
statefulsetUID: statefulSet.statefulSetName,
statefulsetNameRaw: statefulSet.meta.k8s_statefulset_name || '',
statefulsetName: (
<Tooltip title={statefulSet.meta.k8s_statefulset_name}>
{statefulSet.meta.k8s_statefulset_name || ''}

View File

@@ -390,9 +390,6 @@ function K8sVolumesList({
const handleRowClick = (record: K8sVolumesRowData): void => {
if (groupBy.length === 0) {
if (!record.volumeNameRaw) {
return;
}
setSelectedRowData(null);
setselectedVolumeUID(record.volumeUID);
setSearchParams({
@@ -457,13 +454,9 @@ function K8sVolumesList({
showHeader={false}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (record.volumeNameRaw) {
setselectedVolumeUID(record.volumeUID);
}
setselectedVolumeUID(record.volumeUID);
},
className: record.volumeNameRaw
? 'expanded-clickable-row'
: 'disabled-row',
className: 'expanded-clickable-row',
})}
/>
@@ -648,10 +641,7 @@ function K8sVolumesList({
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className:
groupBy.length > 0 || record.volumeNameRaw
? 'clickable-row'
: 'disabled-row',
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,

View File

@@ -47,7 +47,6 @@ export const defaultAddedColumns: IEntityColumn[] = [
export interface K8sVolumesRowData {
key: string;
volumeUID: string;
volumeNameRaw: string;
pvcName: React.ReactNode;
namespaceName: React.ReactNode;
capacity: React.ReactNode;
@@ -187,7 +186,6 @@ export const formatDataForTable = (
data.map((volume, index) => ({
key: index.toString(),
volumeUID: volume.persistentVolumeClaimName,
volumeNameRaw: volume.persistentVolumeClaimName || '',
pvcName: (
<Tooltip title={volume.persistentVolumeClaimName}>
{volume.persistentVolumeClaimName || ''}

View File

@@ -105,7 +105,6 @@ export const defaultAvailableColumns = [
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podNameRaw: string;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
@@ -348,7 +347,6 @@ export const formatDataForTable = (
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podNameRaw: pod.meta.k8s_pod_name || '',
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper

View File

@@ -0,0 +1,91 @@
.trace-details-header {
display: flex;
align-items: center;
padding: 0px 16px;
.previous-btn {
display: flex;
height: 30px;
padding: 6px 8px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
background: var(--bg-slate-500);
border-radius: 4px;
box-shadow: none;
}
.trace-name {
display: flex;
padding: 6px 8px;
margin-left: 6px;
align-items: center;
gap: 4px;
border: 1px solid var(--bg-slate-300);
border-radius: 4px 0px 0px 4px;
background: var(--bg-slate-500);
.drafting {
color: white;
}
.trace-id {
color: #fff;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
}
.trace-id-value {
display: flex;
padding: 6px 8px;
justify-content: center;
align-items: center;
gap: 10px;
background: var(--bg-slate-400);
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
border: 1px solid var(--bg-slate-300);
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
}
.lightMode {
.trace-details-header {
.previous-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
}
.trace-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
border-right: none;
.drafting {
color: var(--bg-ink-100);
}
.trace-id {
color: var(--bg-ink-100);
}
}
.trace-id-value {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
}
}
}
// TODO: move to new css module name system

View File

@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Button, Typography } from 'antd';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { ArrowLeft } from 'lucide-react';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import './TraceDetailsHeader.styles.scss';
function TraceDetailsHeader(): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const handlePreviousBtnClick = useCallback((): void => {
const isSpaNavigate =
document.referrer &&
new URL(document.referrer).origin === window.location.origin;
if (isSpaNavigate) {
history.goBack();
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, []);
return (
<div className="trace-details-header">
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
<ArrowLeft size={14} />
</Button>
<div className="trace-name">
<Typography.Text className="trace-id">Trace ID</Typography.Text>
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
</div>
);
}
export default TraceDetailsHeader;

View File

@@ -0,0 +1,221 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { FlamegraphCanvasProps, SpanRect } from './types';
import { formatDuration } from './utils';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const { spans, traceMetadata, firstSpanAtFetchLevel, onSpanClick } = props;
const isDarkMode = useIsDarkMode(); //TODO: see if can be removed or use a new hook
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const spanRectsRef = useRef<SpanRect[]>([]);
const [viewStartTs, setViewStartTs] = useState<number>(
traceMetadata.startTime,
);
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
const [scrollTop, setScrollTop] = useState<number>(0);
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
const viewStartRef = useRef(viewStartTs);
const viewEndRef = useRef(viewEndTs);
const rowHeightRef = useRef(rowHeight);
const scrollTopRef = useRef(scrollTop);
useEffect(() => {
viewStartRef.current = viewStartTs;
}, [viewStartTs]);
useEffect(() => {
viewEndRef.current = viewEndTs;
}, [viewEndTs]);
useEffect(() => {
rowHeightRef.current = rowHeight;
}, [rowHeight]);
useEffect(() => {
scrollTopRef.current = scrollTop;
}, [scrollTop]);
useEffect(() => {
//TODO: see if this can be removed as once loaded the view start and end ts will not change
setViewStartTs(traceMetadata.startTime);
setViewEndTs(traceMetadata.endTime);
viewStartRef.current = traceMetadata.startTime;
viewEndRef.current = traceMetadata.endTime;
}, [traceMetadata.startTime, traceMetadata.endTime]);
const totalHeight = spans.length * rowHeight;
const { isOverFlamegraphRef } = useFlamegraphZoom({
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
});
const {
handleMouseDown,
handleMouseMove: handleDragMouseMove,
handleMouseUp,
handleDragMouseLeave,
suppressClickRef,
isDraggingRef,
} = useFlamegraphDrag({
canvasRef,
containerRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
totalHeight,
});
const {
hoveredSpanId,
handleHoverMouseMove,
handleHoverMouseLeave,
handleClick,
tooltipContent,
} = useFlamegraphHover({
canvasRef,
spanRectsRef,
traceMetadata,
viewStartTs,
viewEndTs,
isDraggingRef,
suppressClickRef,
onSpanClick,
isDarkMode,
});
const { drawFlamegraph } = useFlamegraphDraw({
canvasRef,
containerRef,
spans,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId: firstSpanAtFetchLevel || undefined,
hoveredSpanId: hoveredSpanId ?? '',
isDarkMode,
spanRectsRef,
});
useCanvasSetup(canvasRef, containerRef, drawFlamegraph);
const handleMouseMove = useCallback(
(e: React.MouseEvent): void => {
handleDragMouseMove(e);
handleHoverMouseMove(e);
},
[handleDragMouseMove, handleHoverMouseMove],
);
const handleMouseLeave = useCallback((): void => {
isOverFlamegraphRef.current = false;
handleDragMouseLeave();
handleHoverMouseLeave();
}, [isOverFlamegraphRef, handleDragMouseLeave, handleHoverMouseLeave]);
// todo: move to a separate component/utils file
const tooltipElement = tooltipContent
? createPortal(
<div
style={{
position: 'fixed',
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
zIndex: 1000,
backgroundColor: 'rgba(30, 30, 30, 0.95)',
color: '#fff',
padding: '8px 12px',
borderRadius: 4,
fontSize: 12,
fontFamily: 'Inter, sans-serif',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}}
>
<div
style={{
fontWeight: 600,
marginBottom: 4,
color: tooltipContent.spanColor,
}}
>
{tooltipContent.spanName}
</div>
<div>Status: {tooltipContent.status}</div>
<div>Start: {tooltipContent.startMs.toFixed(2)} ms</div>
<div>Duration: {formatDuration(tooltipContent.durationMs * 1e6)}</div>
</div>,
document.body,
)
: null;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{tooltipElement}
<TimelineV3
startTimestamp={viewStartTs}
endTimestamp={viewEndTs}
offsetTimestamp={viewStartTs - traceMetadata.startTime}
timelineHeight={22}
/>
<div
ref={containerRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
}}
onMouseEnter={(): void => {
isOverFlamegraphRef.current = true;
}}
onMouseLeave={handleMouseLeave}
>
<canvas
ref={canvasRef}
style={{
display: 'block',
width: '100%',
cursor: 'grab',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleClick}
/>
</div>
</div>
);
}
export default FlamegraphCanvas;

View File

@@ -0,0 +1,118 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
import FlamegraphCanvas from './FlamegraphCanvas';
//TODO: analyse if this is needed or not and move to separate file if needed else delete this enum.
enum TraceFlamegraphState {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA = 'FETCHING_WITH_OLD_DATA',
}
interface TraceFlamegraphProps {
serviceExecTime: Record<string, number>;
startTime: number;
endTime: number;
selectedSpan: Span | undefined;
}
function TraceFlamegraph(props: TraceFlamegraphProps): JSX.Element {
const { selectedSpan } = props;
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const history = useHistory();
const { search } = useLocation();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
useEffect(() => {
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
}, [urlQuery]);
const handleSpanClick = useCallback(
(spanId: string): void => {
setFirstSpanAtFetchLevel(spanId);
const searchParams = new URLSearchParams(search);
//tood: use from query params constants
if (searchParams.get('spanId') !== spanId) {
searchParams.set('spanId', spanId);
history.replace({ search: searchParams.toString() });
}
},
[history, search],
);
const { data, isFetching, error } = useGetTraceFlamegraph({
traceId,
selectedSpanId: firstSpanAtFetchLevel,
});
const flamegraphState = useMemo(() => {
if (isFetching) {
if (data?.payload?.spans && data.payload.spans.length > 0) {
return TraceFlamegraphState.FETCHING_WITH_OLD_DATA;
}
return TraceFlamegraphState.LOADING;
}
if (error) {
return TraceFlamegraphState.ERROR;
}
if (data?.payload?.spans && data.payload.spans.length === 0) {
return TraceFlamegraphState.NO_DATA;
}
return TraceFlamegraphState.SUCCESS;
}, [error, isFetching, data]);
const spans = useMemo(() => data?.payload?.spans || [], [
data?.payload?.spans,
]);
const content = useMemo(() => {
switch (flamegraphState) {
case TraceFlamegraphState.LOADING:
return <div>Loading...</div>;
case TraceFlamegraphState.ERROR:
return <div>Error loading flamegraph</div>;
case TraceFlamegraphState.NO_DATA:
return <div>No data found for trace {traceId}</div>;
case TraceFlamegraphState.SUCCESS:
case TraceFlamegraphState.FETCHING_WITH_OLD_DATA:
return (
<FlamegraphCanvas
spans={spans}
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
selectedSpan={selectedSpan}
/>
);
default:
return <div>Fetching the trace...</div>;
}
}, [
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
firstSpanAtFetchLevel,
flamegraphState,
selectedSpan,
spans,
traceId,
handleSpanClick,
]);
return <>{content}</>;
}
export default TraceFlamegraph;

View File

@@ -0,0 +1,36 @@
export const ROW_HEIGHT = 24;
export const SPAN_BAR_HEIGHT = 22;
export const SPAN_BAR_Y_OFFSET = Math.floor((ROW_HEIGHT - SPAN_BAR_HEIGHT) / 2);
export const EVENT_DOT_SIZE = 6;
// Span bar sizing relative to row height (used by getFlamegraphRowMetrics)
export const SPAN_BAR_HEIGHT_RATIO = SPAN_BAR_HEIGHT / ROW_HEIGHT;
export const MIN_SPAN_BAR_HEIGHT = 8;
export const MAX_SPAN_BAR_HEIGHT = SPAN_BAR_HEIGHT;
// Event dot sizing relative to span bar height
export const EVENT_DOT_SIZE_RATIO = EVENT_DOT_SIZE / SPAN_BAR_HEIGHT;
export const MIN_EVENT_DOT_SIZE = 4;
export const MAX_EVENT_DOT_SIZE = EVENT_DOT_SIZE;
export const LABEL_FONT = '11px Inter, sans-serif';
export const LABEL_PADDING_X = 8;
export const MIN_WIDTH_FOR_NAME = 30;
export const MIN_WIDTH_FOR_NAME_AND_DURATION = 80;
// Dynamic row height (vertical zoom) -- disabled for now (MIN === MAX)
export const MIN_ROW_HEIGHT = 24;
export const MAX_ROW_HEIGHT = 24;
export const DEFAULT_ROW_HEIGHT = MIN_ROW_HEIGHT;
// Zoom intensity -- how fast zoom reacts to wheel/pinch delta
export const PINCH_ZOOM_INTENSITY_H = 0.01;
export const SCROLL_ZOOM_INTENSITY_H = 0.0015;
export const PINCH_ZOOM_INTENSITY_V = 0.008;
export const SCROLL_ZOOM_INTENSITY_V = 0.001;
// Minimum visible time span in ms (prevents zooming to sub-pixel)
export const MIN_VISIBLE_SPAN_MS = 5;
// Selection and hover style (derived from span color per mock design)
export const DASHED_BORDER_LINE_DASH = [4, 2];

View File

@@ -0,0 +1,55 @@
import { RefObject, useCallback, useEffect } from 'react';
export function useCanvasSetup(
canvasRef: RefObject<HTMLCanvasElement>,
containerRef: RefObject<HTMLDivElement>,
onDraw: () => void,
): void {
const updateCanvasSize = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
return;
}
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const viewportHeight = container.clientHeight;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${viewportHeight}px`;
const newWidth = Math.floor(rect.width * dpr);
const newHeight = Math.floor(viewportHeight * dpr);
if (canvas.width !== newWidth || canvas.height !== newHeight) {
canvas.width = newWidth;
canvas.height = newHeight;
onDraw();
}
}, [canvasRef, containerRef, onDraw]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return (): void => {};
}
const resizeObserver = new ResizeObserver(updateCanvasSize);
resizeObserver.observe(container);
updateCanvasSize();
// when dpr changes, update the canvas size
const dprQuery = window.matchMedia('(resolution: 1dppx)');
dprQuery.addEventListener('change', updateCanvasSize);
return (): void => {
resizeObserver.disconnect();
dprQuery.removeEventListener('change', updateCanvasSize);
};
}, [containerRef, updateCanvasSize]);
useEffect(() => {
onDraw();
}, [onDraw]);
}

View File

@@ -0,0 +1,178 @@
import {
Dispatch,
MouseEvent as ReactMouseEvent,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useRef,
} from 'react';
import { ITraceMetadata } from '../types';
import { clamp } from '../utils';
interface UseFlamegraphDragArgs {
canvasRef: RefObject<HTMLCanvasElement>;
containerRef: RefObject<HTMLDivElement>;
traceMetadata: ITraceMetadata;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
scrollTopRef: MutableRefObject<number>;
setScrollTop: Dispatch<SetStateAction<number>>;
totalHeight: number;
}
interface UseFlamegraphDragResult {
handleMouseDown: (e: ReactMouseEvent) => void;
handleMouseMove: (e: ReactMouseEvent) => void;
handleMouseUp: () => void;
handleDragMouseLeave: () => void;
suppressClickRef: MutableRefObject<boolean>;
isDraggingRef: MutableRefObject<boolean>;
}
const DRAG_THRESHOLD = 5;
export function useFlamegraphDrag(
args: UseFlamegraphDragArgs,
): UseFlamegraphDragResult {
const {
canvasRef,
containerRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
totalHeight,
} = args;
const isDraggingRef = useRef(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragDistanceRef = useRef(0);
const suppressClickRef = useRef(false);
const clampScrollTop = useCallback(
(next: number): number => {
const container = containerRef.current;
if (!container) {
return 0;
}
const viewportHeight = container.clientHeight;
const maxScroll = Math.max(0, totalHeight - viewportHeight);
return clamp(next, 0, maxScroll);
},
[containerRef, totalHeight],
);
const handleMouseDown = useCallback(
(event: ReactMouseEvent): void => {
if (event.button !== 0) {
return;
}
event.preventDefault();
isDraggingRef.current = true;
dragStartRef.current = { x: event.clientX, y: event.clientY };
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grabbing';
}
},
[canvasRef],
);
const handleMouseMove = useCallback(
(event: ReactMouseEvent): void => {
if (!isDraggingRef.current || !dragStartRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const rect = canvas.getBoundingClientRect();
const deltaX = event.clientX - dragStartRef.current.x;
const deltaY = event.clientY - dragStartRef.current.y;
dragDistanceRef.current = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// --- Horizontal pan ---
const timeSpan = viewEndRef.current - viewStartRef.current;
const deltaTime = (deltaX / rect.width) * timeSpan;
const newStart = viewStartRef.current - deltaTime;
const clampedStart = clamp(
newStart,
traceMetadata.startTime,
traceMetadata.endTime - timeSpan,
);
const clampedEnd = clampedStart + timeSpan;
viewStartRef.current = clampedStart;
viewEndRef.current = clampedEnd;
setViewStartTs(clampedStart);
setViewEndTs(clampedEnd);
// --- Vertical scroll pan ---
const nextScrollTop = clampScrollTop(scrollTopRef.current - deltaY);
scrollTopRef.current = nextScrollTop;
setScrollTop(nextScrollTop);
dragStartRef.current = { x: event.clientX, y: event.clientY };
},
[
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
setViewStartTs,
setViewEndTs,
scrollTopRef,
setScrollTop,
clampScrollTop,
],
);
const handleMouseUp = useCallback((): void => {
const wasDrag = dragDistanceRef.current > DRAG_THRESHOLD;
suppressClickRef.current = wasDrag;
isDraggingRef.current = false;
dragStartRef.current = null;
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grab';
}
}, [canvasRef]);
const handleDragMouseLeave = useCallback((): void => {
isDraggingRef.current = false;
dragStartRef.current = null;
dragDistanceRef.current = 0;
const canvas = canvasRef.current;
if (canvas) {
canvas.style.cursor = 'grab';
}
}, [canvasRef]);
return {
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleDragMouseLeave,
suppressClickRef,
isDraggingRef,
};
}

View File

@@ -0,0 +1,219 @@
import React, { RefObject, useCallback, useRef } from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpanRect } from '../types';
import {
clamp,
drawSpanBar,
FlamegraphRowMetrics,
getFlamegraphRowMetrics,
getSpanColor,
} from '../utils';
interface UseFlamegraphDrawArgs {
canvasRef: RefObject<HTMLCanvasElement>;
containerRef: RefObject<HTMLDivElement>;
spans: FlamegraphSpan[][];
viewStartTs: number;
viewEndTs: number;
scrollTop: number;
rowHeight: number;
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
spanRectsRef?: React.MutableRefObject<SpanRect[]>;
}
interface UseFlamegraphDrawResult {
drawFlamegraph: () => void;
spanRectsRef: RefObject<SpanRect[]>;
}
const OVERSCAN_ROWS = 4;
interface DrawLevelArgs {
ctx: CanvasRenderingContext2D;
levelSpans: FlamegraphSpan[];
levelIndex: number;
y: number;
viewStartTs: number;
timeSpan: number;
cssWidth: number;
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
spanRectsArray: SpanRect[];
metrics: FlamegraphRowMetrics;
}
function drawLevel(args: DrawLevelArgs): void {
const {
ctx,
levelSpans,
levelIndex,
y,
viewStartTs,
timeSpan,
cssWidth,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsArray,
metrics,
} = args;
const viewEndTs = viewStartTs + timeSpan;
for (let i = 0; i < levelSpans.length; i++) {
const span = levelSpans[i];
const spanStartMs = span.timestamp;
const spanEndMs = span.timestamp + span.durationNano / 1e6;
// Time culling -- skip spans entirely outside the visible time window
if (spanEndMs < viewStartTs || spanStartMs > viewEndTs) {
continue;
}
const leftOffset = ((spanStartMs - viewStartTs) / timeSpan) * cssWidth;
const rightEdge = ((spanEndMs - viewStartTs) / timeSpan) * cssWidth;
let width = rightEdge - leftOffset;
// Clamp to visible x-range
if (leftOffset < 0) {
width += leftOffset;
if (width <= 0) {
continue;
}
}
if (rightEdge > cssWidth) {
width = cssWidth - Math.max(0, leftOffset);
if (width <= 0) {
continue;
}
}
// Minimum 1px width so tiny spans remain visible
width = clamp(width, 1, Infinity);
const color = getSpanColor({ span, isDarkMode });
drawSpanBar({
ctx,
span,
x: Math.max(0, leftOffset),
y,
width,
levelIndex,
spanRectsArray,
color,
isDarkMode,
metrics,
selectedSpanId,
hoveredSpanId,
});
}
}
export function useFlamegraphDraw(
args: UseFlamegraphDrawArgs,
): UseFlamegraphDrawResult {
const {
canvasRef,
containerRef,
spans,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsRef: spanRectsRefProp,
} = args;
const spanRectsRefInternal = useRef<SpanRect[]>([]);
const spanRectsRef = spanRectsRefProp ?? spanRectsRefInternal;
const drawFlamegraph = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const timeSpan = viewEndTs - viewStartTs;
if (timeSpan <= 0) {
return;
}
const cssWidth = canvas.width / dpr;
const metrics = getFlamegraphRowMetrics(rowHeight);
// ---- Vertical clipping window ----
const viewportHeight = container.clientHeight;
const firstLevel = Math.max(
0,
Math.floor(scrollTop / metrics.ROW_HEIGHT) - OVERSCAN_ROWS,
);
const visibleLevelCount =
Math.ceil(viewportHeight / metrics.ROW_HEIGHT) + 2 * OVERSCAN_ROWS;
const lastLevel = Math.min(spans.length - 1, firstLevel + visibleLevelCount);
ctx.clearRect(0, 0, cssWidth, viewportHeight);
const spanRectsArray: SpanRect[] = [];
// ---- Draw only visible levels ----
for (let levelIndex = firstLevel; levelIndex <= lastLevel; levelIndex++) {
const levelSpans = spans[levelIndex];
if (!levelSpans) {
continue;
}
drawLevel({
ctx,
levelSpans,
levelIndex,
y: levelIndex * metrics.ROW_HEIGHT - scrollTop,
viewStartTs,
timeSpan,
cssWidth,
selectedSpanId,
hoveredSpanId,
isDarkMode,
spanRectsArray,
metrics,
});
}
spanRectsRef.current = spanRectsArray;
}, [
canvasRef,
containerRef,
spanRectsRef,
spans,
viewStartTs,
viewEndTs,
scrollTop,
rowHeight,
selectedSpanId,
hoveredSpanId,
isDarkMode,
]);
// TODO: spanRectsRef is a flat array — hover scans all visible rects O(N).
// Upgrade to per-level buckets: spanRects[levelIndex] = [...] so hover can
// compute level from mouseY / ROW_HEIGHT and scan only that row.
// Further: binary search within a level by x (spans are sorted by start time)
// to reduce hover cost from O(N) to O(log N).
return { drawFlamegraph, spanRectsRef };
}

View File

@@ -0,0 +1,214 @@
import {
Dispatch,
MouseEvent as ReactMouseEvent,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useState,
} from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import { getSpanColor } from '../utils';
function getCanvasPointer(
canvas: HTMLCanvasElement,
clientX: number,
clientY: number,
): { cssX: number; cssY: number } | null {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssHeight = canvas.height / dpr;
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
const cssY = (clientY - rect.top) * (cssHeight / rect.height);
return { cssX, cssY };
}
function findSpanAtPosition(
cssX: number,
cssY: number,
spanRects: SpanRect[],
): FlamegraphSpan | null {
for (let i = spanRects.length - 1; i >= 0; i--) {
const r = spanRects[i];
if (
cssX >= r.x &&
cssX <= r.x + r.width &&
cssY >= r.y &&
cssY <= r.y + r.height
) {
return r.span;
}
}
return null;
}
export interface TooltipContent {
spanName: string;
status: 'ok' | 'warning' | 'error';
startMs: number;
durationMs: number;
clientX: number;
clientY: number;
spanColor: string;
}
interface UseFlamegraphHoverArgs {
canvasRef: RefObject<HTMLCanvasElement>;
spanRectsRef: MutableRefObject<SpanRect[]>;
traceMetadata: ITraceMetadata;
viewStartTs: number;
viewEndTs: number;
isDraggingRef: MutableRefObject<boolean>;
suppressClickRef: MutableRefObject<boolean>;
onSpanClick: (spanId: string) => void;
isDarkMode: boolean;
}
interface UseFlamegraphHoverResult {
hoveredSpanId: string | null;
setHoveredSpanId: Dispatch<SetStateAction<string | null>>;
handleHoverMouseMove: (e: ReactMouseEvent) => void;
handleHoverMouseLeave: () => void;
handleClick: (e: ReactMouseEvent) => void;
tooltipContent: TooltipContent | null;
}
export function useFlamegraphHover(
args: UseFlamegraphHoverArgs,
): UseFlamegraphHoverResult {
const {
canvasRef,
spanRectsRef,
traceMetadata,
viewStartTs,
viewEndTs,
isDraggingRef,
suppressClickRef,
onSpanClick,
isDarkMode,
} = args;
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
const [tooltipContent, setTooltipContent] = useState<TooltipContent | null>(
null,
);
const isZoomed =
viewStartTs !== traceMetadata.startTime ||
viewEndTs !== traceMetadata.endTime;
const updateCursor = useCallback(
(canvas: HTMLCanvasElement, span: FlamegraphSpan | null): void => {
if (span) {
canvas.style.cursor = 'pointer';
} else if (isZoomed) {
canvas.style.cursor = 'grab';
} else {
canvas.style.cursor = 'default';
}
},
[isZoomed],
);
const handleHoverMouseMove = useCallback(
(e: ReactMouseEvent): void => {
if (isDraggingRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
if (!pointer) {
return;
}
const span = findSpanAtPosition(
pointer.cssX,
pointer.cssY,
spanRectsRef.current,
);
if (span) {
setHoveredSpanId(span.spanId);
setTooltipContent({
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({ span, isDarkMode }),
});
updateCursor(canvas, span);
} else {
setHoveredSpanId(null);
setTooltipContent(null);
updateCursor(canvas, null);
}
},
[
canvasRef,
spanRectsRef,
traceMetadata.startTime,
isDraggingRef,
updateCursor,
isDarkMode,
],
);
const handleHoverMouseLeave = useCallback((): void => {
setHoveredSpanId(null);
setTooltipContent(null);
const canvas = canvasRef.current;
if (canvas) {
updateCursor(canvas, null);
}
}, [canvasRef, updateCursor]);
const handleClick = useCallback(
(e: ReactMouseEvent): void => {
if (suppressClickRef.current) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const pointer = getCanvasPointer(canvas, e.clientX, e.clientY);
if (!pointer) {
return;
}
const span = findSpanAtPosition(
pointer.cssX,
pointer.cssY,
spanRectsRef.current,
);
if (span) {
onSpanClick(span.spanId);
}
},
[canvasRef, spanRectsRef, suppressClickRef, onSpanClick],
);
return {
hoveredSpanId,
setHoveredSpanId,
handleHoverMouseMove,
handleHoverMouseLeave,
handleClick,
tooltipContent,
};
}

View File

@@ -0,0 +1,224 @@
import {
Dispatch,
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import {
DEFAULT_ROW_HEIGHT,
MAX_ROW_HEIGHT,
MIN_ROW_HEIGHT,
MIN_VISIBLE_SPAN_MS,
PINCH_ZOOM_INTENSITY_H,
PINCH_ZOOM_INTENSITY_V,
SCROLL_ZOOM_INTENSITY_H,
SCROLL_ZOOM_INTENSITY_V,
} from '../constants';
import { ITraceMetadata } from '../types';
import { clamp } from '../utils';
interface UseFlamegraphZoomArgs {
canvasRef: RefObject<HTMLCanvasElement>;
traceMetadata: ITraceMetadata;
viewStartRef: MutableRefObject<number>;
viewEndRef: MutableRefObject<number>;
rowHeightRef: MutableRefObject<number>;
setViewStartTs: Dispatch<SetStateAction<number>>;
setViewEndTs: Dispatch<SetStateAction<number>>;
setRowHeight: Dispatch<SetStateAction<number>>;
}
interface UseFlamegraphZoomResult {
handleResetZoom: () => void;
isOverFlamegraphRef: MutableRefObject<boolean>;
}
function getCanvasPointer(
canvasRef: RefObject<HTMLCanvasElement>,
clientX: number,
): { cssX: number; cssWidth: number } | null {
const canvas = canvasRef.current;
if (!canvas) {
return null;
}
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssX = (clientX - rect.left) * (cssWidth / rect.width);
return { cssX, cssWidth };
}
export function useFlamegraphZoom(
args: UseFlamegraphZoomArgs,
): UseFlamegraphZoomResult {
const {
canvasRef,
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
} = args;
const isOverFlamegraphRef = useRef(false);
const wheelDeltaRef = useRef(0);
const rafRef = useRef<number | null>(null);
const lastCursorXRef = useRef(0);
const lastCssWidthRef = useRef(1);
const lastIsPinchRef = useRef(false);
const lastWheelClientXRef = useRef<number | null>(null);
// Prevent browser zoom when pinching over the flamegraph
useEffect(() => {
const onWheel = (e: WheelEvent): void => {
if (isOverFlamegraphRef.current && e.ctrlKey) {
e.preventDefault();
}
};
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
return (): void => {
window.removeEventListener('wheel', onWheel, {
capture: true,
} as EventListenerOptions);
};
}, []);
const applyWheelZoom = useCallback(() => {
rafRef.current = null;
const cssWidth = lastCssWidthRef.current || 1;
const cursorX = lastCursorXRef.current;
const fullSpanMs = traceMetadata.endTime - traceMetadata.startTime;
const oldStart = viewStartRef.current;
const oldEnd = viewEndRef.current;
const oldSpan = oldEnd - oldStart;
const deltaY = wheelDeltaRef.current;
wheelDeltaRef.current = 0;
if (deltaY === 0) {
return;
}
const zoomH = lastIsPinchRef.current
? PINCH_ZOOM_INTENSITY_H
: SCROLL_ZOOM_INTENSITY_H;
const zoomV = lastIsPinchRef.current
? PINCH_ZOOM_INTENSITY_V
: SCROLL_ZOOM_INTENSITY_V;
const factorH = Math.exp(deltaY * zoomH);
const factorV = Math.exp(deltaY * zoomV);
// --- Horizontal zoom ---
const desiredSpan = oldSpan * factorH;
const minSpanMs = Math.max(
MIN_VISIBLE_SPAN_MS,
oldSpan / Math.max(cssWidth, 1),
);
const clampedSpan = clamp(desiredSpan, minSpanMs, fullSpanMs);
const cursorRatio = clamp(cursorX / cssWidth, 0, 1);
const anchorTs = oldStart + cursorRatio * oldSpan;
let nextStart = anchorTs - cursorRatio * clampedSpan;
nextStart = clamp(
nextStart,
traceMetadata.startTime,
traceMetadata.endTime - clampedSpan,
);
const nextEnd = nextStart + clampedSpan;
// --- Vertical zoom (row height) ---
const desiredRow = rowHeightRef.current * (1 / factorV);
const nextRow = clamp(desiredRow, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
// Write refs immediately so rapid wheel events read fresh values
viewStartRef.current = nextStart;
viewEndRef.current = nextEnd;
rowHeightRef.current = nextRow;
setViewStartTs(nextStart);
setViewEndTs(nextEnd);
setRowHeight(nextRow);
}, [
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
]);
// Native wheel listener on the canvas (passive: false for reliable preventDefault)
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return (): void => {};
}
const onWheel = (e: WheelEvent): void => {
e.preventDefault();
const pointer = getCanvasPointer(canvasRef, e.clientX);
if (!pointer) {
return;
}
// Flush accumulated delta if cursor moved significantly
if (lastWheelClientXRef.current !== null) {
const moved = Math.abs(e.clientX - lastWheelClientXRef.current);
if (moved > 6) {
wheelDeltaRef.current = 0;
}
}
lastWheelClientXRef.current = e.clientX;
lastIsPinchRef.current = e.ctrlKey;
lastCssWidthRef.current = pointer.cssWidth;
lastCursorXRef.current = pointer.cssX;
wheelDeltaRef.current += e.deltaY;
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(applyWheelZoom);
}
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return (): void => {
canvas.removeEventListener('wheel', onWheel);
};
}, [canvasRef, applyWheelZoom]);
const handleResetZoom = useCallback(() => {
viewStartRef.current = traceMetadata.startTime;
viewEndRef.current = traceMetadata.endTime;
rowHeightRef.current = DEFAULT_ROW_HEIGHT;
setViewStartTs(traceMetadata.startTime);
setViewEndTs(traceMetadata.endTime);
setRowHeight(DEFAULT_ROW_HEIGHT);
}, [
traceMetadata,
viewStartRef,
viewEndRef,
rowHeightRef,
setViewStartTs,
setViewEndTs,
setRowHeight,
]);
return { handleResetZoom, isOverFlamegraphRef };
}

View File

@@ -0,0 +1,26 @@
import { Dispatch, SetStateAction } from 'react';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
export interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface FlamegraphCanvasProps {
spans: FlamegraphSpan[][];
firstSpanAtFetchLevel: string;
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
onSpanClick: (spanId: string) => void;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
}
export interface SpanRect {
span: FlamegraphSpan;
x: number;
y: number;
width: number;
height: number;
level: number;
}

View File

@@ -0,0 +1,302 @@
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import {
DASHED_BORDER_LINE_DASH,
EVENT_DOT_SIZE_RATIO,
LABEL_FONT,
LABEL_PADDING_X,
MAX_EVENT_DOT_SIZE,
MAX_SPAN_BAR_HEIGHT,
MIN_EVENT_DOT_SIZE,
MIN_SPAN_BAR_HEIGHT,
MIN_WIDTH_FOR_NAME,
MIN_WIDTH_FOR_NAME_AND_DURATION,
SPAN_BAR_HEIGHT_RATIO,
} from './constants';
import { SpanRect } from './types';
export function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
export interface FlamegraphRowMetrics {
ROW_HEIGHT: number;
SPAN_BAR_HEIGHT: number;
SPAN_BAR_Y_OFFSET: number;
EVENT_DOT_SIZE: number;
}
export function getFlamegraphRowMetrics(
rowHeight: number,
): FlamegraphRowMetrics {
const spanBarHeight = clamp(
Math.round(rowHeight * SPAN_BAR_HEIGHT_RATIO),
MIN_SPAN_BAR_HEIGHT,
MAX_SPAN_BAR_HEIGHT,
);
const spanBarYOffset = Math.floor((rowHeight - spanBarHeight) / 2);
const eventDotSize = clamp(
Math.round(spanBarHeight * EVENT_DOT_SIZE_RATIO),
MIN_EVENT_DOT_SIZE,
MAX_EVENT_DOT_SIZE,
);
return {
ROW_HEIGHT: rowHeight,
SPAN_BAR_HEIGHT: spanBarHeight,
SPAN_BAR_Y_OFFSET: spanBarYOffset,
EVENT_DOT_SIZE: eventDotSize,
};
}
interface GetSpanColorArgs {
span: FlamegraphSpan;
isDarkMode: boolean;
}
export function getSpanColor(args: GetSpanColorArgs): string {
const { span, isDarkMode } = args;
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
if (span.hasError) {
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
}
return color;
}
interface DrawEventDotArgs {
ctx: CanvasRenderingContext2D;
x: number;
y: number;
isError: boolean;
isDarkMode: boolean;
eventDotSize: number;
}
export function drawEventDot(args: DrawEventDotArgs): void {
const { ctx, x, y, isError, isDarkMode, eventDotSize } = args;
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.PI / 4);
if (isError) {
ctx.fillStyle = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
ctx.strokeStyle = isDarkMode ? 'rgb(185, 28, 28)' : 'rgb(153, 27, 27)';
} else {
ctx.fillStyle = isDarkMode ? 'rgb(14, 165, 233)' : 'rgb(6, 182, 212)';
ctx.strokeStyle = isDarkMode ? 'rgb(2, 132, 199)' : 'rgb(8, 145, 178)';
}
ctx.lineWidth = 1;
const half = eventDotSize / 2;
ctx.fillRect(-half, -half, eventDotSize, eventDotSize);
ctx.strokeRect(-half, -half, eventDotSize, eventDotSize);
ctx.restore();
}
interface DrawSpanBarArgs {
ctx: CanvasRenderingContext2D;
span: FlamegraphSpan;
x: number;
y: number;
width: number;
levelIndex: number;
spanRectsArray: SpanRect[];
color: string;
isDarkMode: boolean;
metrics: FlamegraphRowMetrics;
selectedSpanId?: string | null;
hoveredSpanId?: string | null;
}
export function drawSpanBar(args: DrawSpanBarArgs): void {
const {
ctx,
span,
x,
y,
width,
levelIndex,
spanRectsArray,
color,
isDarkMode,
metrics,
selectedSpanId,
hoveredSpanId,
} = args;
const spanY = y + metrics.SPAN_BAR_Y_OFFSET;
const isSelectedOrHovered =
selectedSpanId === span.spanId || hoveredSpanId === span.spanId;
ctx.beginPath();
ctx.roundRect(x, spanY, width, metrics.SPAN_BAR_HEIGHT, 2);
if (isSelectedOrHovered) {
// Transparent background, dashed border in same color as span
ctx.setLineDash(DASHED_BORDER_LINE_DASH);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
ctx.setLineDash([]);
} else {
ctx.fillStyle = color;
ctx.fill();
}
spanRectsArray.push({
span,
x,
y: spanY,
width,
height: metrics.SPAN_BAR_HEIGHT,
level: levelIndex,
});
span.event?.forEach((event) => {
const spanDurationMs = span.durationNano / 1e6;
if (spanDurationMs <= 0) {
return;
}
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
const eventX = x + (clampedOffset / 100) * width;
const eventY = spanY + metrics.SPAN_BAR_HEIGHT / 2;
drawEventDot({
ctx,
x: eventX,
y: eventY,
isError: event.isError,
isDarkMode,
eventDotSize: metrics.EVENT_DOT_SIZE,
});
});
drawSpanLabel({
ctx,
span,
x,
y: spanY,
width,
color,
isSelectedOrHovered,
isDarkMode,
spanBarHeight: metrics.SPAN_BAR_HEIGHT,
});
}
export function formatDuration(durationNano: number): string {
const durationMs = durationNano / 1e6;
const { time, timeUnitName } = convertTimeToRelevantUnit(durationMs);
return `${parseFloat(time.toFixed(2))}${timeUnitName}`;
}
interface DrawSpanLabelArgs {
ctx: CanvasRenderingContext2D;
span: FlamegraphSpan;
x: number;
y: number;
width: number;
color: string;
isSelectedOrHovered: boolean;
isDarkMode: boolean;
spanBarHeight: number;
}
function drawSpanLabel(args: DrawSpanLabelArgs): void {
const {
ctx,
span,
x,
y,
width,
color,
isSelectedOrHovered,
isDarkMode,
spanBarHeight,
} = args;
if (width < MIN_WIDTH_FOR_NAME) {
return;
}
const name = span.name;
ctx.save();
// Clip text to span bar bounds
ctx.beginPath();
ctx.rect(x, y, width, spanBarHeight);
ctx.clip();
ctx.font = LABEL_FONT;
ctx.fillStyle = isSelectedOrHovered
? color
: isDarkMode
? 'rgba(0, 0, 0, 0.9)'
: 'rgba(255, 255, 255, 0.9)';
ctx.textBaseline = 'middle';
const textY = y + spanBarHeight / 2;
const leftX = x + LABEL_PADDING_X;
const rightX = x + width - LABEL_PADDING_X;
const availableWidth = width - LABEL_PADDING_X * 2;
if (width >= MIN_WIDTH_FOR_NAME_AND_DURATION) {
const duration = formatDuration(span.durationNano);
const durationWidth = ctx.measureText(duration).width;
const minGap = 6;
const nameSpace = availableWidth - durationWidth - minGap;
// Duration right-aligned
ctx.textAlign = 'right';
ctx.fillText(duration, rightX, textY);
// Name left-aligned, truncated to fit remaining space
if (nameSpace > 20) {
ctx.textAlign = 'left';
ctx.fillText(truncateText(ctx, name, nameSpace), leftX, textY);
}
} else {
// Name only, truncated to fit
ctx.textAlign = 'left';
ctx.fillText(truncateText(ctx, name, availableWidth), leftX, textY);
}
ctx.restore();
}
function truncateText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string {
const ellipsis = '...';
const ellipsisWidth = ctx.measureText(ellipsis).width;
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (ctx.measureText(text.slice(0, mid)).width + ellipsisWidth <= maxWidth) {
lo = mid;
} else {
hi = mid - 1;
}
}
return lo > 0 ? `${text.slice(0, lo)}${ellipsis}` : ellipsis;
}

View File

@@ -0,0 +1,57 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/resizable';
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV2URLProps>();
const [selectedSpan, _setSelectedSpan] = useState<Span>();
const [uncollapsedNodes] = useState<string[]>([]);
const { data: traceData } = useGetTraceV2({
traceId,
uncollapsedSpans: uncollapsedNodes,
selectedSpanId: '',
isSelectedSpanIDUnCollapsed: false,
});
return (
<div
style={{
height: 'calc(100vh - 90px)',
display: 'flex',
flexDirection: 'column',
}}
>
<TraceDetailsHeader />
<ResizablePanelGroup
direction="vertical"
autoSaveId="trace-details-v3-layout"
style={{ flex: 1 }}
>
<ResizablePanel defaultSize={40} minSize={20} maxSize={80}>
<TraceFlamegraph
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap || {}}
startTime={traceData?.payload?.startTimestampMillis || 0}
endTime={traceData?.payload?.endTimestampMillis || 0}
selectedSpan={selectedSpan}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={60} minSize={20}>
<div />
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default TraceDetailsV3;

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -24,7 +25,7 @@ func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
}
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
var views []types.SavedView
var views []savedviewtypes.SavedView
var err error
if len(category) == 0 {
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
@@ -76,7 +77,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
createBy := claims.Email
updatedBy := claims.Email
dbView := types.SavedView{
dbView := savedviewtypes.SavedView{
TimeAuditable: types.TimeAuditable{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
@@ -105,7 +106,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
}
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
var view types.SavedView
var view savedviewtypes.SavedView
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
@@ -146,7 +147,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
updatedBy := claims.Email
_, err = module.sqlstore.BunDB().NewUpdate().
Model(&types.SavedView{}).
Model(&savedviewtypes.SavedView{}).
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
Where("id = ?", uuid.StringValue()).
@@ -160,7 +161,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
_, err := module.sqlstore.BunDB().NewDelete().
Model(&types.SavedView{}).
Model(&savedviewtypes.SavedView{}).
Where("id = ?", uuid.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
@@ -171,7 +172,7 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
savedViews := []*types.SavedView{}
savedViews := []*savedviewtypes.SavedView{}
err := module.
sqlstore.
@@ -184,5 +185,5 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return nil, err
}
return types.NewStatsFromSavedViews(savedViews), nil
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
}

View File

@@ -13,6 +13,7 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -462,7 +463,7 @@ func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
@@ -507,7 +508,7 @@ func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/dustin/go-humanize"
@@ -279,7 +280,7 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.WithAdditionalf(err, "cannot delete root user")
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email.String())) {
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(user.Email.String())) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}

View File

@@ -10,15 +10,16 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type cloudProviderAccountsRepository interface {
listConnected(ctx context.Context, orgId string, provider string) ([]types.CloudIntegration, *model.ApiError)
listConnected(ctx context.Context, orgId string, provider string) ([]integrationtypes.CloudIntegration, *model.ApiError)
get(ctx context.Context, orgId string, provider string, id string) (*types.CloudIntegration, *model.ApiError)
get(ctx context.Context, orgId string, provider string, id string) (*integrationtypes.CloudIntegration, *model.ApiError)
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*types.CloudIntegration, *model.ApiError)
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*integrationtypes.CloudIntegration, *model.ApiError)
// Insert an account or update it by (cloudProvider, id)
// for specified non-empty fields
@@ -27,11 +28,11 @@ type cloudProviderAccountsRepository interface {
orgId string,
provider string,
id *string,
config *types.AccountConfig,
config *integrationtypes.AccountConfig,
accountId *string,
agentReport *types.AgentReport,
agentReport *integrationtypes.AgentReport,
removedAt *time.Time,
) (*types.CloudIntegration, *model.ApiError)
) (*integrationtypes.CloudIntegration, *model.ApiError)
}
func newCloudProviderAccountsRepository(store sqlstore.SQLStore) (
@@ -48,8 +49,8 @@ type cloudProviderAccountsSQLRepository struct {
func (r *cloudProviderAccountsSQLRepository) listConnected(
ctx context.Context, orgId string, cloudProvider string,
) ([]types.CloudIntegration, *model.ApiError) {
accounts := []types.CloudIntegration{}
) ([]integrationtypes.CloudIntegration, *model.ApiError) {
accounts := []integrationtypes.CloudIntegration{}
err := r.store.BunDB().NewSelect().
Model(&accounts).
@@ -72,8 +73,8 @@ func (r *cloudProviderAccountsSQLRepository) listConnected(
func (r *cloudProviderAccountsSQLRepository) get(
ctx context.Context, orgId string, provider string, id string,
) (*types.CloudIntegration, *model.ApiError) {
var result types.CloudIntegration
) (*integrationtypes.CloudIntegration, *model.ApiError) {
var result integrationtypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -97,8 +98,8 @@ func (r *cloudProviderAccountsSQLRepository) get(
func (r *cloudProviderAccountsSQLRepository) getConnectedCloudAccount(
ctx context.Context, orgId string, provider string, accountId string,
) (*types.CloudIntegration, *model.ApiError) {
var result types.CloudIntegration
) (*integrationtypes.CloudIntegration, *model.ApiError) {
var result integrationtypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -127,11 +128,11 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
orgId string,
provider string,
id *string,
config *types.AccountConfig,
config *integrationtypes.AccountConfig,
accountId *string,
agentReport *types.AgentReport,
agentReport *integrationtypes.AgentReport,
removedAt *time.Time,
) (*types.CloudIntegration, *model.ApiError) {
) (*integrationtypes.CloudIntegration, *model.ApiError) {
// Insert
if id == nil {
temp := valuer.GenerateUUID().StringValue()
@@ -181,7 +182,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
)
}
integration := types.CloudIntegration{
integration := integrationtypes.CloudIntegration{
OrgID: orgId,
Provider: provider,
Identifiable: types.Identifiable{ID: valuer.MustNewUUID(*id)},

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/exp/maps"
)
@@ -52,7 +53,7 @@ func NewController(sqlStore sqlstore.SQLStore) (*Controller, error) {
}
type ConnectedAccountsListResponse struct {
Accounts []types.Account `json:"accounts"`
Accounts []integrationtypes.Account `json:"accounts"`
}
func (c *Controller) ListConnectedAccounts(ctx context.Context, orgId string, cloudProvider string) (
@@ -67,7 +68,7 @@ func (c *Controller) ListConnectedAccounts(ctx context.Context, orgId string, cl
return nil, model.WrapApiError(apiErr, "couldn't list cloud accounts")
}
connectedAccounts := []types.Account{}
connectedAccounts := []integrationtypes.Account{}
for _, a := range accountRecords {
connectedAccounts = append(connectedAccounts, a.Account())
}
@@ -81,7 +82,7 @@ type GenerateConnectionUrlRequest struct {
// Optional. To be specified for updates.
AccountId *string `json:"account_id,omitempty"`
AccountConfig types.AccountConfig `json:"account_config"`
AccountConfig integrationtypes.AccountConfig `json:"account_config"`
AgentConfig SigNozAgentConfig `json:"agent_config"`
}
@@ -149,9 +150,9 @@ func (c *Controller) GenerateConnectionUrl(ctx context.Context, orgId string, cl
}
type AccountStatusResponse struct {
Id string `json:"id"`
CloudAccountId *string `json:"cloud_account_id,omitempty"`
Status types.AccountStatus `json:"status"`
Id string `json:"id"`
CloudAccountId *string `json:"cloud_account_id,omitempty"`
Status integrationtypes.AccountStatus `json:"status"`
}
func (c *Controller) GetAccountStatus(ctx context.Context, orgId string, cloudProvider string, accountId string) (
@@ -217,7 +218,7 @@ func (c *Controller) CheckInAsAgent(ctx context.Context, orgId string, cloudProv
))
}
agentReport := types.AgentReport{
agentReport := integrationtypes.AgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: req.Data,
}
@@ -286,10 +287,10 @@ func (c *Controller) CheckInAsAgent(ctx context.Context, orgId string, cloudProv
}
type UpdateAccountConfigRequest struct {
Config types.AccountConfig `json:"config"`
Config integrationtypes.AccountConfig `json:"config"`
}
func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, cloudProvider string, accountId string, req UpdateAccountConfigRequest) (*types.Account, *model.ApiError) {
func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, cloudProvider string, accountId string, req UpdateAccountConfigRequest) (*integrationtypes.Account, *model.ApiError) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
@@ -306,7 +307,7 @@ func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, clou
return &account, nil
}
func (c *Controller) DisconnectAccount(ctx context.Context, orgId string, cloudProvider string, accountId string) (*types.CloudIntegration, *model.ApiError) {
func (c *Controller) DisconnectAccount(ctx context.Context, orgId string, cloudProvider string, accountId string) (*integrationtypes.CloudIntegration, *model.ApiError) {
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
return nil, apiErr
}
@@ -346,7 +347,7 @@ func (c *Controller) ListServices(
return nil, model.WrapApiError(apiErr, "couldn't list cloud services")
}
svcConfigs := map[string]*types.CloudServiceConfig{}
svcConfigs := map[string]*integrationtypes.CloudServiceConfig{}
if cloudAccountId != nil {
activeAccount, apiErr := c.accountsRepo.getConnectedCloudAccount(
ctx, orgID, cloudProvider, *cloudAccountId,
@@ -441,8 +442,8 @@ func (c *Controller) GetServiceDetails(
}
type UpdateServiceConfigRequest struct {
CloudAccountId string `json:"cloud_account_id"`
Config types.CloudServiceConfig `json:"config"`
CloudAccountId string `json:"cloud_account_id"`
Config integrationtypes.CloudServiceConfig `json:"config"`
}
func (u *UpdateServiceConfigRequest) Validate(def *services.Definition) error {
@@ -460,8 +461,8 @@ func (u *UpdateServiceConfigRequest) Validate(def *services.Definition) error {
}
type UpdateServiceConfigResponse struct {
Id string `json:"id"`
Config types.CloudServiceConfig `json:"config"`
Id string `json:"id"`
Config integrationtypes.CloudServiceConfig `json:"config"`
}
func (c *Controller) UpdateServiceConfig(

View File

@@ -3,20 +3,20 @@ package cloudintegrations
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
)
type ServiceSummary struct {
services.Metadata
Config *types.CloudServiceConfig `json:"config"`
Config *integrationtypes.CloudServiceConfig `json:"config"`
}
type ServiceDetails struct {
services.Definition
Config *types.CloudServiceConfig `json:"config"`
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
Config *integrationtypes.CloudServiceConfig `json:"config"`
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
}
type AccountStatus struct {
@@ -61,7 +61,7 @@ func NewCompiledCollectionStrategy(provider string) (*CompiledCollectionStrategy
// Helper for accumulating strategies for enabled services.
func AddServiceStrategy(serviceType string, cs *CompiledCollectionStrategy,
definitionStrat *services.CollectionStrategy, config *types.CloudServiceConfig) error {
definitionStrat *services.CollectionStrategy, config *integrationtypes.CloudServiceConfig) error {
if definitionStrat.Provider != cs.Provider {
return errors.NewInternalf(CodeMismatchCloudProvider, "can't add %s service strategy to compiled strategy for %s",
definitionStrat.Provider, cs.Provider)

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -18,7 +19,7 @@ type ServiceConfigDatabase interface {
orgID string,
cloudAccountId string,
serviceType string,
) (*types.CloudServiceConfig, *model.ApiError)
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
upsert(
ctx context.Context,
@@ -26,15 +27,15 @@ type ServiceConfigDatabase interface {
cloudProvider string,
cloudAccountId string,
serviceId string,
config types.CloudServiceConfig,
) (*types.CloudServiceConfig, *model.ApiError)
config integrationtypes.CloudServiceConfig,
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
getAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (
configsBySvcId map[string]*types.CloudServiceConfig,
configsBySvcId map[string]*integrationtypes.CloudServiceConfig,
apiErr *model.ApiError,
)
}
@@ -56,9 +57,9 @@ func (r *serviceConfigSQLRepository) get(
orgID string,
cloudAccountId string,
serviceType string,
) (*types.CloudServiceConfig, *model.ApiError) {
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
var result types.CloudIntegrationService
var result integrationtypes.CloudIntegrationService
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -89,14 +90,14 @@ func (r *serviceConfigSQLRepository) upsert(
cloudProvider string,
cloudAccountId string,
serviceId string,
config types.CloudServiceConfig,
) (*types.CloudServiceConfig, *model.ApiError) {
config integrationtypes.CloudServiceConfig,
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
// get cloud integration id from account id
// if the account is not connected, we don't need to upsert the config
var cloudIntegrationId string
err := r.store.BunDB().NewSelect().
Model((*types.CloudIntegration)(nil)).
Model((*integrationtypes.CloudIntegration)(nil)).
Column("id").
Where("provider = ?", cloudProvider).
Where("account_id = ?", cloudAccountId).
@@ -111,7 +112,7 @@ func (r *serviceConfigSQLRepository) upsert(
))
}
serviceConfig := types.CloudIntegrationService{
serviceConfig := integrationtypes.CloudIntegrationService{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
@@ -139,8 +140,8 @@ func (r *serviceConfigSQLRepository) getAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (map[string]*types.CloudServiceConfig, *model.ApiError) {
serviceConfigs := []types.CloudIntegrationService{}
) (map[string]*integrationtypes.CloudServiceConfig, *model.ApiError) {
serviceConfigs := []integrationtypes.CloudIntegrationService{}
err := r.store.BunDB().NewSelect().
Model(&serviceConfigs).
@@ -154,7 +155,7 @@ func (r *serviceConfigSQLRepository) getAllForAccount(
))
}
result := map[string]*types.CloudServiceConfig{}
result := map[string]*integrationtypes.CloudServiceConfig{}
for _, r := range serviceConfigs {
result[r.Type] = &r.Config

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -107,7 +108,7 @@ type IntegrationsListItem struct {
type Integration struct {
IntegrationDetails
Installation *types.InstalledIntegration `json:"installation"`
Installation *integrationtypes.InstalledIntegration `json:"installation"`
}
type Manager struct {
@@ -223,7 +224,7 @@ func (m *Manager) InstallIntegration(
ctx context.Context,
orgId string,
integrationId string,
config types.InstalledIntegrationConfig,
config integrationtypes.InstalledIntegrationConfig,
) (*IntegrationsListItem, *model.ApiError) {
integrationDetails, apiErr := m.getIntegrationDetails(ctx, integrationId)
if apiErr != nil {
@@ -429,7 +430,7 @@ func (m *Manager) getInstalledIntegration(
ctx context.Context,
orgId string,
integrationId string,
) (*types.InstalledIntegration, *model.ApiError) {
) (*integrationtypes.InstalledIntegration, *model.ApiError) {
iis, apiErr := m.installedIntegrationsRepo.get(
ctx, orgId, []string{integrationId},
)
@@ -457,7 +458,7 @@ func (m *Manager) getInstalledIntegrations(
return nil, apiErr
}
installedTypes := utils.MapSlice(installations, func(i types.InstalledIntegration) string {
installedTypes := utils.MapSlice(installations, func(i integrationtypes.InstalledIntegration) string {
return i.Type
})
integrationDetails, apiErr := m.availableIntegrationsRepo.get(ctx, installedTypes)

View File

@@ -4,22 +4,22 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
)
type InstalledIntegrationsRepo interface {
list(ctx context.Context, orgId string) ([]types.InstalledIntegration, *model.ApiError)
list(ctx context.Context, orgId string) ([]integrationtypes.InstalledIntegration, *model.ApiError)
get(
ctx context.Context, orgId string, integrationTypes []string,
) (map[string]types.InstalledIntegration, *model.ApiError)
) (map[string]integrationtypes.InstalledIntegration, *model.ApiError)
upsert(
ctx context.Context,
orgId string,
integrationType string,
config types.InstalledIntegrationConfig,
) (*types.InstalledIntegration, *model.ApiError)
config integrationtypes.InstalledIntegrationConfig,
) (*integrationtypes.InstalledIntegration, *model.ApiError)
delete(ctx context.Context, orgId string, integrationType string) *model.ApiError
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -26,8 +27,8 @@ func NewInstalledIntegrationsSqliteRepo(store sqlstore.SQLStore) (
func (r *InstalledIntegrationsSqliteRepo) list(
ctx context.Context,
orgId string,
) ([]types.InstalledIntegration, *model.ApiError) {
integrations := []types.InstalledIntegration{}
) ([]integrationtypes.InstalledIntegration, *model.ApiError) {
integrations := []integrationtypes.InstalledIntegration{}
err := r.store.BunDB().NewSelect().
Model(&integrations).
@@ -44,8 +45,8 @@ func (r *InstalledIntegrationsSqliteRepo) list(
func (r *InstalledIntegrationsSqliteRepo) get(
ctx context.Context, orgId string, integrationTypes []string,
) (map[string]types.InstalledIntegration, *model.ApiError) {
integrations := []types.InstalledIntegration{}
) (map[string]integrationtypes.InstalledIntegration, *model.ApiError) {
integrations := []integrationtypes.InstalledIntegration{}
typeValues := []interface{}{}
for _, integrationType := range integrationTypes {
@@ -62,7 +63,7 @@ func (r *InstalledIntegrationsSqliteRepo) get(
))
}
result := map[string]types.InstalledIntegration{}
result := map[string]integrationtypes.InstalledIntegration{}
for _, ii := range integrations {
result[ii.Type] = ii
}
@@ -74,10 +75,10 @@ func (r *InstalledIntegrationsSqliteRepo) upsert(
ctx context.Context,
orgId string,
integrationType string,
config types.InstalledIntegrationConfig,
) (*types.InstalledIntegration, *model.ApiError) {
config integrationtypes.InstalledIntegrationConfig,
) (*integrationtypes.InstalledIntegration, *model.ApiError) {
integration := types.InstalledIntegration{
integration := integrationtypes.InstalledIntegration{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -114,7 +115,7 @@ func (r *InstalledIntegrationsSqliteRepo) delete(
ctx context.Context, orgId string, integrationType string,
) *model.ApiError {
_, dbErr := r.store.BunDB().NewDelete().
Model(&types.InstalledIntegration{}).
Model(&integrationtypes.InstalledIntegration{}).
Where("type = ?", integrationType).
Where("org_id = ?", orgId).
Exec(ctx)

View File

@@ -1,4 +1,4 @@
package types
package integrationtypes
import (
"database/sql/driver"
@@ -6,6 +6,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/uptrace/bun"
)
@@ -26,17 +27,17 @@ var AllIntegrationUserEmails = []IntegrationUserEmail{
type InstalledIntegration struct {
bun.BaseModel `bun:"table:installed_integration"`
Identifiable
types.Identifiable
Type string `json:"type" bun:"type,type:text,unique:org_id_type"`
Config InstalledIntegrationConfig `json:"config" bun:"config,type:text"`
InstalledAt time.Time `json:"installed_at" bun:"installed_at,default:current_timestamp"`
OrgID string `json:"org_id" bun:"org_id,type:text,unique:org_id_type,references:organizations(id),on_delete:cascade"`
}
type InstalledIntegrationConfig map[string]interface{}
type InstalledIntegrationConfig map[string]any
// For serializing from db
func (c *InstalledIntegrationConfig) Scan(src interface{}) error {
func (c *InstalledIntegrationConfig) Scan(src any) error {
var data []byte
switch v := src.(type) {
case []byte:
@@ -67,8 +68,8 @@ func (c *InstalledIntegrationConfig) Value() (driver.Value, error) {
type CloudIntegration struct {
bun.BaseModel `bun:"table:cloud_integration"`
Identifiable
TimeAuditable
types.Identifiable
types.TimeAuditable
Provider string `json:"provider" bun:"provider,type:text,unique:provider_id"`
Config *AccountConfig `json:"config" bun:"config,type:text"`
AccountID *string `json:"account_id" bun:"account_id,type:text"`
@@ -194,8 +195,8 @@ func (r *AgentReport) Value() (driver.Value, error) {
type CloudIntegrationService struct {
bun.BaseModel `bun:"table:cloud_integration_service,alias:cis"`
Identifiable
TimeAuditable
types.Identifiable
types.TimeAuditable
Type string `bun:"type,type:text,notnull,unique:cloud_integration_id_type"`
Config CloudServiceConfig `bun:"config,type:text"`
CloudIntegrationID string `bun:"cloud_integration_id,type:text,notnull,unique:cloud_integration_id_type,references:cloud_integrations(id),on_delete:cascade"`

View File

@@ -1,17 +1,18 @@
package types
package savedviewtypes
import (
"strings"
"github.com/SigNoz/signoz/pkg/types"
"github.com/uptrace/bun"
)
type SavedView struct {
bun.BaseModel `bun:"table:saved_views"`
Identifiable
TimeAuditable
UserAuditable
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"orgId" bun:"org_id,notnull"`
Name string `json:"name" bun:"name,type:text,notnull"`
Category string `json:"category" bun:"category,type:text,notnull"`