mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-23 12:20:31 +01:00
Compare commits
100 Commits
feat/query
...
infraM/v2_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9360fcf13 | ||
|
|
b5ab45db20 | ||
|
|
08f76aca78 | ||
|
|
30d3f754b5 | ||
|
|
d5dcdf382c | ||
|
|
ce5e3e7943 | ||
|
|
983d4fe4f2 | ||
|
|
833af794c3 | ||
|
|
21b51d1fcc | ||
|
|
56f22682c8 | ||
|
|
9c8359940c | ||
|
|
4050880275 | ||
|
|
5e775f64f2 | ||
|
|
0189f23f46 | ||
|
|
49a36d4e3d | ||
|
|
9407d658ab | ||
|
|
5035712485 | ||
|
|
bab17c3615 | ||
|
|
37b44f4db9 | ||
|
|
99dd6e5f1e | ||
|
|
9c7131fa6a | ||
|
|
ad889a2e1d | ||
|
|
a4f6d0cbf5 | ||
|
|
589bed7c16 | ||
|
|
93843a1f48 | ||
|
|
88c43108fc | ||
|
|
ed4cf540e8 | ||
|
|
9e2dfa9033 | ||
|
|
d98d5d68ee | ||
|
|
2cb1c3b73b | ||
|
|
ae7ca497ad | ||
|
|
a579916961 | ||
|
|
4a16d56abf | ||
|
|
642b5ac3f0 | ||
|
|
a12112619c | ||
|
|
014785f1bc | ||
|
|
58ee797b10 | ||
|
|
82d236742f | ||
|
|
397e1ad5be | ||
|
|
8d6b25ca9b | ||
|
|
5fa6bd8b8d | ||
|
|
bd9977483b | ||
|
|
50fbdfeeef | ||
|
|
e2b1b73e87 | ||
|
|
cb9f3fd3e5 | ||
|
|
232acc343d | ||
|
|
2025afdccc | ||
|
|
d2f4d4af93 | ||
|
|
47ff7bbb8e | ||
|
|
724071c5dc | ||
|
|
4d24979358 | ||
|
|
042943b10a | ||
|
|
48a9be7ec8 | ||
|
|
a9504b2120 | ||
|
|
8755887c4a | ||
|
|
4cb4662b3a | ||
|
|
e6900dabc8 | ||
|
|
c1ba389b63 | ||
|
|
3a1f40234f | ||
|
|
2e4891fa63 | ||
|
|
04ebc0bec7 | ||
|
|
271f9b81ed | ||
|
|
6fa815c294 | ||
|
|
63ec518efb | ||
|
|
c4ca20dd90 | ||
|
|
e56cc4222b | ||
|
|
07d2944d7c | ||
|
|
dea01ae36a | ||
|
|
62ea5b54e2 | ||
|
|
e549a7e42f | ||
|
|
90e2ebb11f | ||
|
|
61baa1be7a | ||
|
|
b946fa665f | ||
|
|
2e049556e4 | ||
|
|
492a5e70d7 | ||
|
|
ba1f2771e8 | ||
|
|
7458fb4855 | ||
|
|
5f55f3938b | ||
|
|
3e8102485c | ||
|
|
861c682ea5 | ||
|
|
c8e5895dff | ||
|
|
82d72e7edb | ||
|
|
a3f8ecaaf1 | ||
|
|
19aada656c | ||
|
|
b21bb4280f | ||
|
|
bc0a4fdb5c | ||
|
|
37fb0e9254 | ||
|
|
aecfa1a174 | ||
|
|
b869d23d94 | ||
|
|
6ee3d44f76 | ||
|
|
462e554107 | ||
|
|
66afa73e6f | ||
|
|
54c604bcf4 | ||
|
|
c1be02ba54 | ||
|
|
d3c7ba8f45 | ||
|
|
039c4a0496 | ||
|
|
51a94b6bbc | ||
|
|
bbfbb94f52 | ||
|
|
d1eb9ef16f | ||
|
|
3db00f8bc3 |
@@ -2287,6 +2287,205 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
InframonitoringtypesHostFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
|
||||
type: object
|
||||
InframonitoringtypesHostRecord:
|
||||
properties:
|
||||
activeHostCount:
|
||||
type: integer
|
||||
cpu:
|
||||
format: double
|
||||
type: number
|
||||
diskUsage:
|
||||
format: double
|
||||
type: number
|
||||
hostName:
|
||||
type: string
|
||||
inactiveHostCount:
|
||||
type: integer
|
||||
load15:
|
||||
format: double
|
||||
type: number
|
||||
memory:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
status:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
|
||||
wait:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- hostName
|
||||
- status
|
||||
- activeHostCount
|
||||
- inactiveHostCount
|
||||
- cpu
|
||||
- memory
|
||||
- wait
|
||||
- load15
|
||||
- diskUsage
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesHostStatus:
|
||||
enum:
|
||||
- active
|
||||
- inactive
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesHosts:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPodPhase:
|
||||
enum:
|
||||
- pending
|
||||
- running
|
||||
- succeeded
|
||||
- failed
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesPodRecord:
|
||||
properties:
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
podAge:
|
||||
format: int64
|
||||
type: integer
|
||||
podCPU:
|
||||
format: double
|
||||
type: number
|
||||
podCPULimit:
|
||||
format: double
|
||||
type: number
|
||||
podCPURequest:
|
||||
format: double
|
||||
type: number
|
||||
podMemory:
|
||||
format: double
|
||||
type: number
|
||||
podMemoryLimit:
|
||||
format: double
|
||||
type: number
|
||||
podMemoryRequest:
|
||||
format: double
|
||||
type: number
|
||||
podPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
|
||||
podUID:
|
||||
type: string
|
||||
type: object
|
||||
InframonitoringtypesPods:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
type: object
|
||||
InframonitoringtypesPostableHosts:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostablePods:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
InframonitoringtypesRequiredMetricsCheck:
|
||||
properties:
|
||||
missingMetrics:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- missingMetrics
|
||||
type: object
|
||||
InframonitoringtypesResponseType:
|
||||
enum:
|
||||
- list
|
||||
- grouped_list
|
||||
type: string
|
||||
MetricsexplorertypesInspectMetricsRequest:
|
||||
properties:
|
||||
end:
|
||||
@@ -9853,6 +10052,140 @@ paths:
|
||||
summary: Health check
|
||||
tags:
|
||||
- health
|
||||
/api/v2/infra_monitoring/hosts:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of hosts with key infrastructure metrics:
|
||||
CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute
|
||||
load average. Each host includes its current status (active/inactive based
|
||||
on metrics reported in the last 10 minutes) and metadata attributes (e.g.,
|
||||
os.type). Supports filtering via a filter expression, filtering by host status,
|
||||
custom groupBy to aggregate hosts by any attribute, ordering by any of the
|
||||
five metrics, and pagination via offset/limit. The response type is ''list''
|
||||
for the default host.name grouping or ''grouped_list'' for custom groupBy
|
||||
keys. Also reports missing required metrics and whether the requested time
|
||||
range falls before the data retention boundary.'
|
||||
operationId: ListHosts
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableHosts'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHosts'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/pods:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes pods with key metrics:
|
||||
CPU usage, CPU request/limit utilization, memory working set, memory request/limit
|
||||
utilization, current pod phase (pending/running/succeeded/failed), and pod
|
||||
age (ms since start time). Each pod includes metadata attributes (namespace,
|
||||
node, workload owner such as deployment/statefulset/daemonset/job/cronjob,
|
||||
cluster). Supports filtering via a filter expression, custom groupBy to aggregate
|
||||
pods by any attribute, ordering by any of the seven metrics (cpu, cpu_request,
|
||||
cpu_limit, memory, memory_request, memory_limit, phase), and pagination via
|
||||
offset/limit. The response type is ''list'' for the default k8s.pod.uid grouping
|
||||
or ''grouped_list'' for custom groupBy keys. Also reports missing required
|
||||
metrics and whether the requested time range falls before the data retention
|
||||
boundary.'
|
||||
operationId: ListPods
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostablePods'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPods'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Pods for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/livez:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -37,6 +38,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
providerSettings.MeterProvider,
|
||||
client.WithRequestResponseLog(true),
|
||||
client.WithRetryCount(3),
|
||||
client.WithTimeout(30*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.9",
|
||||
"@signozhq/ui": "0.0.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -266,4 +266,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
191
frontend/src/api/generated/services/inframonitoring/index.ts
Normal file
191
frontend/src/api/generated/services/inframonitoring/index.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
MutationFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
InframonitoringtypesPostablePodsDTO,
|
||||
ListHosts200,
|
||||
ListPods200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const listHosts = (
|
||||
inframonitoringtypesPostableHostsDTO: BodyType<InframonitoringtypesPostableHostsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListHosts200>({
|
||||
url: `/api/v2/infra_monitoring/hosts`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableHostsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListHostsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listHosts'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listHosts(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListHostsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listHosts>>
|
||||
>;
|
||||
export type ListHostsMutationBody =
|
||||
BodyType<InframonitoringtypesPostableHostsDTO>;
|
||||
export type ListHostsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const useListHosts = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listHosts>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableHostsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListHostsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the seven metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit, phase), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const listPods = (
|
||||
inframonitoringtypesPostablePodsDTO: BodyType<InframonitoringtypesPostablePodsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListPods200>({
|
||||
url: `/api/v2/infra_monitoring/pods`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostablePodsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPodsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listPods'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listPods(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListPodsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPods>>
|
||||
>;
|
||||
export type ListPodsMutationBody = BodyType<InframonitoringtypesPostablePodsDTO>;
|
||||
export type ListPodsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const useListPods = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListPodsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -3053,6 +3053,240 @@ export interface GlobaltypesTokenizerConfigDTO {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesHostFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
filterByStatus?: InframonitoringtypesHostStatusDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesHostRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesHostRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
activeHostCount: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
cpu: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
diskUsage: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
hostName: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
inactiveHostCount: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
load15: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
memory: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesHostRecordDTOMeta;
|
||||
status: InframonitoringtypesHostStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
wait: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesHostStatusDTO {
|
||||
active = 'active',
|
||||
inactive = 'inactive',
|
||||
'' = '',
|
||||
}
|
||||
export interface InframonitoringtypesHostsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesHostRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesPodPhaseDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
succeeded = 'succeeded',
|
||||
failed = 'failed',
|
||||
'' = '',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesPodRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesPodRecordDTO {
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta?: InframonitoringtypesPodRecordDTOMeta;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
podAge?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podCPU?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podCPULimit?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podCPURequest?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podMemory?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podMemoryLimit?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podMemoryRequest?: number;
|
||||
podPhase?: InframonitoringtypesPodPhaseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
podUID?: string;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention?: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records?: InframonitoringtypesPodRecordDTO[] | null;
|
||||
requiredMetricsCheck?: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total?: number;
|
||||
type?: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableHostsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: InframonitoringtypesHostFilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostablePodsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end?: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start?: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesRequiredMetricsCheckDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
missingMetrics: string[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
list = 'list',
|
||||
grouped_list = 'grouped_list',
|
||||
}
|
||||
export interface MetricsexplorertypesInspectMetricsRequestDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -6638,6 +6872,22 @@ export type Healthz503 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListHosts200 = {
|
||||
data: InframonitoringtypesHostsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPods200 = {
|
||||
data: InframonitoringtypesPodsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Livez200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
};
|
||||
return <BarChartTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -46,6 +47,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ChartWrapper({
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
canPinTooltip = false,
|
||||
pinKey,
|
||||
onClick,
|
||||
syncMode,
|
||||
syncKey,
|
||||
onDestroy = noop,
|
||||
@@ -101,6 +103,8 @@ export default function ChartWrapper({
|
||||
<TooltipPlugin
|
||||
config={config}
|
||||
canPinTooltip={canPinTooltip}
|
||||
pinKey={pinKey}
|
||||
onClick={onClick}
|
||||
syncMode={syncMode}
|
||||
maxWidth={Math.max(
|
||||
TOOLTIP_MIN_WIDTH,
|
||||
|
||||
@@ -26,10 +26,11 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
...props,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,10 +21,17 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
};
|
||||
return <TimeSeriesTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[
|
||||
customTooltip,
|
||||
rest.timezone,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,12 @@ interface BaseChartProps {
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
|
||||
@@ -121,6 +121,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
canPinTooltip
|
||||
plotRef={onPlotRef}
|
||||
onDestroy={onPlotDestroy}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
|
||||
@@ -89,6 +89,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
canPinTooltip
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
|
||||
@@ -112,6 +112,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
|
||||
@@ -1,73 +1,22 @@
|
||||
.uplot-tooltip-container {
|
||||
.container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--bg-ink-300);
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-ink-100);
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.lightMode {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.uplot-tooltip-header-container {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.uplot-tooltip-header {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-divider {
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.uplot-tooltip-list {
|
||||
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
|
||||
// horizontal offset when the scroller has padding or transform applied.
|
||||
div[data-viewport-type='element'] {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 12px 4px 16px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,28 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
import TooltipItem from './components/TooltipItem/TooltipItem';
|
||||
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
|
||||
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
|
||||
import TooltipList from './components/TooltipList/TooltipList';
|
||||
|
||||
import Styles from './Tooltip.module.scss';
|
||||
|
||||
// Fallback per-item height used for the initial size estimate before
|
||||
// Virtuoso reports the real total height via totalListHeightChanged.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
export default function Tooltip({
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
content,
|
||||
showTooltipHeader = true,
|
||||
isPinned,
|
||||
canPinTooltip,
|
||||
dismiss,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const [totalListHeight, setTotalListHeight] = useState(0);
|
||||
|
||||
const tooltipContent = useMemo(() => content ?? [], [content]);
|
||||
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() => tooltipContent.find((item) => item.isActive) ?? null,
|
||||
[tooltipContent],
|
||||
);
|
||||
|
||||
// Use the measured height from Virtuoso when available; fall back to a
|
||||
// per-item estimate on the first render. Math.ceil prevents a 1 px
|
||||
// subpixel rounding gap from triggering a spurious scrollbar.
|
||||
const virtuosoHeight = useMemo(() => {
|
||||
return totalListHeight > 0
|
||||
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
|
||||
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
|
||||
}, [totalListHeight, tooltipContent.length]);
|
||||
|
||||
const showHeader = showTooltipHeader || activeItem != null;
|
||||
// With a single series the active item is fully represented in the header —
|
||||
// hide the divider and list to avoid showing a duplicate row.
|
||||
@@ -74,46 +31,24 @@ export default function Tooltip({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
|
||||
className={cx(Styles.container, isPinned && Styles.pinned)}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
{showHeader && (
|
||||
<div className={Styles.uplotTooltipHeaderContainer}>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div
|
||||
className={Styles.uplotTooltipHeader}
|
||||
data-testid="uplot-tooltip-header"
|
||||
>
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDivider && <span className={Styles.uplotTooltipDivider} />}
|
||||
|
||||
{showList && (
|
||||
<Virtuoso
|
||||
className={Styles.uplotTooltipList}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={tooltipContent}
|
||||
style={{ height: virtuosoHeight, width: '100%' }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
<TooltipHeader
|
||||
uPlotInstance={uPlotInstance}
|
||||
timezone={timezone}
|
||||
showTooltipHeader={showTooltipHeader}
|
||||
isPinned={isPinned}
|
||||
activeItem={activeItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDivider && <span className={Styles.divider} />}
|
||||
|
||||
{showList && <TooltipList content={tooltipContent} />}
|
||||
|
||||
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -92,7 +93,6 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
viaSync: false,
|
||||
clickData: null,
|
||||
} as TooltipTestProps;
|
||||
|
||||
return render(
|
||||
@@ -191,3 +191,85 @@ describe('Tooltip', () => {
|
||||
expect(list).toHaveStyle({ height: '76px' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip footer hint', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('to pin the tooltip');
|
||||
});
|
||||
|
||||
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('Esc');
|
||||
expect(footer).toHaveTextContent('to unpin');
|
||||
});
|
||||
|
||||
it('does not render Unpin button when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Unpin button when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
expect(unpinBtn).toBeInTheDocument();
|
||||
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
|
||||
});
|
||||
|
||||
it('calls dismiss when Unpin button is clicked', async () => {
|
||||
const dismiss = jest.fn();
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
await user.click(unpinBtn);
|
||||
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('footer has role="status" for screen reader announcements', () => {
|
||||
renderTooltip({ canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByRole('status');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip header status pill', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('shows Pinned status when pinned and header is visible', () => {
|
||||
const uPlotInstance = createUPlotInstance(0);
|
||||
|
||||
renderTooltip({ uPlotInstance, isPinned: true });
|
||||
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render status pill when showTooltipHeader is false', () => {
|
||||
const uPlotInstance = createUPlotInstance(0);
|
||||
|
||||
renderTooltip({ uPlotInstance, showTooltipHeader: false, isPinned: false });
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Kbd } from '@signozhq/ui';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import Styles from './TooltipFooter.module.scss';
|
||||
|
||||
interface TooltipFooterProps {
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export default function TooltipFooter({
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
dismiss,
|
||||
}: TooltipFooterProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={Styles.footer}
|
||||
role="status"
|
||||
data-testid="uplot-tooltip-footer"
|
||||
>
|
||||
<div className={Styles.hint}>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<span>Press</span>
|
||||
<Kbd active>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>or</span>
|
||||
<Kbd active>Esc</Kbd>
|
||||
<span>to unpin</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPinned && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={dismiss}
|
||||
aria-label="Unpin tooltip"
|
||||
data-testid="uplot-tooltip-unpin"
|
||||
>
|
||||
<X size={10} />
|
||||
<span>Unpin</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.headerContainer {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--callout-primary-title);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pin } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
|
||||
import Styles from './TooltipHeader.module.scss';
|
||||
|
||||
interface TooltipHeaderProps {
|
||||
uPlotInstance: uPlot;
|
||||
timezone?: Timezone;
|
||||
showTooltipHeader: boolean;
|
||||
isPinned: boolean;
|
||||
activeItem: TooltipContentItem | null;
|
||||
}
|
||||
|
||||
export default function TooltipHeader({
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
showTooltipHeader,
|
||||
isPinned,
|
||||
activeItem,
|
||||
}: TooltipHeaderProps): JSX.Element {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={Styles.headerContainer}
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={Styles.headerRow}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
<>
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<TooltipItem
|
||||
item={activeItem}
|
||||
isItemActive={true}
|
||||
containerTestId="uplot-tooltip-pinned"
|
||||
markerTestId="uplot-tooltip-pinned-marker"
|
||||
contentTestId="uplot-tooltip-pinned-content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.list {
|
||||
width: 100%;
|
||||
:global(div[data-viewport-type='element']) {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 12px 4px 16px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.listLightMode {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
|
||||
import Styles from './TooltipList.module.scss';
|
||||
|
||||
// Fallback per-item height before Virtuoso reports the real total.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
interface TooltipListProps {
|
||||
content: TooltipContentItem[];
|
||||
}
|
||||
|
||||
export default function TooltipList({
|
||||
content,
|
||||
}: TooltipListProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [totalListHeight, setTotalListHeight] = useState(0);
|
||||
|
||||
// Use the measured height from Virtuoso when available; fall back to a
|
||||
// per-item estimate on first render. Math.ceil prevents a 1 px
|
||||
// subpixel rounding gap from triggering a spurious scrollbar.
|
||||
const height = useMemo(
|
||||
() =>
|
||||
totalListHeight > 0
|
||||
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
|
||||
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
|
||||
[totalListHeight, content.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface TooltipRenderArgs {
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.tooltip-plugin-container {
|
||||
.tooltipPluginContainer {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
@@ -10,13 +10,9 @@
|
||||
transform: translate(-1000px, -1000px); // hide the tooltip initially
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&.pinned {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
@@ -17,18 +16,21 @@ import {
|
||||
} from './tooltipController';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
TooltipClickData,
|
||||
DEFAULT_PIN_TOOLTIP_KEY,
|
||||
TooltipControllerContext,
|
||||
TooltipControllerState,
|
||||
TooltipLayoutInfo,
|
||||
TooltipPluginProps,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
import { createInitialViewState, createLayoutObserver } from './utils';
|
||||
import {
|
||||
buildClickData,
|
||||
createInitialViewState,
|
||||
createLayoutObserver,
|
||||
} from './utils';
|
||||
|
||||
import './TooltipPlugin.styles.scss';
|
||||
import Styles from './TooltipPlugin.module.scss';
|
||||
|
||||
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
|
||||
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
|
||||
// the plot – this avoids flicker when moving between nearby points.
|
||||
const HOVER_DISMISS_DELAY_MS = 100;
|
||||
@@ -44,6 +46,8 @@ export default function TooltipPlugin({
|
||||
syncMetadata,
|
||||
pinnedTooltipElement,
|
||||
canPinTooltip = false,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
onClick,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rafId = useRef<number | null>(null);
|
||||
@@ -131,8 +135,8 @@ export default function TooltipPlugin({
|
||||
// Dismiss the tooltip when the user clicks / presses a key
|
||||
// outside the tooltip container while it is pinned.
|
||||
const onOutsideInteraction = (event: Event): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
|
||||
const target = event.target as Node;
|
||||
if (!containerRef.current?.contains(target)) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
@@ -159,13 +163,14 @@ export default function TooltipPlugin({
|
||||
|
||||
// Attach / detach global listeners when pin state changes so
|
||||
// we can detect when the user interacts outside the tooltip.
|
||||
// Keyboard unpinning is handled exclusively in handleKeyDown so
|
||||
// that only P (toggle) and Escape (release) can dismiss — not
|
||||
// arbitrary keystrokes like arrow keys or Tab.
|
||||
function toggleOutsideListeners(enable: boolean): void {
|
||||
if (enable) {
|
||||
document.addEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.addEventListener('keydown', onOutsideInteraction, true);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,66 +288,84 @@ export default function TooltipPlugin({
|
||||
}
|
||||
};
|
||||
|
||||
// When pinning is enabled, a click on the plot overlay while
|
||||
// hovering converts the transient tooltip into a pinned one.
|
||||
// Uses getPlot(controller) to avoid closing over u (plot), which
|
||||
// would retain the plot and detached canvases across unmounts.
|
||||
const handleUPlotOverClick = (event: MouseEvent): void => {
|
||||
// Handles all tooltip-pin keyboard interactions:
|
||||
// Escape — always releases the tooltip when pinned (never steals Escape
|
||||
// from other handlers since we do not call stopPropagation).
|
||||
// pinKey — toggles: pins when hovering+unpinned, unpins when pinned.
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
// Escape: release-only (never toggles on).
|
||||
if (event.key === 'Escape') {
|
||||
if (controller.pinned) {
|
||||
dismissTooltip();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== pinKey.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle off: P pressed while already pinned.
|
||||
if (controller.pinned) {
|
||||
dismissTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle on: P pressed while hovering.
|
||||
const plot = getPlot(controller);
|
||||
if (
|
||||
!plot ||
|
||||
!controller.hoverActive ||
|
||||
controller.focusedSeriesIndex == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorLeft = plot.cursor.left ?? -1;
|
||||
const cursorTop = plot.cursor.top ?? -1;
|
||||
if (cursorLeft < 0 || cursorTop < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plotRect = plot.over.getBoundingClientRect();
|
||||
const syntheticEvent = ({
|
||||
clientX: plotRect.left + cursorLeft,
|
||||
clientY: plotRect.top + cursorTop,
|
||||
target: plot.over,
|
||||
offsetX: cursorLeft,
|
||||
offsetY: cursorTop,
|
||||
} as unknown) as MouseEvent;
|
||||
|
||||
controller.clickData = buildClickData(syntheticEvent, plot);
|
||||
controller.pinned = true;
|
||||
scheduleRender(true);
|
||||
};
|
||||
|
||||
// Forward overlay clicks to the consumer-provided onClick callback.
|
||||
const handleOverClick = (event: MouseEvent): void => {
|
||||
const plot = getPlot(controller);
|
||||
/**
|
||||
* Only trigger onClick if the click happened on the plot overlay and there is a focused series.
|
||||
* It also ensures that clicks only trigger onClick when there is a relevant data point (i.e. a focused series) to provide context for the click.
|
||||
*/
|
||||
if (
|
||||
plot &&
|
||||
event.target === plot.over &&
|
||||
controller.hoverActive &&
|
||||
!controller.pinned &&
|
||||
controller.focusedSeriesIndex != null
|
||||
) {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
let clickedDataTimestamp = xValue;
|
||||
if (focusedSeries) {
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
const xSeriesData = plot.data[0];
|
||||
if (
|
||||
xSeriesData &&
|
||||
dataIndex >= 0 &&
|
||||
dataIndex < xSeriesData.length &&
|
||||
xSeriesData[dataIndex] !== undefined
|
||||
) {
|
||||
clickedDataTimestamp = xSeriesData[dataIndex];
|
||||
}
|
||||
}
|
||||
|
||||
const clickData: TooltipClickData = {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
|
||||
controller.clickData = clickData;
|
||||
|
||||
setTimeout(() => {
|
||||
controller.pinned = true;
|
||||
scheduleRender(true);
|
||||
}, 0);
|
||||
const clickData = buildClickData(event, plot);
|
||||
onClick?.(clickData);
|
||||
}
|
||||
};
|
||||
|
||||
let overClickHandler: ((event: MouseEvent) => void) | null = null;
|
||||
|
||||
// Called once per uPlot instance; used to store the instance
|
||||
// on the controller and optionally attach the pinning handler.
|
||||
// Called once per uPlot instance; used to store the instance on the controller.
|
||||
const handleInit = (u: uPlot): void => {
|
||||
controller.plot = u;
|
||||
updateState({ hasPlot: true });
|
||||
if (canPinTooltip) {
|
||||
overClickHandler = handleUPlotOverClick;
|
||||
if (onClick) {
|
||||
overClickHandler = handleOverClick;
|
||||
u.over.addEventListener('click', overClickHandler);
|
||||
}
|
||||
};
|
||||
@@ -389,13 +412,18 @@ export default function TooltipPlugin({
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
if (canPinTooltip) {
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
layoutRef.current?.observer.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
if (canPinTooltip) {
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
cancelPendingRender();
|
||||
removeReadyHook();
|
||||
removeInitHook();
|
||||
@@ -405,9 +433,7 @@ export default function TooltipPlugin({
|
||||
removeSetCursorHook();
|
||||
if (overClickHandler) {
|
||||
const plot = getPlot(controller);
|
||||
if (plot) {
|
||||
plot.over.removeEventListener('click', overClickHandler);
|
||||
}
|
||||
plot?.over.removeEventListener('click', overClickHandler);
|
||||
overClickHandler = null;
|
||||
}
|
||||
clearPlotReferences();
|
||||
@@ -447,8 +473,12 @@ export default function TooltipPlugin({
|
||||
}, [isHovering, hasPlot]);
|
||||
|
||||
const tooltipBody = useMemo(() => {
|
||||
if (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
|
||||
return pinnedTooltipElement(viewState.clickData);
|
||||
if (isPinned) {
|
||||
if (pinnedTooltipElement != null && viewState.clickData != null) {
|
||||
return pinnedTooltipElement(viewState.clickData);
|
||||
}
|
||||
// No custom pinned element — keep showing the last hover contents.
|
||||
return contents ?? null;
|
||||
}
|
||||
|
||||
if (isHovering) {
|
||||
@@ -471,9 +501,8 @@ export default function TooltipPlugin({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cx('tooltip-plugin-container', {
|
||||
pinned: isPinned,
|
||||
visible: isTooltipVisible,
|
||||
className={cx(Styles.tooltipPluginContainer, {
|
||||
[Styles.visible]: isTooltipVisible,
|
||||
})}
|
||||
style={{
|
||||
...style,
|
||||
@@ -484,6 +513,7 @@ export default function TooltipPlugin({
|
||||
aria-atomic="true"
|
||||
aria-hidden={!isTooltipVisible}
|
||||
ref={containerRef}
|
||||
data-pinned={isPinned}
|
||||
data-testid="tooltip-plugin-container"
|
||||
>
|
||||
{tooltipBody}
|
||||
|
||||
@@ -102,6 +102,12 @@ export function updateHoverState(
|
||||
controller: TooltipControllerState,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): void {
|
||||
// When pinned, keep hoverActive stable so the tooltip stays visible
|
||||
// until explicitly dismissed — the cursor lock fires asynchronously
|
||||
// and setSeries/setLegend can otherwise race and clear hoverActive.
|
||||
if (controller.pinned) {
|
||||
return;
|
||||
}
|
||||
// When the cursor is driven by dashboard‑level sync, we only show
|
||||
// the tooltip if the plot is in viewport and at least one series
|
||||
// is active. Otherwise we fall back to local interaction logic.
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
|
||||
export const TOOLTIP_OFFSET = 10;
|
||||
|
||||
// Default key that pins the tooltip while hovering over the chart.
|
||||
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
|
||||
|
||||
export enum DashboardCursorSync {
|
||||
Crosshair,
|
||||
None,
|
||||
@@ -41,6 +44,10 @@ export interface TooltipSyncMetadata {
|
||||
export interface TooltipPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
canPinTooltip?: boolean;
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncMetadata?: TooltipSyncMetadata;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
|
||||
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
|
||||
import {
|
||||
TOOLTIP_OFFSET,
|
||||
TooltipClickData,
|
||||
TooltipLayoutInfo,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
|
||||
export function isPlotInViewport(
|
||||
rect: uPlot.BBox,
|
||||
@@ -158,3 +165,40 @@ export function createLayoutObserver(
|
||||
};
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
|
||||
* and the current uPlot instance. Shared by the overlay click handler and the
|
||||
* keyboard-pin handler (which synthesises an event from the cursor position).
|
||||
*/
|
||||
export function buildClickData(
|
||||
event: MouseEvent,
|
||||
plot: uPlot,
|
||||
): TooltipClickData {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
let clickedDataTimestamp = xValue;
|
||||
const xSeriesData = plot.data[0];
|
||||
if (
|
||||
xSeriesData &&
|
||||
dataIndex >= 0 &&
|
||||
dataIndex < xSeriesData.length &&
|
||||
xSeriesData[dataIndex] !== undefined
|
||||
) {
|
||||
clickedDataTimestamp = xSeriesData[dataIndex];
|
||||
}
|
||||
|
||||
return {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import type uPlot from 'uplot';
|
||||
import { TooltipRenderArgs } from '../../components/types';
|
||||
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
|
||||
import { DashboardCursorSync } from '../TooltipPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DEFAULT_PIN_TOOLTIP_KEY,
|
||||
} from '../TooltipPlugin/types';
|
||||
|
||||
// Avoid depending on the full uPlot + onClickPlugin behaviour in these tests.
|
||||
// We only care that pinning logic runs without throwing, not which series is focused.
|
||||
@@ -60,7 +63,7 @@ function getHandler(config: ConfigMock, hookName: string): HookHandler {
|
||||
function createFakePlot(): {
|
||||
over: HTMLDivElement;
|
||||
setCursor: jest.Mock<void, [uPlot.Cursor]>;
|
||||
cursor: { event: Record<string, unknown> };
|
||||
cursor: { event: Record<string, unknown>; left: number; top: number };
|
||||
posToVal: jest.Mock<number, [value: number]>;
|
||||
posToIdx: jest.Mock<number, []>;
|
||||
data: [number[], number[]];
|
||||
@@ -71,7 +74,9 @@ function createFakePlot(): {
|
||||
return {
|
||||
over,
|
||||
setCursor: jest.fn(),
|
||||
cursor: { event: {} },
|
||||
// left / top are set to valid values so keyboard-pin tests do not
|
||||
// hit the "cursor off-screen" guard inside handleKeyDown.
|
||||
cursor: { event: {}, left: 50, top: 50 },
|
||||
// In real uPlot these map overlay coordinates to data-space values.
|
||||
posToVal: jest.fn((value: number) => value),
|
||||
posToIdx: jest.fn(() => 0),
|
||||
@@ -144,7 +149,7 @@ describe('TooltipPlugin', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
|
||||
expect(screen.queryByTestId('tooltip-plugin-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('registers all required uPlot hooks on mount', () => {
|
||||
@@ -182,9 +187,7 @@ describe('TooltipPlugin', () => {
|
||||
expect(renderTooltip).toHaveBeenCalled();
|
||||
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
|
||||
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement;
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.parentElement).toBe(document.body);
|
||||
});
|
||||
@@ -203,9 +206,7 @@ describe('TooltipPlugin', () => {
|
||||
|
||||
renderAndActivateHover(config);
|
||||
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement;
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.parentElement).toBe(document.body);
|
||||
|
||||
const fullscreenRoot = document.createElement('div');
|
||||
@@ -245,24 +246,27 @@ describe('TooltipPlugin', () => {
|
||||
// ---- Pin behaviour ----------------------------------------------------------
|
||||
|
||||
describe('pin behaviour', () => {
|
||||
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
|
||||
it('pins the tooltip when canPinTooltip is true and the pinKey is pressed while hovering', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
const fakePlot = renderAndActivateHover(config, undefined, {
|
||||
canPinTooltip: true,
|
||||
});
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return waitFor(() => {
|
||||
const updated = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(updated).toBeInTheDocument();
|
||||
expect(updated.classList.contains('pinned')).toBe(true);
|
||||
expect(updated.getAttribute('data-pinned') === 'true').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +276,7 @@ describe('TooltipPlugin', () => {
|
||||
React.createElement('div', null, 'pinned-tooltip'),
|
||||
);
|
||||
|
||||
const fakePlot = renderAndActivateHover(
|
||||
renderAndActivateHover(
|
||||
config,
|
||||
() => React.createElement('div', null, 'hover-tooltip'),
|
||||
{
|
||||
@@ -284,7 +288,12 @@ describe('TooltipPlugin', () => {
|
||||
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -318,18 +327,20 @@ describe('TooltipPlugin', () => {
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
});
|
||||
|
||||
// Pin the tooltip.
|
||||
// Pin the tooltip via the keyboard shortcut.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Wait until the tooltip is actually pinned (pointer events enabled)
|
||||
// Wait until the tooltip is actually pinned.
|
||||
await waitFor(() => {
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement | null;
|
||||
expect(container).not.toBeNull();
|
||||
expect(container?.classList.contains('pinned')).toBe(true);
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(true);
|
||||
});
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
@@ -342,8 +353,7 @@ describe('TooltipPlugin', () => {
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -369,16 +379,21 @@ describe('TooltipPlugin', () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Pin.
|
||||
// Pin via keyboard.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
(document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement)?.classList.contains('pinned'),
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
|
||||
// Simulate data update – should dismiss the pinned tooltip.
|
||||
@@ -390,8 +405,7 @@ describe('TooltipPlugin', () => {
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -417,15 +431,21 @@ describe('TooltipPlugin', () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Pin via keyboard.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('.tooltip-plugin-container')
|
||||
?.classList.contains('pinned'),
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
|
||||
// Click outside the tooltip container.
|
||||
@@ -439,14 +459,13 @@ describe('TooltipPlugin', () => {
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('unpins the tooltip on outside keydown', async () => {
|
||||
it('unpins the tooltip when Escape is pressed while pinned', async () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
@@ -467,18 +486,24 @@ describe('TooltipPlugin', () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Pin via keyboard.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('.tooltip-plugin-container')
|
||||
?.classList.contains('pinned'),
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
|
||||
// Press a key outside the tooltip.
|
||||
// Press Escape to release.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
@@ -490,12 +515,282 @@ describe('TooltipPlugin', () => {
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(container.classList.contains('visible')).toBe(false);
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('unpins the tooltip when the pin key is pressed a second time (toggle off)', async () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
jest.runAllTimers();
|
||||
|
||||
// First press — pin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Second press — unpin (toggle off).
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not unpin on Escape when tooltip is not pinned', () => {
|
||||
const config = createConfigMock();
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
|
||||
// Escape without pinning first — should be a no-op.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
// Tooltip should still be hovering (visible), not dismissed.
|
||||
expect(container.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
it('does not unpin on arbitrary keys that are not Escape or the pin key', async () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
renderAndActivateHover(config, undefined, { canPinTooltip: true });
|
||||
jest.runAllTimers();
|
||||
|
||||
// Pin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Arrow key — should NOT unpin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Keyboard pin edge cases ------------------------------------------------
|
||||
|
||||
describe('keyboard pin edge cases', () => {
|
||||
it('does not pin when cursor coordinates are negative (cursor off-screen)', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => React.createElement('div', null, 'tooltip-body'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Negative cursor coords — handleKeyDown bails out before pinning.
|
||||
const fakePlot = {
|
||||
...createFakePlot(),
|
||||
cursor: { event: {}, left: -1, top: -1 },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
});
|
||||
|
||||
it('does not pin when hover is not active', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => React.createElement('div', null, 'tooltip-body'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const fakePlot = createFakePlot();
|
||||
|
||||
act(() => {
|
||||
// Initialise the plot but do NOT call setSeries – hoverActive stays false.
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: DEFAULT_PIN_TOOLTIP_KEY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The container exists once the plot is initialised, but it should
|
||||
// be hidden and not pinned since hover was never activated.
|
||||
const container = screen.getByTestId('tooltip-plugin-container');
|
||||
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
|
||||
expect(container.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('ignores other keys and only pins on the configured pinKey', async () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
renderAndActivateHover(config, undefined, {
|
||||
canPinTooltip: true,
|
||||
pinKey: 'p',
|
||||
});
|
||||
|
||||
// 'l' should NOT pin when pinKey is 'p'.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'l',
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Custom pin key 'p' SHOULD pin.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'p', bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('tooltip-plugin-container')
|
||||
.getAttribute('data-pinned') === 'true',
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not register a keydown listener when canPinTooltip is false', () => {
|
||||
const config = createConfigMock();
|
||||
const addSpy = jest.spyOn(document, 'addEventListener');
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const keydownCalls = addSpy.mock.calls.filter(
|
||||
([type]) => type === 'keydown',
|
||||
);
|
||||
expect(keydownCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('removes the keydown pin listener on unmount', () => {
|
||||
const config = createConfigMock();
|
||||
const addSpy = jest.spyOn(document, 'addEventListener');
|
||||
const removeSpy = jest.spyOn(document, 'removeEventListener');
|
||||
|
||||
const { unmount } = render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const pinListenerCall = addSpy.mock.calls.find(
|
||||
([type]) => type === 'keydown',
|
||||
);
|
||||
expect(pinListenerCall).toBeDefined();
|
||||
if (!pinListenerCall) {
|
||||
return;
|
||||
}
|
||||
const [, pinListener, pinOptions] = pinListenerCall;
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', pinListener, pinOptions);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cursor sync ------------------------------------------------------------
|
||||
|
||||
@@ -5593,10 +5593,10 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/ui@0.0.9":
|
||||
version "0.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.9.tgz#e00f2ec86c5528eea91d1669510a702c4253de0d"
|
||||
integrity sha512-L9DV0OF69Z2sMnxwPEGpSTiDxI/liT6+QfzngGVnxMl/9t3WKcPr1/p8dbPOcAS6bU9lQEBOiYlsoIejM8DsXw==
|
||||
"@signozhq/ui@0.0.10":
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
|
||||
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
|
||||
dependencies:
|
||||
"@chenglou/pretext" "^0.0.5"
|
||||
"@radix-ui/react-checkbox" "^1.2.3"
|
||||
|
||||
1
go.mod
1
go.mod
@@ -66,6 +66,7 @@ require (
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.9
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
|
||||
github.com/uptrace/bun/extra/bunotel v1.2.9
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
go.opentelemetry.io/collector/confmap v1.51.0
|
||||
go.opentelemetry.io/collector/otelcol v0.144.0
|
||||
go.opentelemetry.io/collector/pdata v1.51.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1153,6 +1153,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
|
||||
|
||||
52
pkg/apiserver/signozapiserver/inframonitoring.go
Normal file
52
pkg/apiserver/signozapiserver/inframonitoring.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/infra_monitoring/hosts", handler.New(
|
||||
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListHosts),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListHosts",
|
||||
Tags: []string{"inframonitoring"},
|
||||
Summary: "List Hosts for Infra Monitoring",
|
||||
Description: "Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.",
|
||||
Request: new(inframonitoringtypes.PostableHosts),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(inframonitoringtypes.Hosts),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/infra_monitoring/pods", handler.New(
|
||||
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListPods),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListPods",
|
||||
Tags: []string{"inframonitoring"},
|
||||
Summary: "List Pods for Infra Monitoring",
|
||||
Description: "Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the seven metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit, phase), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.",
|
||||
Request: new(inframonitoringtypes.PostablePods),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(inframonitoringtypes.Pods),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
@@ -49,6 +50,7 @@ type provider struct {
|
||||
dashboardModule dashboard.Module
|
||||
dashboardHandler dashboard.Handler
|
||||
metricsExplorerHandler metricsexplorer.Handler
|
||||
infraMonitoringHandler inframonitoring.Handler
|
||||
gatewayHandler gateway.Handler
|
||||
fieldsHandler fields.Handler
|
||||
authzHandler authz.Handler
|
||||
@@ -77,6 +79,7 @@ func NewFactory(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
authzHandler authz.Handler,
|
||||
@@ -108,6 +111,7 @@ func NewFactory(
|
||||
dashboardModule,
|
||||
dashboardHandler,
|
||||
metricsExplorerHandler,
|
||||
infraMonitoringHandler,
|
||||
gatewayHandler,
|
||||
fieldsHandler,
|
||||
authzHandler,
|
||||
@@ -141,6 +145,7 @@ func newProvider(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
authzHandler authz.Handler,
|
||||
@@ -172,6 +177,7 @@ func newProvider(
|
||||
dashboardModule: dashboardModule,
|
||||
dashboardHandler: dashboardHandler,
|
||||
metricsExplorerHandler: metricsExplorerHandler,
|
||||
infraMonitoringHandler: infraMonitoringHandler,
|
||||
gatewayHandler: gatewayHandler,
|
||||
fieldsHandler: fieldsHandler,
|
||||
authzHandler: authzHandler,
|
||||
@@ -240,6 +246,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addInfraMonitoringRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addGatewayRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
33
pkg/modules/inframonitoring/config.go
Normal file
33
pkg/modules/inframonitoring/config.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package inframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TelemetryStore TelemetryStoreConfig `mapstructure:"telemetrystore"`
|
||||
}
|
||||
|
||||
type TelemetryStoreConfig struct {
|
||||
Threads int `mapstructure:"threads"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("inframonitoring"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
TelemetryStore: TelemetryStoreConfig{
|
||||
Threads: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.TelemetryStore.Threads <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "inframonitoring.telemetrystore.threads must be positive, got %d", c.TelemetryStore.Threads)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
71
pkg/modules/inframonitoring/implinframonitoring/handler.go
Normal file
71
pkg/modules/inframonitoring/implinframonitoring/handler.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module inframonitoring.Module
|
||||
}
|
||||
|
||||
// NewHandler returns an inframonitoring.Handler implementation.
|
||||
func NewHandler(m inframonitoring.Module) inframonitoring.Handler {
|
||||
return &handler{
|
||||
module: m,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) ListHosts(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var parsedReq inframonitoringtypes.PostableHosts
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListHosts(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) ListPods(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var parsedReq inframonitoringtypes.PostablePods
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListPods(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
573
pkg/modules/inframonitoring/implinframonitoring/helpers.go
Normal file
573
pkg/modules/inframonitoring/implinframonitoring/helpers.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// quoteIdentifier wraps s in backticks for use as a ClickHouse identifier,
|
||||
// escaping any embedded backticks by doubling them.
|
||||
func quoteIdentifier(s string) string {
|
||||
return fmt.Sprintf("`%s`", strings.ReplaceAll(s, "`", "``"))
|
||||
}
|
||||
|
||||
func isKeyInGroupByAttrs(groupByAttrs []qbtypes.GroupByKey, key string) bool {
|
||||
for _, groupBy := range groupByAttrs {
|
||||
if groupBy.Name == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeFilterExpressions(queryFilterExpr, reqFilterExpr string) string {
|
||||
queryFilterExpr = strings.TrimSpace(queryFilterExpr)
|
||||
reqFilterExpr = strings.TrimSpace(reqFilterExpr)
|
||||
if queryFilterExpr == "" {
|
||||
return reqFilterExpr
|
||||
}
|
||||
if reqFilterExpr == "" {
|
||||
return queryFilterExpr
|
||||
}
|
||||
return fmt.Sprintf("(%s) AND (%s)", queryFilterExpr, reqFilterExpr)
|
||||
}
|
||||
|
||||
// compositeKeyFromList builds a composite key by joining the given parts
|
||||
// with a null byte separator. This is the canonical way to construct
|
||||
// composite keys for group identification across the infra monitoring module.
|
||||
func compositeKeyFromList(parts []string) string {
|
||||
return strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
// compositeKeyFromLabels builds a composite key from a label map by extracting
|
||||
// the value for each groupBy key in order and joining them via compositeKeyFromList.
|
||||
func compositeKeyFromLabels(labels map[string]string, groupBy []qbtypes.GroupByKey) string {
|
||||
parts := make([]string, len(groupBy))
|
||||
for i, key := range groupBy {
|
||||
parts[i] = labels[key.Name]
|
||||
}
|
||||
return compositeKeyFromList(parts)
|
||||
}
|
||||
|
||||
// parseAndSortGroups extracts group label maps from a ScalarData response and
|
||||
// sorts them by the ranking query's aggregation value.
|
||||
func parseAndSortGroups(
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
rankingQueryName string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
direction qbtypes.OrderDirection,
|
||||
) []rankedGroup {
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the ScalarData that contains the ranking column.
|
||||
var sd *qbtypes.ScalarData
|
||||
for _, r := range resp.Data.Results {
|
||||
candidate, ok := r.(*qbtypes.ScalarData)
|
||||
if !ok || candidate == nil {
|
||||
continue
|
||||
}
|
||||
for _, col := range candidate.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
|
||||
sd = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if sd != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sd == nil || len(sd.Data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupColIndices := make(map[string]int)
|
||||
rankingColIdx := -1
|
||||
for i, col := range sd.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeGroup {
|
||||
groupColIndices[col.Name] = i
|
||||
}
|
||||
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
|
||||
rankingColIdx = i
|
||||
}
|
||||
}
|
||||
if rankingColIdx == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := make([]rankedGroup, 0, len(sd.Data))
|
||||
for _, row := range sd.Data {
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
|
||||
labels[key.Name] = fmt.Sprintf("%v", row[idx])
|
||||
}
|
||||
}
|
||||
var value float64
|
||||
if rankingColIdx < len(row) {
|
||||
if v, ok := row[rankingColIdx].(float64); ok {
|
||||
value = v
|
||||
}
|
||||
}
|
||||
groups = append(groups, rankedGroup{
|
||||
labels: labels,
|
||||
value: value,
|
||||
compositeKey: compositeKeyFromLabels(labels, groupBy),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
if groups[i].value != groups[j].value {
|
||||
if direction == qbtypes.OrderDirectionAsc {
|
||||
return groups[i].value < groups[j].value
|
||||
}
|
||||
return groups[i].value > groups[j].value
|
||||
}
|
||||
return groups[i].compositeKey < groups[j].compositeKey
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// paginateWithBackfill returns the page of groups for [offset, offset+limit).
|
||||
// The virtual sorted list is: metric-ranked groups first, then metadata-only
|
||||
// groups (those in metadataMap but not in metric results) sorted alphabetically.
|
||||
func paginateWithBackfill(
|
||||
metricGroups []rankedGroup,
|
||||
metadataMap map[string]map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
offset, limit int,
|
||||
) []map[string]string {
|
||||
metricKeySet := make(map[string]bool, len(metricGroups))
|
||||
for _, g := range metricGroups {
|
||||
metricKeySet[g.compositeKey] = true
|
||||
}
|
||||
|
||||
metadataOnlyKeys := make([]string, 0)
|
||||
for compositeKey := range metadataMap {
|
||||
if !metricKeySet[compositeKey] {
|
||||
metadataOnlyKeys = append(metadataOnlyKeys, compositeKey)
|
||||
}
|
||||
}
|
||||
sort.Strings(metadataOnlyKeys)
|
||||
|
||||
totalMetric := len(metricGroups)
|
||||
totalAll := totalMetric + len(metadataOnlyKeys)
|
||||
|
||||
end := offset + limit
|
||||
if end > totalAll {
|
||||
end = totalAll
|
||||
}
|
||||
if offset >= totalAll {
|
||||
return nil
|
||||
}
|
||||
|
||||
pageGroups := make([]map[string]string, 0, end-offset)
|
||||
for i := offset; i < end; i++ {
|
||||
if i < totalMetric {
|
||||
pageGroups = append(pageGroups, metricGroups[i].labels)
|
||||
} else {
|
||||
compositeKey := metadataOnlyKeys[i-totalMetric]
|
||||
attrs := metadataMap[compositeKey]
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
labels[key.Name] = attrs[key.Name]
|
||||
}
|
||||
pageGroups = append(pageGroups, labels)
|
||||
}
|
||||
}
|
||||
return pageGroups
|
||||
}
|
||||
|
||||
// buildPageGroupsFilterExpr builds a filter expression that restricts results
|
||||
// to the given page of groups via IN clauses.
|
||||
// Returns e.g. "host.name IN ('h1','h2') AND os.type IN ('linux','windows')".
|
||||
func buildPageGroupsFilterExpr(pageGroups []map[string]string) string {
|
||||
groupValues := make(map[string][]string)
|
||||
for _, labels := range pageGroups {
|
||||
for k, v := range labels {
|
||||
groupValues[k] = append(groupValues[k], v)
|
||||
}
|
||||
}
|
||||
|
||||
inClauses := make([]string, 0, len(groupValues))
|
||||
for key, values := range groupValues {
|
||||
quoted := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
quoted[i] = fmt.Sprintf("'%s'", v)
|
||||
}
|
||||
inClauses = append(inClauses, fmt.Sprintf("%s IN (%s)", key, strings.Join(quoted, ", ")))
|
||||
}
|
||||
return strings.Join(inClauses, " AND ")
|
||||
}
|
||||
|
||||
// buildFullQueryRequest creates a QueryRangeRequest for all metrics,
|
||||
// restricted to the given page of groups via an IN filter.
|
||||
// Accepts primitive fields so it can be reused across different v2 APIs
|
||||
// (hosts, pods, etc.).
|
||||
func buildFullQueryRequest(
|
||||
start int64,
|
||||
end int64,
|
||||
filterExpr string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
tableListQuery *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.QueryRangeRequest {
|
||||
inFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
|
||||
fullReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(tableListQuery.CompositeQuery.Queries)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range tableListQuery.CompositeQuery.Queries {
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, filterExpr)
|
||||
merged = mergeFilterExpressions(merged, inFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(groupBy)
|
||||
}
|
||||
fullReq.CompositeQuery.Queries = append(fullReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
return fullReq
|
||||
}
|
||||
|
||||
// parseFullQueryResponse extracts per-group metric values from the full
|
||||
// composite query response. Returns compositeKey -> (queryName -> value).
|
||||
// Each enabled query/formula produces its own ScalarData entry in Results,
|
||||
// so we iterate over all of them and merge metrics per composite key.
|
||||
func parseFullQueryResponse(
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
) map[string]map[string]float64 {
|
||||
result := make(map[string]map[string]float64)
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, r := range resp.Data.Results {
|
||||
sd, ok := r.(*qbtypes.ScalarData)
|
||||
if !ok || sd == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
groupColIndices := make(map[string]int)
|
||||
aggCols := make(map[int]string) // col index -> query name
|
||||
for i, col := range sd.Columns {
|
||||
if col.Type == qbtypes.ColumnTypeGroup {
|
||||
groupColIndices[col.Name] = i
|
||||
}
|
||||
if col.Type == qbtypes.ColumnTypeAggregation {
|
||||
aggCols[i] = col.QueryName
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range sd.Data {
|
||||
labels := make(map[string]string, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
|
||||
labels[key.Name] = fmt.Sprintf("%v", row[idx])
|
||||
}
|
||||
}
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
|
||||
if result[compositeKey] == nil {
|
||||
result[compositeKey] = make(map[string]float64)
|
||||
}
|
||||
for idx, queryName := range aggCols {
|
||||
if idx < len(row) {
|
||||
if v, ok := row[idx].(float64); ok {
|
||||
result[compositeKey][queryName] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildSamplesTblFingerprintSubQuery returns a SelectBuilder that selects distinct fingerprints
|
||||
// from the samples table for the given metric names andtime range.
|
||||
func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, startMs, endMs int64) *sqlbuilder.SelectBuilder {
|
||||
samplesTableName := telemetrymetrics.WhichSamplesTableToUse(
|
||||
uint64(startMs), uint64(endMs),
|
||||
metrictypes.UnspecifiedType,
|
||||
metrictypes.TimeAggregationUnspecified,
|
||||
nil,
|
||||
)
|
||||
localSamplesTable := strings.TrimPrefix(samplesTableName, "distributed_")
|
||||
fpSB := sqlbuilder.NewSelectBuilder()
|
||||
fpSB.Select("DISTINCT fingerprint")
|
||||
fpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localSamplesTable))
|
||||
fpSB.Where(
|
||||
fpSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
fpSB.GE("unix_milli", startMs),
|
||||
fpSB.L("unix_milli", endMs),
|
||||
)
|
||||
return fpSB
|
||||
}
|
||||
|
||||
func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := ""
|
||||
if filter != nil {
|
||||
expression = strings.TrimSpace(filter.Expression)
|
||||
}
|
||||
if expression == "" {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
|
||||
for idx := range whereClauseSelectors {
|
||||
whereClauseSelectors[idx].Signal = telemetrytypes.SignalMetrics
|
||||
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||
}
|
||||
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, whereClauseSelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Logger: m.logger,
|
||||
FieldMapper: m.fieldMapper,
|
||||
ConditionBuilder: m.condBuilder,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
FieldKeys: keys,
|
||||
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
|
||||
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),
|
||||
}
|
||||
|
||||
whereClause, err := querybuilder.PrepareWhereClause(expression, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if whereClause == nil || whereClause.WhereClause == nil {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
return whereClause.WhereClause, nil
|
||||
}
|
||||
|
||||
// NOTE: this method is not specific to infra monitoring — it queries attributes_metadata generically.
|
||||
// Consider moving to telemetryMetaStore when a second use case emerges.
|
||||
//
|
||||
// getMetricsExistenceAndEarliestTime checks which of the given metric names have been
|
||||
// reported. It returns a list of missing metrics (those not found or with zero count)
|
||||
// and the earliest first-reported timestamp across all present metrics.
|
||||
// When all metrics are missing, minFirstReportedUnixMilli is 0.
|
||||
// TODO(nikhilmantri0902, srikanthccv): This method was designed this way because querier errors if any of the metrics
|
||||
// in the querier list was never sent, the QueryRange call throws not found error. Modify this method, if QueryRange
|
||||
// behaviour changes towards this.
|
||||
func (m *module) getMetricsExistenceAndEarliestTime(ctx context.Context, metricNames []string) ([]string, uint64, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "count(*) AS cnt", "min(first_reported_unix_milli) AS min_first_reported")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type metricInfo struct {
|
||||
count uint64
|
||||
minFirstReported uint64
|
||||
}
|
||||
found := make(map[string]metricInfo, len(metricNames))
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var cnt, minFR uint64
|
||||
if err := rows.Scan(&name, &cnt, &minFR); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
found[name] = metricInfo{count: cnt, minFirstReported: minFR}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var missingMetrics []string
|
||||
var globalMinFirstReported uint64
|
||||
for _, name := range metricNames {
|
||||
info, ok := found[name]
|
||||
if !ok || info.count == 0 {
|
||||
missingMetrics = append(missingMetrics, name)
|
||||
continue
|
||||
}
|
||||
if globalMinFirstReported == 0 || info.minFirstReported < globalMinFirstReported {
|
||||
globalMinFirstReported = info.minFirstReported
|
||||
}
|
||||
}
|
||||
|
||||
return missingMetrics, globalMinFirstReported, nil
|
||||
}
|
||||
|
||||
// getMetadata fetches the latest values of additionalCols for each unique combination of groupBy keys,
|
||||
// within the given time range and metric names. It uses argMax(tuple(...), unix_milli) to ensure
|
||||
// we always pick attribute values from the latest timestamp for each group.
|
||||
// The returned map has a composite key of groupBy column values joined by "\x00" (null byte),
|
||||
// mapping to a flat map of attr_name -> attr_value (includes both groupBy and additional cols).
|
||||
func (m *module) getMetadata(
|
||||
ctx context.Context,
|
||||
metricNames []string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
additionalCols []string,
|
||||
filter *qbtypes.Filter,
|
||||
startMs, endMs int64,
|
||||
) (map[string]map[string]string, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricNames must not be empty")
|
||||
}
|
||||
if len(groupBy) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "groupBy must not be empty")
|
||||
}
|
||||
|
||||
// Pick the optimal timeseries table based on time range; also get adjusted start.
|
||||
adjustedStart, adjustedEnd, distributedTableName, _ := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(startMs), uint64(endMs), nil,
|
||||
)
|
||||
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, startMs, endMs)
|
||||
|
||||
// Flatten groupBy keys to string names for SQL expressions and result scanning.
|
||||
groupByCols := make([]string, len(groupBy))
|
||||
for i, key := range groupBy {
|
||||
groupByCols[i] = key.Name
|
||||
}
|
||||
allCols := append(groupByCols, additionalCols...)
|
||||
|
||||
// --- Build inner query ---
|
||||
innerSB := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Inner SELECT columns: JSONExtractString for each groupBy col + argMax(tuple(...)) for additional cols
|
||||
innerSelectCols := make([]string, 0, len(groupByCols)+1)
|
||||
for _, col := range groupByCols {
|
||||
innerSelectCols = append(innerSelectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", innerSB.Var(col), quoteIdentifier(col)),
|
||||
)
|
||||
}
|
||||
|
||||
// Build the argMax(tuple(...), unix_milli) expression for all additional cols
|
||||
if len(additionalCols) > 0 {
|
||||
tupleArgs := make([]string, 0, len(additionalCols))
|
||||
for _, col := range additionalCols {
|
||||
tupleArgs = append(tupleArgs, fmt.Sprintf("JSONExtractString(labels, %s)", innerSB.Var(col)))
|
||||
}
|
||||
innerSelectCols = append(innerSelectCols,
|
||||
fmt.Sprintf("argMax(tuple(%s), unix_milli) AS latest_attrs", strings.Join(tupleArgs, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
innerSB.Select(innerSelectCols...)
|
||||
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
innerSB.Where(
|
||||
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
innerSB.GE("unix_milli", adjustedStart),
|
||||
innerSB.L("unix_milli", adjustedEnd),
|
||||
fmt.Sprintf("fingerprint IN (%s)", innerSB.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply optional filter expression
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
innerSB.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
groupByAliases := make([]string, 0, len(groupByCols))
|
||||
for _, col := range groupByCols {
|
||||
groupByAliases = append(groupByAliases, quoteIdentifier(col))
|
||||
}
|
||||
innerSB.GroupBy(groupByAliases...)
|
||||
|
||||
innerQuery, innerArgs := innerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// --- Build outer query ---
|
||||
// Outer SELECT columns: groupBy cols directly + tupleElement(latest_attrs, N) for each additionalCol
|
||||
outerSelectCols := make([]string, 0, len(allCols))
|
||||
for _, col := range groupByCols {
|
||||
outerSelectCols = append(outerSelectCols, quoteIdentifier(col))
|
||||
}
|
||||
for i, col := range additionalCols {
|
||||
outerSelectCols = append(outerSelectCols,
|
||||
fmt.Sprintf("tupleElement(latest_attrs, %d) AS %s", i+1, quoteIdentifier(col)),
|
||||
)
|
||||
}
|
||||
|
||||
outerSB := sqlbuilder.NewSelectBuilder()
|
||||
outerSB.Select(outerSelectCols...)
|
||||
outerSB.From(fmt.Sprintf("(%s)", innerQuery))
|
||||
|
||||
outerQuery, _ := outerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// All ? params are in innerArgs; outer query introduces none of its own.
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, outerQuery, innerArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]map[string]string)
|
||||
|
||||
for rows.Next() {
|
||||
row := make([]string, len(allCols))
|
||||
scanPtrs := make([]any, len(row))
|
||||
for i := range row {
|
||||
scanPtrs[i] = &row[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
compositeKey := compositeKeyFromList(row[:len(groupByCols)])
|
||||
|
||||
attrMap := make(map[string]string, len(allCols))
|
||||
for i, col := range allCols {
|
||||
attrMap[col] = row[i]
|
||||
}
|
||||
result[compositeKey] = attrMap
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
283
pkg/modules/inframonitoring/implinframonitoring/helpers_test.go
Normal file
283
pkg/modules/inframonitoring/implinframonitoring/helpers_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func groupByKey(name string) qbtypes.GroupByKey {
|
||||
return qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKeyInGroupByAttrs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
groupByAttrs []qbtypes.GroupByKey
|
||||
key string
|
||||
expectedFound bool
|
||||
}{
|
||||
{
|
||||
name: "key present in single-element list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "host.name",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key present in multi-element list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host.name"),
|
||||
groupByKey("os.type"),
|
||||
groupByKey("k8s.cluster.name"),
|
||||
},
|
||||
key: "os.type",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key at last position",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host.name"),
|
||||
groupByKey("os.type"),
|
||||
},
|
||||
key: "os.type",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "key not in list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "os.type",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty group by list",
|
||||
groupByAttrs: []qbtypes.GroupByKey{},
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "nil group by list",
|
||||
groupByAttrs: nil,
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty key string",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
key: "",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty key matches empty-named group by key",
|
||||
groupByAttrs: []qbtypes.GroupByKey{groupByKey("")},
|
||||
key: "",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "partial match does not count",
|
||||
groupByAttrs: []qbtypes.GroupByKey{
|
||||
groupByKey("host"),
|
||||
},
|
||||
key: "host.name",
|
||||
expectedFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isKeyInGroupByAttrs(tt.groupByAttrs, tt.key)
|
||||
if got != tt.expectedFound {
|
||||
t.Errorf("isKeyInGroupByAttrs(%v, %q) = %v, want %v",
|
||||
tt.groupByAttrs, tt.key, got, tt.expectedFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFilterExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryFilterExpr string
|
||||
reqFilterExpr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "both non-empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "(cpu > 50) AND (host.name = 'web-1')",
|
||||
},
|
||||
{
|
||||
name: "query empty, req non-empty",
|
||||
queryFilterExpr: "",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "host.name = 'web-1'",
|
||||
},
|
||||
{
|
||||
name: "query non-empty, req empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: "",
|
||||
expected: "cpu > 50",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
queryFilterExpr: "",
|
||||
reqFilterExpr: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only query treated as empty",
|
||||
queryFilterExpr: " ",
|
||||
reqFilterExpr: "host.name = 'web-1'",
|
||||
expected: "host.name = 'web-1'",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only req treated as empty",
|
||||
queryFilterExpr: "cpu > 50",
|
||||
reqFilterExpr: " ",
|
||||
expected: "cpu > 50",
|
||||
},
|
||||
{
|
||||
name: "both whitespace-only",
|
||||
queryFilterExpr: " ",
|
||||
reqFilterExpr: " ",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "leading/trailing whitespace trimmed before merge",
|
||||
queryFilterExpr: " cpu > 50 ",
|
||||
reqFilterExpr: " mem < 80 ",
|
||||
expected: "(cpu > 50) AND (mem < 80)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeFilterExpressions(tt.queryFilterExpr, tt.reqFilterExpr)
|
||||
if got != tt.expected {
|
||||
t.Errorf("mergeFilterExpressions(%q, %q) = %q, want %q",
|
||||
tt.queryFilterExpr, tt.reqFilterExpr, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeKeyFromList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parts []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single part",
|
||||
parts: []string{"web-1"},
|
||||
expected: "web-1",
|
||||
},
|
||||
{
|
||||
name: "multiple parts joined with null separator",
|
||||
parts: []string{"web-1", "linux", "us-east"},
|
||||
expected: "web-1\x00linux\x00us-east",
|
||||
},
|
||||
{
|
||||
name: "empty slice returns empty string",
|
||||
parts: []string{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nil slice returns empty string",
|
||||
parts: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "parts with empty strings",
|
||||
parts: []string{"web-1", "", "us-east"},
|
||||
expected: "web-1\x00\x00us-east",
|
||||
},
|
||||
{
|
||||
name: "all empty strings",
|
||||
parts: []string{"", ""},
|
||||
expected: "\x00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromList(tt.parts)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromList(%v) = %q, want %q",
|
||||
tt.parts, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
groupBy []qbtypes.GroupByKey
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single group-by key",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "web-1",
|
||||
},
|
||||
{
|
||||
name: "multiple group-by keys joined with null separator",
|
||||
labels: map[string]string{
|
||||
"host.name": "web-1",
|
||||
"os.type": "linux",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
|
||||
expected: "web-1\x00linux",
|
||||
},
|
||||
{
|
||||
name: "missing label yields empty segment",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
|
||||
expected: "web-1\x00",
|
||||
},
|
||||
{
|
||||
name: "empty labels map",
|
||||
labels: map[string]string{},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty group-by slice",
|
||||
labels: map[string]string{"host.name": "web-1"},
|
||||
groupBy: []qbtypes.GroupByKey{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nil labels map",
|
||||
labels: nil,
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "order matches group-by order, not map iteration order",
|
||||
labels: map[string]string{
|
||||
"z": "last",
|
||||
"a": "first",
|
||||
"m": "middle",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{groupByKey("a"), groupByKey("m"), groupByKey("z")},
|
||||
expected: "first\x00middle\x00last",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromLabels(tt.labels, tt.groupBy)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromLabels(%v, %v) = %q, want %q",
|
||||
tt.labels, tt.groupBy, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
354
pkg/modules/inframonitoring/implinframonitoring/hosts.go
Normal file
354
pkg/modules/inframonitoring/implinframonitoring/hosts.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// getPerGroupHostStatusCounts computes the number of active and inactive hosts per group
|
||||
// for the current page. It queries the timeseries table using the provided filter
|
||||
// expression (which includes user filter + status filter + page groups IN clause).
|
||||
// Uses GLOBAL IN with the active-hosts subquery inside uniqExactIf for active count,
|
||||
// and a simple uniqExactIf for total count. Inactive = total - active (computed in Go).
|
||||
func (m *module) getPerGroupHostStatusCounts(
|
||||
ctx context.Context,
|
||||
req *inframonitoringtypes.PostableHosts,
|
||||
metricNames []string,
|
||||
pageGroups []map[string]string,
|
||||
sinceUnixMilli int64,
|
||||
) (map[string]groupHostStatusCounts, error) {
|
||||
|
||||
// Build the full filter expression from req (user filter + status filter) and page groups.
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
|
||||
|
||||
adjustedStart, adjustedEnd, distributedTimeSeriesTableName, _ := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(req.Start), uint64(req.End), nil,
|
||||
)
|
||||
|
||||
hostNameExpr := fmt.Sprintf("JSONExtractString(labels, '%s')", hostNameAttrKey)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selectCols := make([]string, 0, len(req.GroupBy)+2)
|
||||
for _, key := range req.GroupBy {
|
||||
selectCols = append(selectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", sb.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
}
|
||||
|
||||
activeHostsSQ := m.getActiveHostsQuery(metricNames, hostNameAttrKey, sinceUnixMilli)
|
||||
selectCols = append(selectCols,
|
||||
fmt.Sprintf("uniqExactIf(%s, %s GLOBAL IN (%s)) AS active_host_count", hostNameExpr, hostNameExpr, sb.Var(activeHostsSQ)),
|
||||
fmt.Sprintf("uniqExactIf(%s, %s != '') AS total_host_count", hostNameExpr, hostNameExpr),
|
||||
)
|
||||
|
||||
// Build a fingerprint subquery to restrict to fingerprints with actual sample
|
||||
// data in the original time range (not the wider timeseries table window).
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, req.Start, req.End)
|
||||
|
||||
sb.Select(selectCols...)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", adjustedStart),
|
||||
sb.L("unix_milli", adjustedEnd),
|
||||
fmt.Sprintf("fingerprint IN (%s)", sb.Var(fpSB)),
|
||||
)
|
||||
|
||||
// Apply the combined filter expression (user filter + status filter + page groups IN).
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
sb.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
// GROUP BY
|
||||
groupByAliases := make([]string, 0, len(req.GroupBy))
|
||||
for _, key := range req.GroupBy {
|
||||
groupByAliases = append(groupByAliases, quoteIdentifier(key.Name))
|
||||
}
|
||||
sb.GroupBy(groupByAliases...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]groupHostStatusCounts)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(req.GroupBy))
|
||||
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
|
||||
var activeCount, totalCount uint64
|
||||
scanPtrs = append(scanPtrs, &activeCount, &totalCount)
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
compositeKey := compositeKeyFromList(groupVals)
|
||||
result[compositeKey] = groupHostStatusCounts{
|
||||
Active: int(activeCount),
|
||||
Inactive: int(totalCount - activeCount),
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildHostRecords constructs the final list of HostRecords for a page.
|
||||
// Groups that had no metric data get default values of -1.
|
||||
//
|
||||
// hostCounts is nil when host.name is in the groupBy — in that case, counts are
|
||||
// derived directly from activeHostsMap (1/0 per host). When non-nil (custom groupBy
|
||||
// without host.name), counts are looked up from the map.
|
||||
func buildHostRecords(
|
||||
isHostNameInGroupBy bool,
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
pageGroups []map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
activeHostsMap map[string]bool,
|
||||
hostCounts map[string]groupHostStatusCounts,
|
||||
) []inframonitoringtypes.HostRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
records := make([]inframonitoringtypes.HostRecord, 0, len(pageGroups))
|
||||
for _, labels := range pageGroups {
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
hostName := labels[hostNameAttrKey]
|
||||
|
||||
activeStatus := inframonitoringtypes.HostStatusNone
|
||||
activeHostCount := 0
|
||||
inactiveHostCount := 0
|
||||
if isHostNameInGroupBy { // derive from activeHostsMap since each row = one host
|
||||
if hostName != "" {
|
||||
if activeHostsMap[hostName] {
|
||||
activeStatus = inframonitoringtypes.HostStatusActive
|
||||
activeHostCount = 1
|
||||
} else {
|
||||
activeStatus = inframonitoringtypes.HostStatusInactive
|
||||
inactiveHostCount = 1
|
||||
}
|
||||
}
|
||||
} else { // derive from hostCounts since custom groupBy without host.name
|
||||
if counts, ok := hostCounts[compositeKey]; ok {
|
||||
activeHostCount = counts.Active
|
||||
inactiveHostCount = counts.Inactive
|
||||
}
|
||||
}
|
||||
|
||||
record := inframonitoringtypes.HostRecord{
|
||||
HostName: hostName,
|
||||
Status: activeStatus,
|
||||
ActiveHostCount: activeHostCount,
|
||||
InactiveHostCount: inactiveHostCount,
|
||||
CPU: -1,
|
||||
Memory: -1,
|
||||
Wait: -1,
|
||||
Load15: -1,
|
||||
DiskUsage: -1,
|
||||
Meta: map[string]any{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
if v, exists := metrics["F1"]; exists {
|
||||
record.CPU = v
|
||||
}
|
||||
if v, exists := metrics["F2"]; exists {
|
||||
record.Memory = v
|
||||
}
|
||||
if v, exists := metrics["F3"]; exists {
|
||||
record.Wait = v
|
||||
}
|
||||
if v, exists := metrics["F4"]; exists {
|
||||
record.DiskUsage = v
|
||||
}
|
||||
if v, exists := metrics["G"]; exists {
|
||||
record.Load15 = v
|
||||
}
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopHostGroups runs a ranking query for the ordering metric, sorts the
|
||||
// results, paginates, and backfills from metadataMap when the page extends
|
||||
// past the metric-ranked groups.
|
||||
func (m *module) getTopHostGroups(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableHosts,
|
||||
metadataMap map[string]map[string]string,
|
||||
) ([]map[string]string, error) {
|
||||
orderByKey := req.OrderBy.Key.Name
|
||||
queryNamesForOrderBy := orderByToHostsQueryNames[orderByKey]
|
||||
// The last entry is the formula/query whose value we sort by.
|
||||
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
|
||||
|
||||
topReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(req.Start),
|
||||
End: uint64(req.End),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range m.newListHostsQuery().CompositeQuery.Queries {
|
||||
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
|
||||
continue
|
||||
}
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(req.GroupBy)
|
||||
}
|
||||
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
// applyHostsActiveStatusFilter MODIFIES req.Filter.Expression to include an IN/NOT IN
|
||||
// clause based on FilterByStatus and the set of active hosts.
|
||||
// Returns true if the caller should short-circuit with an empty result (eg. ACTIVE
|
||||
// requested but no hosts are active).
|
||||
func (m *module) applyHostsActiveStatusFilter(req *inframonitoringtypes.PostableHosts, activeHostsMap map[string]bool) (shouldShortCircuit bool) {
|
||||
if req.Filter == nil || (req.Filter.FilterByStatus != inframonitoringtypes.HostStatusActive && req.Filter.FilterByStatus != inframonitoringtypes.HostStatusInactive) {
|
||||
return false
|
||||
}
|
||||
|
||||
activeHosts := make([]string, 0, len(activeHostsMap))
|
||||
for host := range activeHostsMap {
|
||||
activeHosts = append(activeHosts, fmt.Sprintf("'%s'", host))
|
||||
}
|
||||
|
||||
if len(activeHosts) == 0 {
|
||||
return req.Filter.FilterByStatus == inframonitoringtypes.HostStatusActive
|
||||
}
|
||||
|
||||
op := "IN"
|
||||
if req.Filter.FilterByStatus == inframonitoringtypes.HostStatusInactive {
|
||||
op = "NOT IN"
|
||||
}
|
||||
statusClause := fmt.Sprintf("%s %s (%s)", hostNameAttrKey, op, strings.Join(activeHosts, ", "))
|
||||
req.Filter.Expression = mergeFilterExpressions(req.Filter.Expression, statusClause)
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *module) getHostsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableHosts) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range hostAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
metadataMap, err := m.getMetadata(ctx, hostsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metadataMap, nil
|
||||
}
|
||||
|
||||
// getActiveHostsQuery builds a SelectBuilder that returns distinct host names
|
||||
// with metrics reported in the last 10 minutes. The builder is not executed —
|
||||
// callers can either execute it (getActiveHosts) or embed it as a subquery
|
||||
// (getPerGroupActiveInactiveHostCounts).
|
||||
func (m *module) getActiveHostsQuery(metricNames []string, hostNameAttr string, sinceUnixMilli int64) *sqlbuilder.SelectBuilder {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Distinct()
|
||||
sb.Select("attr_string_value")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.E("attr_name", hostNameAttr),
|
||||
sb.NE("attr_string_value", ""),
|
||||
sb.GE("last_reported_unix_milli", sinceUnixMilli),
|
||||
)
|
||||
|
||||
return sb
|
||||
}
|
||||
|
||||
// getActiveHosts returns a set of host names that have reported metrics recently.
|
||||
// It queries distributed_metadata for hosts where last_reported_unix_milli >= sinceUnixMilli.
|
||||
func (m *module) getActiveHosts(ctx context.Context, metricNames []string, hostNameAttr string, sinceUnixMilli int64) (map[string]bool, error) {
|
||||
sb := m.getActiveHostsQuery(metricNames, hostNameAttr, sinceUnixMilli)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
activeHosts := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var hostName string
|
||||
if err := rows.Scan(&hostName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hostName != "" {
|
||||
activeHosts[hostName] = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return activeHosts, nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
hostNameAttrKey = "host.name"
|
||||
)
|
||||
|
||||
// Helper group-by key used across all queries.
|
||||
var hostNameGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: hostNameAttrKey,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
var hostsTableMetricNamesList = []string{
|
||||
"system.cpu.time",
|
||||
"system.memory.usage",
|
||||
"system.cpu.load_average.15m",
|
||||
"system.filesystem.usage",
|
||||
}
|
||||
|
||||
var hostAttrKeysForMetadata = []string{
|
||||
"os.type",
|
||||
}
|
||||
|
||||
// orderByToHostsQueryNames maps the orderBy column to the query/formula names
|
||||
// from HostsTableListQuery used for ranking host groups.
|
||||
var orderByToHostsQueryNames = map[string][]string{
|
||||
inframonitoringtypes.HostsOrderByCPU: {"A", "B", "F1"},
|
||||
inframonitoringtypes.HostsOrderByMemory: {"C", "D", "F2"},
|
||||
inframonitoringtypes.HostsOrderByWait: {"E", "F", "F3"},
|
||||
inframonitoringtypes.HostsOrderByDiskUsage: {"H", "I", "F4"},
|
||||
inframonitoringtypes.HostsOrderByLoad15: {"G"},
|
||||
}
|
||||
|
||||
// newListHostsQuery constructs the base QueryRangeRequest with all the queries for the hosts table.
|
||||
// This is kept in this file because the queries themselves do not change based on the request parameters
|
||||
// only the filters, group bys, and order bys change, which are applied in buildFullQueryRequest.
|
||||
func (m *module) newListHostsQuery() *qbtypes.QueryRangeRequest {
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
// Query A: CPU usage logic (non-idle)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state != 'idle'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query B: CPU usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F1: CPU Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A/B",
|
||||
Legend: "CPU Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query C: Memory usage (state = used)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'used'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query D: Memory usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F2: Memory Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F2",
|
||||
Expression: "C/D",
|
||||
Legend: "Memory Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query E: CPU Wait time (state = wait)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "E",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'wait'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query F: CPU time (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "F",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.time",
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F3: CPU Wait Time (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F3",
|
||||
Expression: "E/F",
|
||||
Legend: "CPU Wait Time (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query G: Load15
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "G",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Legend: "CPU Load Average (15m)",
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.cpu.load_average.15m",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query H: Filesystem Usage (state = used)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "H",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.filesystem.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "state = 'used'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Query I: Filesystem Usage (all states)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "I",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.filesystem.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
|
||||
Disabled: true,
|
||||
},
|
||||
},
|
||||
// Formula F4: Disk Usage (%)
|
||||
{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{
|
||||
Name: "F4",
|
||||
Expression: "H/I",
|
||||
Legend: "Disk Usage (%)",
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package implinframonitoring
|
||||
|
||||
// The types in this file are only used within the implinframonitoring package, and are not exposed outside.
|
||||
// They are primarily used for internal processing and structuring of data within the module's implementation.
|
||||
|
||||
type rankedGroup struct {
|
||||
labels map[string]string
|
||||
value float64
|
||||
compositeKey string
|
||||
}
|
||||
|
||||
// groupHostStatusCounts holds per-group active and inactive host counts.
|
||||
type groupHostStatusCounts struct {
|
||||
Active int
|
||||
Inactive int
|
||||
}
|
||||
237
pkg/modules/inframonitoring/implinframonitoring/module.go
Normal file
237
pkg/modules/inframonitoring/implinframonitoring/module.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
querier querier.Querier
|
||||
fieldMapper qbtypes.FieldMapper
|
||||
condBuilder qbtypes.ConditionBuilder
|
||||
logger *slog.Logger
|
||||
config inframonitoring.Config
|
||||
}
|
||||
|
||||
// NewModule constructs the inframonitoring module with the provided dependencies.
|
||||
func NewModule(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore,
|
||||
querier querier.Querier,
|
||||
providerSettings factory.ProviderSettings,
|
||||
cfg inframonitoring.Config,
|
||||
) inframonitoring.Module {
|
||||
fieldMapper := telemetrymetrics.NewFieldMapper()
|
||||
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
querier: querier,
|
||||
fieldMapper: fieldMapper,
|
||||
condBuilder: condBuilder,
|
||||
logger: providerSettings.Logger,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &inframonitoringtypes.Hosts{}
|
||||
|
||||
// default to cpu order by
|
||||
if req.OrderBy == nil {
|
||||
req.OrderBy = &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "cpu",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}
|
||||
}
|
||||
|
||||
// default to host name group by
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{hostNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
}
|
||||
|
||||
// 1. Check which required metrics exist and get earliest retention time.
|
||||
// If any required metric is missing, return early with the list of missing metrics.
|
||||
// 2. If metrics exist but req.End is before the earliest reported time, return early with endTimeBeforeRetention=true.
|
||||
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, hostsTableMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(missingMetrics) > 0 {
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
if req.End < int64(minFirstReportedUnixMilli) {
|
||||
resp.EndTimeBeforeRetention = true
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
|
||||
|
||||
// TOD(nikhilmantri0902): replace this separate ClickHouse query with a sub-query inside the main query builder query
|
||||
// once QB supports sub-queries.
|
||||
// Determine active hosts: those with metrics reported in the last 10 minutes.
|
||||
// Compute the cutoff once so every downstream query/subquery agrees on what "active" means.
|
||||
sinceUnixMilli := time.Now().Add(-10 * time.Minute).UTC().UnixMilli()
|
||||
activeHostsMap, err := m.getActiveHosts(ctx, hostsTableMetricNamesList, hostNameAttrKey, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// this check below modifies req.Filter by adding `AND active hosts filter` if req.FilterByStatus is set.
|
||||
if m.applyHostsActiveStatusFilter(req, activeHostsMap) {
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
metadataMap, err := m.getHostsTableMetadata(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
pageGroups, err := m.getTopHostGroups(ctx, orgID, req, metadataMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
resp.Records = []inframonitoringtypes.HostRecord{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
hostsFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
hostsFilterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compute per-group active/inactive host counts.
|
||||
// When host.name is in groupBy, each row = one host, so counts are derived
|
||||
// directly from activeHostsMap in buildHostRecords (no extra query needed).
|
||||
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
|
||||
// using the same filter expression as the main query (including user filters + page groups IN clause).
|
||||
hostCounts := make(map[string]groupHostStatusCounts)
|
||||
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, hostNameAttrKey)
|
||||
if !isHostNameInGroupBy {
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &inframonitoringtypes.Pods{}
|
||||
|
||||
if req.OrderBy == nil {
|
||||
req.OrderBy = &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: inframonitoringtypes.PodsOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{podUIDGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
}
|
||||
|
||||
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, podsTableMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(missingMetrics) > 0 {
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
|
||||
resp.Records = []inframonitoringtypes.PodRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
if req.End < int64(minFirstReportedUnixMilli) {
|
||||
resp.EndTimeBeforeRetention = true
|
||||
resp.Records = []inframonitoringtypes.PodRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
|
||||
|
||||
metadataMap, err := m.getPodsTableMetadata(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
pageGroups, err := m.getTopPodGroups(ctx, orgID, req, metadataMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
resp.Records = []inframonitoringtypes.PodRecord{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildPodRecords(queryResp, pageGroups, req.GroupBy, metadataMap, req.End)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
154
pkg/modules/inframonitoring/implinframonitoring/pods.go
Normal file
154
pkg/modules/inframonitoring/implinframonitoring/pods.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func mapPhaseNumToString(v float64) inframonitoringtypes.PodPhase {
|
||||
switch int(v) {
|
||||
case 1:
|
||||
return inframonitoringtypes.PodPhasePending
|
||||
case 2:
|
||||
return inframonitoringtypes.PodPhaseRunning
|
||||
case 3:
|
||||
return inframonitoringtypes.PodPhaseSucceeded
|
||||
case 4:
|
||||
return inframonitoringtypes.PodPhaseFailed
|
||||
default:
|
||||
return inframonitoringtypes.PodPhaseNone
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) getTopPodGroups(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostablePods,
|
||||
metadataMap map[string]map[string]string,
|
||||
) ([]map[string]string, error) {
|
||||
orderByKey := req.OrderBy.Key.Name
|
||||
queryNamesForOrderBy := orderByToPodsQueryNames[orderByKey]
|
||||
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
|
||||
|
||||
topReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(req.Start),
|
||||
End: uint64(req.End),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range m.newPodsTableListQuery().CompositeQuery.Queries {
|
||||
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
|
||||
continue
|
||||
}
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(req.GroupBy)
|
||||
}
|
||||
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getPodsTableMetadata(ctx context.Context, req *inframonitoringtypes.PostablePods) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range podAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
func buildPodRecords(
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
pageGroups []map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
reqEnd int64,
|
||||
) []inframonitoringtypes.PodRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
records := make([]inframonitoringtypes.PodRecord, 0, len(pageGroups))
|
||||
for _, labels := range pageGroups {
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
podUID := labels[podUIDAttrKey]
|
||||
|
||||
record := inframonitoringtypes.PodRecord{
|
||||
PodUID: podUID,
|
||||
PodCPU: -1,
|
||||
PodCPURequest: -1,
|
||||
PodCPULimit: -1,
|
||||
PodMemory: -1,
|
||||
PodMemoryRequest: -1,
|
||||
PodMemoryLimit: -1,
|
||||
PodAge: -1,
|
||||
Meta: map[string]any{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
if v, exists := metrics["A"]; exists {
|
||||
record.PodCPU = v
|
||||
}
|
||||
if v, exists := metrics["B"]; exists {
|
||||
record.PodCPURequest = v
|
||||
}
|
||||
if v, exists := metrics["C"]; exists {
|
||||
record.PodCPULimit = v
|
||||
}
|
||||
if v, exists := metrics["D"]; exists {
|
||||
record.PodMemory = v
|
||||
}
|
||||
if v, exists := metrics["E"]; exists {
|
||||
record.PodMemoryRequest = v
|
||||
}
|
||||
if v, exists := metrics["F"]; exists {
|
||||
record.PodMemoryLimit = v
|
||||
}
|
||||
if v, exists := metrics["G"]; exists {
|
||||
record.PodPhase = mapPhaseNumToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
if startTimeStr, exists := attrs[podStartTimeAttrKey]; exists && startTimeStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startTimeStr); err == nil {
|
||||
startTimeMs := t.UnixMilli()
|
||||
if startTimeMs > 0 {
|
||||
record.PodAge = reqEnd - startTimeMs
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
podUIDAttrKey = "k8s.pod.uid"
|
||||
podStartTimeAttrKey = "k8s.pod.start_time"
|
||||
)
|
||||
|
||||
var podUIDGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: podUIDAttrKey,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
var podsTableMetricNamesList = []string{
|
||||
"k8s.pod.cpu.usage",
|
||||
"k8s.pod.cpu_request_utilization",
|
||||
"k8s.pod.cpu_limit_utilization",
|
||||
"k8s.pod.memory.working_set",
|
||||
"k8s.pod.memory_request_utilization",
|
||||
"k8s.pod.memory_limit_utilization",
|
||||
"k8s.pod.phase",
|
||||
}
|
||||
|
||||
var podAttrKeysForMetadata = []string{
|
||||
"k8s.pod.uid",
|
||||
"k8s.pod.name",
|
||||
"k8s.namespace.name",
|
||||
"k8s.node.name",
|
||||
"k8s.deployment.name",
|
||||
"k8s.statefulset.name",
|
||||
"k8s.daemonset.name",
|
||||
"k8s.job.name",
|
||||
"k8s.cronjob.name",
|
||||
"k8s.cluster.name",
|
||||
"k8s.pod.start_time",
|
||||
}
|
||||
|
||||
var orderByToPodsQueryNames = map[string][]string{
|
||||
inframonitoringtypes.PodsOrderByCPU: {"A"},
|
||||
inframonitoringtypes.PodsOrderByCPURequest: {"B"},
|
||||
inframonitoringtypes.PodsOrderByCPULimit: {"C"},
|
||||
inframonitoringtypes.PodsOrderByMemory: {"D"},
|
||||
inframonitoringtypes.PodsOrderByMemoryRequest: {"E"},
|
||||
inframonitoringtypes.PodsOrderByMemoryLimit: {"F"},
|
||||
inframonitoringtypes.PodsOrderByPhase: {"G"},
|
||||
}
|
||||
|
||||
func (m *module) newPodsTableListQuery() *qbtypes.QueryRangeRequest {
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
// Query A: CPU usage
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.pod.cpu.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{podUIDGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query B: CPU request utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.pod.cpu_request_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{podUIDGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query C: CPU limit utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.pod.cpu_limit_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{podUIDGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query D: Memory working set
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.pod.memory.working_set",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{podUIDGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query E: Memory request utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "E",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.pod.memory_request_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{podUIDGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query F: Memory limit utilization
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "F",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.pod.memory_limit_utilization",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationAvg,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{podUIDGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query G: Pod phase (latest value per pod)
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "G",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.pod.phase",
|
||||
TimeAggregation: metrictypes.TimeAggregationLatest,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationMax,
|
||||
ReduceTo: qbtypes.ReduceToLast,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{podUIDGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
19
pkg/modules/inframonitoring/inframonitoring.go
Normal file
19
pkg/modules/inframonitoring/inframonitoring.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package inframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
ListHosts(http.ResponseWriter, *http.Request)
|
||||
ListPods(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
|
||||
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
@@ -114,6 +115,9 @@ type Config struct {
|
||||
// MetricsExplorer config
|
||||
MetricsExplorer metricsexplorer.Config `mapstructure:"metricsexplorer"`
|
||||
|
||||
// InfraMonitoring config
|
||||
InfraMonitoring inframonitoring.Config `mapstructure:"inframonitoring"`
|
||||
|
||||
// Flagger config
|
||||
Flagger flagger.Config `mapstructure:"flagger"`
|
||||
|
||||
@@ -157,6 +161,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
|
||||
gateway.NewConfigFactory(),
|
||||
tokenizer.NewConfigFactory(),
|
||||
metricsexplorer.NewConfigFactory(),
|
||||
inframonitoring.NewConfigFactory(),
|
||||
flagger.NewConfigFactory(),
|
||||
user.NewConfigFactory(),
|
||||
identn.NewConfigFactory(),
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields/implfields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
@@ -55,6 +57,7 @@ type Handlers struct {
|
||||
SpanPercentile spanpercentile.Handler
|
||||
Services services.Handler
|
||||
MetricsExplorer metricsexplorer.Handler
|
||||
InfraMonitoring inframonitoring.Handler
|
||||
Global global.Handler
|
||||
FlaggerHandler flagger.Handler
|
||||
GatewayHandler gateway.Handler
|
||||
@@ -95,6 +98,7 @@ func NewHandlers(
|
||||
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
|
||||
Services: implservices.NewHandler(modules.Services),
|
||||
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
|
||||
InfraMonitoring: implinframonitoring.NewHandler(modules.InfraMonitoring),
|
||||
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
|
||||
Global: signozglobal.NewHandler(global),
|
||||
FlaggerHandler: flagger.NewHandler(flaggerService),
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -69,6 +71,7 @@ type Modules struct {
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
CloudIntegration cloudintegration.Module
|
||||
@@ -119,6 +122,7 @@ func NewModules(
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: serviceAccount,
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
@@ -61,6 +62,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ dashboard.Module }{},
|
||||
struct{ dashboard.Handler }{},
|
||||
struct{ metricsexplorer.Handler }{},
|
||||
struct{ inframonitoring.Handler }{},
|
||||
struct{ gateway.Handler }{},
|
||||
struct{ fields.Handler }{},
|
||||
struct{ authz.Handler }{},
|
||||
|
||||
@@ -272,6 +272,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
modules.Dashboard,
|
||||
handlers.Dashboard,
|
||||
handlers.MetricsExplorer,
|
||||
handlers.InfraMonitoring,
|
||||
handlers.GatewayHandler,
|
||||
handlers.Fields,
|
||||
handlers.AuthzHandler,
|
||||
|
||||
755
pkg/templating/markdownrenderer/blockkit/blockkit.go
Normal file
755
pkg/templating/markdownrenderer/blockkit/blockkit.go
Normal file
@@ -0,0 +1,755 @@
|
||||
package blockkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/templatingtypes"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Extender is a goldmark.Extender that registers the Block Kit node renderer
|
||||
// together with the GFM extensions it relies on (tables, strikethrough, task
|
||||
// lists).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
extension.TaskList.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// listFrame tracks state for a single level of list nesting.
|
||||
type listFrame struct {
|
||||
style string // "bullet" or "ordered"
|
||||
indent int
|
||||
itemCount int
|
||||
}
|
||||
|
||||
// listContext holds all state while processing a list tree.
|
||||
type listContext struct {
|
||||
result []templatingtypes.RichTextList
|
||||
stack []listFrame
|
||||
current *templatingtypes.RichTextList
|
||||
currentItemInlines []interface{}
|
||||
}
|
||||
|
||||
// tableContext holds state while processing a table.
|
||||
type tableContext struct {
|
||||
rows [][]templatingtypes.TableCell
|
||||
currentRow []templatingtypes.TableCell
|
||||
currentCellInlines []interface{}
|
||||
isHeader bool
|
||||
}
|
||||
|
||||
// nodeRenderer converts Markdown AST to Slack Block Kit JSON.
|
||||
type nodeRenderer struct {
|
||||
blocks []interface{}
|
||||
mrkdwn strings.Builder
|
||||
// holds active styles for the current rich text element
|
||||
styleStack []templatingtypes.RichTextStyle
|
||||
// holds the current list context while processing a list tree.
|
||||
listCtx *listContext
|
||||
// holds the current table context while processing a table.
|
||||
tableCtx *tableContext
|
||||
// stores the current blockquote depth while processing a blockquote.
|
||||
// so blockquote with nested list can be rendered correctly.
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs registers node rendering functions.
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
reg.Register(extensionast.KindTableHeader, r.renderTableHeader)
|
||||
reg.Register(extensionast.KindTableRow, r.renderTableRow)
|
||||
reg.Register(extensionast.KindTableCell, r.renderTableCell)
|
||||
reg.Register(extensionast.KindTaskCheckBox, r.renderTaskCheckBox)
|
||||
}
|
||||
|
||||
// inRichTextMode returns true when we're inside a list or table context
|
||||
// in slack blockkit list and table items are rendered as rich_text elements
|
||||
// if more cases are found in future those needs to be added here.
|
||||
func (r *nodeRenderer) inRichTextMode() bool {
|
||||
return r.listCtx != nil || r.tableCtx != nil
|
||||
}
|
||||
|
||||
// currentStyle merges the stored style stack into templatingtypes.RichTextStyle
|
||||
// which can be applied on rich text elements.
|
||||
func (r *nodeRenderer) currentStyle() *templatingtypes.RichTextStyle {
|
||||
s := templatingtypes.RichTextStyle{}
|
||||
for _, f := range r.styleStack {
|
||||
s.Bold = s.Bold || f.Bold
|
||||
s.Italic = s.Italic || f.Italic
|
||||
s.Strike = s.Strike || f.Strike
|
||||
s.Code = s.Code || f.Code
|
||||
}
|
||||
if s == (templatingtypes.RichTextStyle{}) {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// flushMrkdwn collects markdown text and adds it as a templatingtypes.SectionBlock with mrkdwn text
|
||||
// whenever starting a new block we flush markdown to render it as a separate block.
|
||||
func (r *nodeRenderer) flushMrkdwn() {
|
||||
text := strings.TrimSpace(r.mrkdwn.String())
|
||||
if text != "" {
|
||||
r.blocks = append(r.blocks, templatingtypes.SectionBlock{
|
||||
Type: "section",
|
||||
Text: &templatingtypes.TextObject{
|
||||
Type: "mrkdwn",
|
||||
Text: text,
|
||||
},
|
||||
})
|
||||
}
|
||||
r.mrkdwn.Reset()
|
||||
}
|
||||
|
||||
// addInline adds an inline element to the appropriate context.
|
||||
func (r *nodeRenderer) addInline(el interface{}) {
|
||||
if r.listCtx != nil {
|
||||
r.listCtx.currentItemInlines = append(r.listCtx.currentItemInlines, el)
|
||||
} else if r.tableCtx != nil {
|
||||
r.tableCtx.currentCellInlines = append(r.tableCtx.currentCellInlines, el)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Document ---
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blocks = nil
|
||||
r.mrkdwn.Reset()
|
||||
r.styleStack = nil
|
||||
r.listCtx = nil
|
||||
r.tableCtx = nil
|
||||
r.blockquoteDepth = 0
|
||||
} else {
|
||||
// on exiting the document node write the json for the collected blocks.
|
||||
r.flushMrkdwn()
|
||||
var data []byte
|
||||
var err error
|
||||
if len(r.blocks) > 0 {
|
||||
data, err = json.Marshal(r.blocks)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
} else {
|
||||
// if no blocks are collected, write an empty array.
|
||||
data = []byte("[]")
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Heading ---
|
||||
|
||||
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("*")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Paragraph ---
|
||||
|
||||
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if r.mrkdwn.Len() > 0 {
|
||||
text := r.mrkdwn.String()
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
}
|
||||
}
|
||||
// handling of nested blockquotes
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ThematicBreak ---
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.blocks = append(r.blocks, templatingtypes.DividerBlock{Type: "divider"})
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- CodeBlock (indented) ---
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
// Remove trailing newline
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
templatingtypes.RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
templatingtypes.RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- FencedCodeBlock ---
|
||||
|
||||
func (r *nodeRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
n := node.(*ast.FencedCodeBlock)
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
templatingtypes.RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
// If language is specified, collect it.
|
||||
var language string
|
||||
lang := n.Language(source)
|
||||
if len(lang) > 0 {
|
||||
language = string(lang)
|
||||
}
|
||||
// Add the preformatted block to the blocks slice with the collected language.
|
||||
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
templatingtypes.RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
Language: language,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Blockquote ---
|
||||
|
||||
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blockquoteDepth++
|
||||
} else {
|
||||
r.blockquoteDepth--
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
list := node.(*ast.List)
|
||||
|
||||
if entering {
|
||||
style := "bullet"
|
||||
if list.IsOrdered() {
|
||||
style = "ordered"
|
||||
}
|
||||
|
||||
if r.listCtx == nil {
|
||||
// Top-level list: flush mrkdwn and create context
|
||||
r.flushMrkdwn()
|
||||
r.listCtx = &listContext{}
|
||||
} else {
|
||||
// Nested list: check if we already have some collected list items that needs to be flushed.
|
||||
// in slack blockkit, list items with different levels of indentation are added as different rich_text_list blocks.
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
// Finalize current list to result only if items were collected
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
}
|
||||
|
||||
// the stack accumulated till this level derives hte indentation
|
||||
// the stack get's collected as we go in more nested levels of list
|
||||
// and as we get our of the nesting we remove the items from the slack
|
||||
indent := len(r.listCtx.stack)
|
||||
r.listCtx.stack = append(r.listCtx.stack, listFrame{
|
||||
style: style,
|
||||
indent: indent,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
newList := &templatingtypes.RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: style,
|
||||
Indent: indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
|
||||
// Handle ordered list with start > 1
|
||||
if list.IsOrdered() && list.Start > 1 {
|
||||
newList.Offset = list.Start - 1
|
||||
}
|
||||
|
||||
r.listCtx.current = newList
|
||||
|
||||
} else {
|
||||
// Leaving list: finalize current list
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
|
||||
// Pop stack to so upcoming indentations can be handled correctly.
|
||||
r.listCtx.stack = r.listCtx.stack[:len(r.listCtx.stack)-1]
|
||||
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
// Resume parent: start a new list segment at parent indent/style
|
||||
parent := &r.listCtx.stack[len(r.listCtx.stack)-1]
|
||||
newList := &templatingtypes.RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: parent.style,
|
||||
Indent: parent.indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
// Set offset for ordered parent continuation
|
||||
if parent.style == "ordered" && parent.itemCount > 0 {
|
||||
newList.Offset = parent.itemCount
|
||||
}
|
||||
r.listCtx.current = newList
|
||||
} else {
|
||||
// Top-level list is done since all stack are popped: build templatingtypes.RichTextBlock if non-empty
|
||||
if len(r.listCtx.result) > 0 {
|
||||
elements := make([]interface{}, len(r.listCtx.result))
|
||||
for i, l := range r.listCtx.result {
|
||||
elements[i] = l
|
||||
}
|
||||
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: elements,
|
||||
})
|
||||
}
|
||||
r.listCtx = nil
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ListItem ---
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.listCtx.currentItemInlines = nil
|
||||
} else {
|
||||
// Only add if there are inlines (might be empty after nested list consumed them)
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent frame's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Table ---
|
||||
// when table is encountered, we flush the markdown and create a table context.
|
||||
// when header row is encountered, we set the isHeader flag to true
|
||||
// when each row ends in renderTableRow we add that row to rows array of table context.
|
||||
// when table cell is encountered, we apply header related styles to the collected inline items,
|
||||
// all inline items are parsed as separate AST items like list item, links, text, etc. are collected
|
||||
// using the addInline function and wrapped in a rich_text_section block.
|
||||
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.tableCtx = &tableContext{}
|
||||
} else {
|
||||
// Pad short rows to match header column count for valid Block Kit payload
|
||||
// without this slack blockkit attachment is invalid and the API fails
|
||||
rows := r.tableCtx.rows
|
||||
if len(rows) > 0 {
|
||||
maxCols := len(rows[0])
|
||||
for i, row := range rows {
|
||||
for len(row) < maxCols {
|
||||
emptySec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: []interface{}{templatingtypes.RichTextInline{Type: "text", Text: " "}},
|
||||
}
|
||||
row = append(row, templatingtypes.TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{emptySec},
|
||||
})
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
}
|
||||
r.blocks = append(r.blocks, templatingtypes.TableBlock{
|
||||
Type: "table",
|
||||
Rows: rows,
|
||||
})
|
||||
r.tableCtx = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.isHeader = true
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
r.tableCtx.isHeader = false
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
} else {
|
||||
// If header, make text bold for the collected inline items.
|
||||
if r.tableCtx.isHeader {
|
||||
for i, el := range r.tableCtx.currentCellInlines {
|
||||
if inline, ok := el.(templatingtypes.RichTextInline); ok {
|
||||
if inline.Style == nil {
|
||||
inline.Style = &templatingtypes.RichTextStyle{Bold: true}
|
||||
} else {
|
||||
inline.Style.Bold = true
|
||||
}
|
||||
r.tableCtx.currentCellInlines[i] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ensure cell has at least one element for valid Block Kit payload
|
||||
if len(r.tableCtx.currentCellInlines) == 0 {
|
||||
r.tableCtx.currentCellInlines = []interface{}{
|
||||
templatingtypes.RichTextInline{Type: "text", Text: " "},
|
||||
}
|
||||
}
|
||||
// All inline items that are collected for a table cell are wrapped in a rich_text_section block.
|
||||
sec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.tableCtx.currentCellInlines,
|
||||
}
|
||||
// The rich_text_section block is wrapped in a rich_text block.
|
||||
cell := templatingtypes.TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{sec},
|
||||
}
|
||||
r.tableCtx.currentRow = append(r.tableCtx.currentRow, cell)
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- TaskCheckBox ---
|
||||
|
||||
func (r *nodeRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*extensionast.TaskCheckBox)
|
||||
text := "[ ] "
|
||||
if n.IsChecked {
|
||||
text = "[x] "
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: text})
|
||||
} else {
|
||||
r.mrkdwn.WriteString(text)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Text ---
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
value := string(n.Segment.Value(source))
|
||||
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(templatingtypes.RichTextInline{
|
||||
Type: "text",
|
||||
Text: value,
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: "\n"})
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Emphasis ---
|
||||
|
||||
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
s := templatingtypes.RichTextStyle{}
|
||||
if n.Level == 1 {
|
||||
s.Italic = true
|
||||
} else {
|
||||
s.Bold = true
|
||||
}
|
||||
r.styleStack = append(r.styleStack, s)
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if n.Level == 1 {
|
||||
r.mrkdwn.WriteString("_")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Strikethrough ---
|
||||
|
||||
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
r.styleStack = append(r.styleStack, templatingtypes.RichTextStyle{Strike: true})
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString("~")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: CodeSpan ---
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
// Collect all child text
|
||||
var buf bytes.Buffer
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
buf.Write(v[:len(v)-1])
|
||||
buf.WriteByte(' ')
|
||||
} else {
|
||||
buf.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
}
|
||||
style := r.currentStyle()
|
||||
if style == nil {
|
||||
style = &templatingtypes.RichTextStyle{Code: true}
|
||||
} else {
|
||||
style.Code = true
|
||||
}
|
||||
r.addInline(templatingtypes.RichTextInline{
|
||||
Type: "text",
|
||||
Text: buf.String(),
|
||||
Style: style,
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
// mrkdwn mode
|
||||
r.mrkdwn.WriteByte('`')
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
r.mrkdwn.Write(v[:len(v)-1])
|
||||
r.mrkdwn.WriteByte(' ')
|
||||
} else {
|
||||
r.mrkdwn.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
r.mrkdwn.Write(s.Value)
|
||||
}
|
||||
}
|
||||
r.mrkdwn.WriteByte('`')
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Inline: Link ---
|
||||
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
// Walk the entire subtree to collect text from all descendants,
|
||||
// including nested inline nodes like emphasis, strong, code spans, etc.
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(node, func(child ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering || child == node {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if t, ok := child.(*ast.Text); ok {
|
||||
buf.Write(t.Segment.Value(source))
|
||||
} else if s, ok := child.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
// Once we've collected the text for the link (given it was present)
|
||||
// let's add the link to the rich text block.
|
||||
r.addInline(templatingtypes.RichTextLink{
|
||||
Type: "link",
|
||||
URL: string(n.Destination),
|
||||
Text: buf.String(),
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
} else {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("<")
|
||||
r.mrkdwn.Write(n.Destination)
|
||||
r.mrkdwn.WriteString("|")
|
||||
} else {
|
||||
r.mrkdwn.WriteString(">")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Image (skip) ---
|
||||
|
||||
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
542
pkg/templating/markdownrenderer/blockkit/blockkit_test.go
Normal file
542
pkg/templating/markdownrenderer/blockkit/blockkit_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
package blockkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb interface{}
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
markdown: "",
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
markdown: "# My Heading",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*My Heading*" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "multiple paragraphs",
|
||||
markdown: "First paragraph\n\nSecond paragraph",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "First paragraph\nSecond paragraph" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "todo list ",
|
||||
markdown: "- [ ] item 1\n- [x] item 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "elements": [ { "text": "[ ] ", "type": "text" }, { "text": "item 1", "type": "text" } ], "type": "rich_text_section" },
|
||||
{ "elements": [ { "text": "[x] ", "type": "text" }, { "text": "item 2", "type": "text" } ], "type": "rich_text_section" }
|
||||
],
|
||||
"indent": 0,
|
||||
"style": "bullet",
|
||||
"type": "rich_text_list"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "thematic break between paragraphs",
|
||||
markdown: "Before\n\n---\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{ "type": "divider" },
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "fenced code block with language",
|
||||
markdown: "```go\nfmt.Println(\"hello\")\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"language": "go",
|
||||
"elements": [
|
||||
{ "type": "text", "text": "fmt.Println(\"hello\")" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "indented code block",
|
||||
markdown: " code line 1\n code line 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "code line 1\ncode line 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "empty fenced code block",
|
||||
markdown: "```\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": " " }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple bullet list",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple ordered list",
|
||||
markdown: "1. first\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested bullet list (2 levels)",
|
||||
markdown: "- item 1\n- item 2\n - sub a\n - sub b\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested ordered list with offset",
|
||||
markdown: "1. first\n 1. nested-a\n 2. nested-b\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "mixed ordered/bullet nesting",
|
||||
markdown: "1. ordered\n - bullet child\n2. ordered again",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bullet child" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered again" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "list items with bold/italic/link/code",
|
||||
markdown: "- **bold item**\n- _italic item_\n- [link](http://example.com)\n- `code item`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bold item", "style": { "bold": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "italic item", "style": { "italic": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "link" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "code item", "style": { "code": true } }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with header and body",
|
||||
markdown: "| Name | Age |\n|------|-----|\n| Alice | 30 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Name", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Age", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Alice" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "30" }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
markdown: "> quoted text",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "> quoted text" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote with nested list",
|
||||
markdown: "> item 1\n> > item 2\n> > item 3",
|
||||
expected: `[
|
||||
{
|
||||
"text": {
|
||||
"text": "> item 1\n> > item 2\n> > item 3",
|
||||
"type": "mrkdwn"
|
||||
},
|
||||
"type": "section"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "inline formatting in paragraph",
|
||||
markdown: "This is **bold** and _italic_ and ~strike~ and `code`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "This is *bold* and _italic_ and ~strike~ and ` + "`code`" + `" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link in paragraph",
|
||||
markdown: "Visit [Google](http://google.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Visit <http://google.com|Google>" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "image is skipped",
|
||||
markdown: "",
|
||||
// For image skip the block and return empty array
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "paragraph then list then paragraph",
|
||||
markdown: "Before\n\n- item\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "ordered list with start > 1",
|
||||
markdown: "5. fifth\n6. sixth",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 4,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "fifth" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sixth" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested ordered list (3 levels) with offsets",
|
||||
markdown: "1. Some things\n\t1. are best left\n2. to the fate\n\t1. of the world\n\t\t1. and then\n\t\t2. this is how\n3. it turns out to be",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Some things" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "are best left" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "to the fate" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "of the world" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 2, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "and then" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "this is how" }] }
|
||||
]
|
||||
},
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 2,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "it turns out to be" }] }] }
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link with bold label in list item",
|
||||
markdown: "- [**docs**](http://example.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "docs" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with empty cell",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 | |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with missing column in row",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(Extender),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("convert error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
9
pkg/templating/markdownrenderer/blockkit/doc.go
Normal file
9
pkg/templating/markdownrenderer/blockkit/doc.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package blockkit provides a goldmark extension that renders markdown as
|
||||
// Slack Block Kit JSON (an array of blocks suitable for the Slack API's
|
||||
// `blocks` payload field).
|
||||
//
|
||||
// The current Slack notifier uses the sibling mrkdwn package instead, because
|
||||
// Block Kit's hard limits (one table per message, 50 blocks per message,
|
||||
// 12k-character total text) would silently truncate or reject notifications
|
||||
// with rich bodies. This package is kept in tree for future use.
|
||||
package blockkit
|
||||
47
pkg/templating/markdownrenderer/concurrency_test.go
Normal file
47
pkg/templating/markdownrenderer/concurrency_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConcurrentRender exercises every render entry point from many
|
||||
// goroutines. Run with `go test -race` to catch shared-state regressions
|
||||
// in the HTML, Block Kit or mrkdwn paths.
|
||||
func TestConcurrentRender(t *testing.T) {
|
||||
const goroutines = 32
|
||||
const iterations = 20
|
||||
|
||||
markdowns := []string{
|
||||
"# Heading\n\nparagraph **bold** *em* `code`",
|
||||
"- item 1\n- item 2\n - nested\n- item 3",
|
||||
"| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |",
|
||||
"> quote\n>> nested quote",
|
||||
"```go\npackage main\n```",
|
||||
"[link](https://example.com) and ",
|
||||
}
|
||||
|
||||
renderers := map[string]func(string) (string, error){
|
||||
"html": RenderHTML,
|
||||
"blockkit": RenderSlackBlockKit,
|
||||
"mrkdwn": RenderSlackMrkdwn,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for name, render := range renderers {
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(name string, render func(string) (string, error), g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
md := markdowns[(g+i)%len(markdowns)]
|
||||
if _, err := render(md); err != nil {
|
||||
t.Errorf("%s render failed: %v", name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(name, render, g)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
3
pkg/templating/markdownrenderer/doc.go
Normal file
3
pkg/templating/markdownrenderer/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package markdownrenderer renders markdown to the formats that alert
|
||||
// notifications are delivered in: HTML, Slack Block Kit JSON, and Slack mrkdwn.
|
||||
package markdownrenderer
|
||||
166
pkg/templating/markdownrenderer/html_test.go
Normal file
166
pkg/templating/markdownrenderer/html_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testMarkdown = `# 🔥 FIRING: High CPU Usage on api-gateway
|
||||
|
||||
https://signoz.example.com/alerts/123
|
||||
https://runbooks.example.com/cpu-high
|
||||
|
||||
## Alert Details
|
||||
|
||||
**Status:** **FIRING** | *api-gateway* service is experiencing high CPU usage. ~~resolved~~ previously.
|
||||
|
||||
Alert triggered because ` + "`cpu_usage_percent`" + ` exceeded threshold ` + "`90`" + `.
|
||||
|
||||
[View Alert in SigNoz](https://signoz.example.com/alerts/123) | [View Logs](https://signoz.example.com/logs?service=api-gateway) | [View Traces](https://signoz.example.com/traces?service=api-gateway)
|
||||
|
||||

|
||||
|
||||
## Alert Labels
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| instance | pod-5a8b3c |
|
||||
| severity | critical |
|
||||
| region | us-east-1 |
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
1. Check current CPU usage on the pod
|
||||
2. Review recent deployments for regressions
|
||||
3. Scale horizontally if load-related
|
||||
1. Increase replica count
|
||||
2. Verify HPA configuration
|
||||
|
||||
## Affected Services
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
* payment-processor
|
||||
* payment-validator
|
||||
|
||||
## Incident Checklist
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [x] On-call notified
|
||||
- [ ] Root cause identified
|
||||
- [ ] Fix deployed
|
||||
|
||||
## Alert Rule Description
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.
|
||||
>
|
||||
>> For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.
|
||||
|
||||
## Triggered Query
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```" + `
|
||||
|
||||
## Inline Details
|
||||
|
||||
This alert was generated by SigNoz using ` + "`alertmanager`" + ` rules engine.
|
||||
`
|
||||
|
||||
func TestRenderHTML_Composite(t *testing.T) {
|
||||
html, err := RenderHTML(testMarkdown)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<h1>🔥 FIRING: High CPU Usage on api-gateway</h1>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">https://signoz.example.com/alerts/123</a>\n<a href=\"https://runbooks.example.com/cpu-high\">https://runbooks.example.com/cpu-high</a></p>\n" +
|
||||
"<h2>Alert Details</h2>\n" +
|
||||
"<p><strong>Status:</strong> <strong>FIRING</strong> | <em>api-gateway</em> service is experiencing high CPU usage. <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Alert triggered because <code>cpu_usage_percent</code> exceeded threshold <code>90</code>.</p>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">View Alert in SigNoz</a> | <a href=\"https://signoz.example.com/logs?service=api-gateway\">View Logs</a> | <a href=\"https://signoz.example.com/traces?service=api-gateway\">View Traces</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n" +
|
||||
"<h2>Alert Labels</h2>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>instance</td>\n<td>pod-5a8b3c</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td>critical</td>\n</tr>\n" +
|
||||
"<tr>\n<td>region</td>\n<td>us-east-1</td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<h2>Remediation Steps</h2>\n" +
|
||||
"<ol>\n<li>Check current CPU usage on the pod</li>\n<li>Review recent deployments for regressions</li>\n<li>Scale horizontally if load-related\n" +
|
||||
"<ol>\n<li>Increase replica count</li>\n<li>Verify HPA configuration</li>\n</ol>\n</li>\n</ol>\n" +
|
||||
"<h2>Affected Services</h2>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service\n" +
|
||||
"<ul>\n<li>payment-processor</li>\n<li>payment-validator</li>\n</ul>\n</li>\n</ul>\n" +
|
||||
"<h2>Incident Checklist</h2>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> On-call notified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Fix deployed</li>\n</ul>\n" +
|
||||
"<h2>Alert Rule Description</h2>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.</p>\n" +
|
||||
"<blockquote>\n<p>For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.</p>\n</blockquote>\n</blockquote>\n" +
|
||||
"<h2>Triggered Query</h2>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n" +
|
||||
"<h2>Inline Details</h2>\n" +
|
||||
"<p>This alert was generated by SigNoz using <code>alertmanager</code> rules engine.</p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_InlineFormatting(t *testing.T) {
|
||||
input := `# 🔥 FIRING: High CPU on api-gateway
|
||||
## Alert Status
|
||||
|
||||
**FIRING** alert for *api-gateway* service — ~~resolved~~ previously.
|
||||
|
||||
Metric ` + "`cpu_usage_percent`" + ` exceeded threshold. [View in SigNoz](https://signoz.example.com/alerts/123)
|
||||
|
||||
`
|
||||
|
||||
html, err := RenderHTML(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<h1>🔥 FIRING: High CPU on api-gateway</h1>\n<h2>Alert Status</h2>\n" +
|
||||
"<p><strong>FIRING</strong> alert for <em>api-gateway</em> service — <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Metric <code>cpu_usage_percent</code> exceeded threshold. <a href=\"https://signoz.example.com/alerts/123\">View in SigNoz</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_BlockElements(t *testing.T) {
|
||||
input := `1. Check CPU usage on the pod
|
||||
2. Review recent deployments
|
||||
3. Scale horizontally if needed
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [ ] Root cause identified
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes.
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| severity | <no value> |
|
||||
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```"
|
||||
|
||||
html, err := RenderHTML(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<ol>\n<li>Check CPU usage on the pod</li>\n<li>Review recent deployments</li>\n<li>Scale horizontally if needed</li>\n</ol>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service</li>\n</ul>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n</ul>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes.</p>\n</blockquote>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td><no value></td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
62
pkg/templating/markdownrenderer/markdownrenderer.go
Normal file
62
pkg/templating/markdownrenderer/markdownrenderer.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
// htmlRenderer is built once and shared. Goldmark's built-in HTML path and
|
||||
// the novalue extension carry no mutable state, so a single goldmark.Markdown
|
||||
// is safe to Convert concurrently.
|
||||
var htmlRenderer = goldmark.New(goldmark.WithExtensions(extension.GFM, escapeNoValue))
|
||||
|
||||
// The Slack renderers hold per-document state on the node renderer (list
|
||||
// context, table context, style stack, blockquote/list prefixes). Two
|
||||
// goroutines calling Convert on the same goldmark.Markdown would corrupt
|
||||
// that state. A sync.Pool gives each concurrent caller its own instance
|
||||
// while still amortising the cost of building the pipeline.
|
||||
var (
|
||||
blockkitPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(blockkit.Extender))
|
||||
},
|
||||
}
|
||||
mrkdwnPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// RenderHTML converts markdown to HTML.
|
||||
func RenderHTML(markdown string) (string, error) {
|
||||
return render(htmlRenderer, markdown, "HTML")
|
||||
}
|
||||
|
||||
// RenderSlackBlockKit converts markdown to a Slack Block Kit JSON array.
|
||||
func RenderSlackBlockKit(markdown string) (string, error) {
|
||||
md := blockkitPool.Get().(goldmark.Markdown)
|
||||
defer blockkitPool.Put(md)
|
||||
return render(md, markdown, "Slack Block Kit")
|
||||
}
|
||||
|
||||
// RenderSlackMrkdwn converts markdown to Slack's mrkdwn format.
|
||||
func RenderSlackMrkdwn(markdown string) (string, error) {
|
||||
md := mrkdwnPool.Get().(goldmark.Markdown)
|
||||
defer mrkdwnPool.Put(md)
|
||||
return render(md, markdown, "Slack mrkdwn")
|
||||
}
|
||||
|
||||
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to %s", format)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
4
pkg/templating/markdownrenderer/mrkdwn/doc.go
Normal file
4
pkg/templating/markdownrenderer/mrkdwn/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package mrkdwn provides a goldmark extension that renders markdown as
|
||||
// Slack's "mrkdwn" text format (the legacy text field used in attachments
|
||||
// and message payloads).
|
||||
package mrkdwn
|
||||
404
pkg/templating/markdownrenderer/mrkdwn/mrkdwn.go
Normal file
404
pkg/templating/markdownrenderer/mrkdwn/mrkdwn.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package mrkdwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Extender is a goldmark.Extender that registers the Slack mrkdwn node
|
||||
// renderer together with the GFM extensions it relies on (tables,
|
||||
// strikethrough).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// nodeRenderer renders nodes as Slack mrkdwn.
|
||||
type nodeRenderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs implements NodeRenderer.RegisterFuncs.
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLineSeparator writes a newline followed by the current prefix.
|
||||
// Used for tight separations (e.g., between list items or text blocks).
|
||||
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
// writeBlockSeparator writes a blank line separator between block-level elements,
|
||||
// respecting any active prefixes for proper nesting (e.g., inside blockquotes).
|
||||
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
// separateFromPrevious writes a block separator if the node has a previous sibling.
|
||||
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
// The renderer is pooled, so wipe any prefix stack left over from a
|
||||
// prior document (e.g. one that errored mid-walk and left push/pop
|
||||
// unbalanced) before starting a fresh convert.
|
||||
r.prefixes = r.prefixes[:0]
|
||||
} else {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
_, _ = w.WriteString("*")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
r.prefixes = append(r.prefixes, "> ")
|
||||
_, _ = w.WriteString("> ")
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
// start code block and write code line by line
|
||||
_, _ = w.WriteString("```\n")
|
||||
l := n.Lines().Len()
|
||||
for i := 0; i < l; i++ {
|
||||
line := n.Lines().At(i)
|
||||
v := line.Value(source)
|
||||
_, _ = w.Write(v)
|
||||
}
|
||||
} else {
|
||||
_, _ = w.WriteString("```")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
// another line break if not a nested list item and starting another block
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
parent := n.Parent().(*ast.List)
|
||||
// compute and write the prefix based on list type and index
|
||||
var prefixStr string
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
prefixStr = fmt.Sprintf("%d. ", index)
|
||||
} else {
|
||||
prefixStr = "• "
|
||||
}
|
||||
_, _ = w.WriteString(prefixStr)
|
||||
r.prefixes = append(r.prefixes, "\t") // add tab for nested list items
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := n.(*ast.RawHTML)
|
||||
l := n.Segments.Len()
|
||||
for i := 0; i < l; i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("---")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
label := string(n.Label(source))
|
||||
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
|
||||
if url == label {
|
||||
_, _ = fmt.Fprintf(w, "<%s>", url)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "<%s|%s>", url, label)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_ = w.WriteByte('`')
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
value := segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) { // replace newline with space
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
_ = w.WriteByte(' ')
|
||||
} else {
|
||||
_, _ = w.Write(value)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
_ = w.WriteByte('`')
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
mark := "_"
|
||||
if n.Level == 2 {
|
||||
mark = "*"
|
||||
}
|
||||
_, _ = w.WriteString(mark)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if entering {
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
} else {
|
||||
_, _ = w.WriteString(">")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Image)
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
|
||||
// Write the alt text directly
|
||||
var altBuf bytes.Buffer
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if textNode, ok := c.(*ast.Text); ok {
|
||||
altBuf.Write(textNode.Segment.Value(source))
|
||||
}
|
||||
}
|
||||
_, _ = w.Write(altBuf.Bytes())
|
||||
|
||||
_, _ = w.WriteString(">")
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
segment := n.Segment
|
||||
value := segment.Value(source)
|
||||
_, _ = w.Write(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
_, _ = w.WriteString("~")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
// Collect cells and max widths
|
||||
var rows [][]string
|
||||
var colWidths []int
|
||||
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() == extensionast.KindTableHeader || c.Kind() == extensionast.KindTableRow {
|
||||
var row []string
|
||||
colIdx := 0
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() == extensionast.KindTableCell {
|
||||
cellText := extractPlainText(cc, source)
|
||||
row = append(row, cellText)
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
if colIdx >= len(colWidths) {
|
||||
colWidths = append(colWidths, runeLen)
|
||||
} else if runeLen > colWidths[colIdx] {
|
||||
colWidths[colIdx] = runeLen
|
||||
}
|
||||
colIdx++
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
// writing table in code block
|
||||
_, _ = w.WriteString("```\n")
|
||||
for i, row := range rows {
|
||||
for colIdx, cellText := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
padding := max(0, width-runeLen)
|
||||
|
||||
_, _ = w.WriteString(cellText)
|
||||
_, _ = w.WriteString(strings.Repeat(" ", padding))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
|
||||
// Print separator after header
|
||||
if i == 0 {
|
||||
for colIdx := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
_, _ = w.WriteString(strings.Repeat("-", width))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString("-|-")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
_, _ = w.WriteString("```")
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// extractPlainText extracts all the text content from the given node.
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if textNode, ok := node.(*ast.Text); ok {
|
||||
buf.Write(textNode.Segment.Value(source))
|
||||
} else if strNode, ok := node.(*ast.String); ok {
|
||||
buf.Write(strNode.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
115
pkg/templating/markdownrenderer/mrkdwn/mrkdwn_test.go
Normal file
115
pkg/templating/markdownrenderer/mrkdwn/mrkdwn_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mrkdwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Heading with Thematic Break",
|
||||
markdown: "# Title 1\n# Hello World\n---\nthis is sometext",
|
||||
expected: "*Title 1*\n\n*Hello World*\n\n---\n\nthis is sometext\n\n",
|
||||
},
|
||||
{
|
||||
name: "Blockquote",
|
||||
markdown: "> This is a quote\n> It continues",
|
||||
expected: "> This is a quote\n> It continues\n\n",
|
||||
},
|
||||
{
|
||||
name: "Fenced Code Block",
|
||||
markdown: "```go\npackage main\nfunc main() {}\n```",
|
||||
expected: "```\npackage main\nfunc main() {}\n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Unordered List",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested unordered list",
|
||||
markdown: "- item 1\n- item 2\n\t- item 2.1\n\t\t- item 2.1.1\n\t\t- item 2.1.2\n\t- item 2.2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n\t• item 2.1\n\t\t• item 2.1.1\n\t\t• item 2.1.2\n\t• item 2.2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "Ordered List",
|
||||
markdown: "1. item 1\n2. item 2\n3. item 3",
|
||||
expected: "1. item 1\n2. item 2\n3. item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested ordered list",
|
||||
markdown: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4",
|
||||
expected: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4\n\n",
|
||||
},
|
||||
{
|
||||
name: "Links and AutoLinks",
|
||||
markdown: "This is a [link](https://example.com) and an autolink <https://test.com>",
|
||||
expected: "This is a <https://example.com|link> and an autolink <https://test.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Images",
|
||||
markdown: "An image ",
|
||||
expected: "An image <https://example.com/image.png|alt text>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Emphasis",
|
||||
markdown: "This is **bold** and *italic* and __bold__ and _italic_",
|
||||
expected: "This is *bold* and _italic_ and *bold* and _italic_\n\n",
|
||||
},
|
||||
{
|
||||
name: "Strikethrough",
|
||||
markdown: "This is ~~strike~~",
|
||||
expected: "This is ~strike~\n\n",
|
||||
},
|
||||
{
|
||||
name: "Code Span",
|
||||
markdown: "This is `inline code` embedded.",
|
||||
expected: "This is `inline code` embedded.\n\n",
|
||||
},
|
||||
{
|
||||
name: "Table",
|
||||
markdown: "Col 1 | Col 2 | Col 3\n--- | --- | ---\nVal 1 | Long Value 2 | 3\nShort | V | 1000",
|
||||
expected: "```\nCol 1 | Col 2 | Col 3\n------|--------------|------\nVal 1 | Long Value 2 | 3 \nShort | V | 1000 \n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Mixed Nested Lists",
|
||||
markdown: "1. first\n\t- nested bullet\n\t- another bullet\n2. second",
|
||||
expected: "1. first\n\t• nested bullet\n\t• another bullet\n2. second\n\n",
|
||||
},
|
||||
{
|
||||
name: "Email AutoLink",
|
||||
markdown: "<user@example.com>",
|
||||
expected: "<mailto:user@example.com|user@example.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "No value string parsed as is",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "Service: <no value>\n\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(goldmark.WithExtensions(Extender))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("failed to convert: %v", err)
|
||||
}
|
||||
|
||||
// Do exact string matching
|
||||
actual := buf.String()
|
||||
if actual != tt.expected {
|
||||
t.Errorf("\nExpected:\n%q\nGot:\n%q\nRaw Expected:\n%s\nRaw Got:\n%s",
|
||||
tt.expected, actual, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
pkg/templating/markdownrenderer/novalue.go
Normal file
61
pkg/templating/markdownrenderer/novalue.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Go text/template writes the literal string "<no value>" when a referenced
|
||||
// key is missing. The default goldmark HTML path treats it as raw HTML and
|
||||
// drops it, hiding the missing value. This renderer escapes it so operators
|
||||
// can see which variable was unset in the rendered notification.
|
||||
type noValueHTMLRenderer struct {
|
||||
html.Config
|
||||
}
|
||||
|
||||
func newNoValueHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||
r := &noValueHTMLRenderer{Config: html.NewConfig()}
|
||||
for _, opt := range opts {
|
||||
opt.SetHTMLOption(&r.Config)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *noValueHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(gast.KindRawHTML, r.renderRawHTML)
|
||||
}
|
||||
|
||||
func (r *noValueHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
n := node.(*gast.RawHTML)
|
||||
if r.Unsafe {
|
||||
for i := 0; i < n.Segments.Len(); i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
if string(n.Segments.Value(source)) == "<no value>" {
|
||||
_, _ = w.WriteString("<no value>")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
_, _ = w.WriteString("<!-- raw HTML omitted -->")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
type escapeNoValueExtension struct{}
|
||||
|
||||
// escapeNoValue renders the literal `<no value>` produced by Go's text/template
|
||||
// as visible escaped text instead of dropping it as raw HTML.
|
||||
var escapeNoValue = &escapeNoValueExtension{}
|
||||
|
||||
func (e *escapeNoValueExtension) Extend(m goldmark.Markdown) {
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(newNoValueHTMLRenderer(), 500),
|
||||
))
|
||||
}
|
||||
66
pkg/templating/markdownrenderer/novalue_test.go
Normal file
66
pkg/templating/markdownrenderer/novalue_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
func TestEscapeNoValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain text",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "<p>Service: <no value></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strong",
|
||||
markdown: "Service: **<no value>**",
|
||||
expected: "<p>Service: <strong><no value></strong></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside emphasis",
|
||||
markdown: "Service: *<no value>*",
|
||||
expected: "<p>Service: <em><no value></em></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strikethrough",
|
||||
markdown: "Service: ~~<no value>~~",
|
||||
expected: "<p>Service: <del><no value></del></p>\n",
|
||||
},
|
||||
{
|
||||
name: "real html still omitted",
|
||||
markdown: "hello <div>world</div>",
|
||||
expected: "<p>hello <!-- raw HTML omitted -->world<!-- raw HTML omitted --></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside heading",
|
||||
markdown: "# Title <no value>",
|
||||
expected: "<h1>Title <no value></h1>\n",
|
||||
},
|
||||
{
|
||||
name: "inside list item",
|
||||
markdown: "- item <no value>",
|
||||
expected: "<ul>\n<li>item <no value></li>\n</ul>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gm := goldmark.New(goldmark.WithExtensions(escapeNoValue, extension.Strikethrough))
|
||||
var buf bytes.Buffer
|
||||
if err := gm.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() != tt.expected {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
145
pkg/templating/markdownrenderer/slack_test.go
Normal file
145
pkg/templating/markdownrenderer/slack_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb any
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderSlackBlockKit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "alert-themed with heading, list, and code block",
|
||||
markdown: `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*Alert Triggered*" }
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Service: " },
|
||||
{ "type": "text", "text": "checkout-api", "style": { "bold": true } }
|
||||
]},
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Status: " },
|
||||
{ "type": "text", "text": "critical", "style": { "italic": true } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "error: connection timeout after 30s" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := RenderSlackBlockKit(tt.markdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
if !json.Valid([]byte(got)) {
|
||||
t.Fatalf("output is not valid JSON:\n%s", got)
|
||||
}
|
||||
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSlackMrkdwn(t *testing.T) {
|
||||
markdown := `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
- Dashboard: [View Dashboard](https://example.com/dashboard)
|
||||
|
||||
| Metric | Value | Threshold |
|
||||
| --- | --- | --- |
|
||||
| Latency | 250ms | 100ms |
|
||||
| Error Rate | 5.2% | 1% |
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```"
|
||||
|
||||
expected := "*Alert Triggered*\n\n" +
|
||||
"• Service: *checkout-api*\n" +
|
||||
"• Status: _critical_\n" +
|
||||
"• Dashboard: <https://example.com/dashboard|View Dashboard>\n\n" +
|
||||
"```\nMetric | Value | Threshold\n-----------|-------|----------\nLatency | 250ms | 100ms \nError Rate | 5.2% | 1% \n```\n\n" +
|
||||
"```\nerror: connection timeout after 30s\n```\n\n"
|
||||
|
||||
got, err := RenderSlackMrkdwn(markdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("mrkdwn mismatch\n\nExpected:\n%q\n\nGot:\n%q", expected, got)
|
||||
}
|
||||
}
|
||||
21
pkg/types/inframonitoringtypes/commontypes.go
Normal file
21
pkg/types/inframonitoringtypes/commontypes.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type ResponseType struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
ResponseTypeList = ResponseType{valuer.NewString("list")}
|
||||
ResponseTypeGroupedList = ResponseType{valuer.NewString("grouped_list")}
|
||||
)
|
||||
|
||||
func (ResponseType) Enum() []any {
|
||||
return []any{
|
||||
ResponseTypeList,
|
||||
ResponseTypeGroupedList,
|
||||
}
|
||||
}
|
||||
117
pkg/types/inframonitoringtypes/hosts.go
Normal file
117
pkg/types/inframonitoringtypes/hosts.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type Hosts struct {
|
||||
Type ResponseType `json:"type" required:"true"`
|
||||
Records []HostRecord `json:"records" required:"true"`
|
||||
Total int `json:"total" required:"true"`
|
||||
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
|
||||
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
|
||||
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
type HostRecord struct {
|
||||
HostName string `json:"hostName" required:"true"`
|
||||
Status HostStatus `json:"status" required:"true"`
|
||||
ActiveHostCount int `json:"activeHostCount" required:"true"`
|
||||
InactiveHostCount int `json:"inactiveHostCount" required:"true"`
|
||||
CPU float64 `json:"cpu" required:"true"`
|
||||
Memory float64 `json:"memory" required:"true"`
|
||||
Wait float64 `json:"wait" required:"true"`
|
||||
Load15 float64 `json:"load15" required:"true"`
|
||||
DiskUsage float64 `json:"diskUsage" required:"true"`
|
||||
Meta map[string]interface{} `json:"meta" required:"true"`
|
||||
}
|
||||
|
||||
type RequiredMetricsCheck struct {
|
||||
MissingMetrics []string `json:"missingMetrics" required:"true"`
|
||||
}
|
||||
|
||||
type PostableHosts struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *HostFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
type HostFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByStatus HostStatus `json:"filterByStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures HostsListRequest contains acceptable values.
|
||||
func (req *PostableHosts) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
|
||||
if req.Start <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid start time %d: start must be greater than 0",
|
||||
req.Start,
|
||||
)
|
||||
}
|
||||
|
||||
if req.End <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid end time %d: end must be greater than 0",
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Start >= req.End {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid time range: start (%d) must be less than end (%d)",
|
||||
req.Start,
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Limit < 1 || req.Limit > 5000 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
|
||||
}
|
||||
|
||||
if req.Offset < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil && !req.Filter.FilterByStatus.IsZero() &&
|
||||
req.Filter.FilterByStatus != HostStatusActive && req.Filter.FilterByStatus != HostStatusInactive {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by status: %s", req.Filter.FilterByStatus)
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(HostsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
}
|
||||
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates input immediately after decoding.
|
||||
func (req *PostableHosts) UnmarshalJSON(data []byte) error {
|
||||
type raw PostableHosts
|
||||
var decoded raw
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*req = PostableHosts(decoded)
|
||||
return req.Validate()
|
||||
}
|
||||
37
pkg/types/inframonitoringtypes/hosts_constants.go
Normal file
37
pkg/types/inframonitoringtypes/hosts_constants.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
type HostStatus struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
HostStatusActive = HostStatus{valuer.NewString("active")}
|
||||
HostStatusInactive = HostStatus{valuer.NewString("inactive")}
|
||||
HostStatusNone = HostStatus{valuer.NewString("")}
|
||||
)
|
||||
|
||||
func (HostStatus) Enum() []any {
|
||||
return []any{
|
||||
HostStatusActive,
|
||||
HostStatusInactive,
|
||||
HostStatusNone,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
HostsOrderByCPU = "cpu"
|
||||
HostsOrderByMemory = "memory"
|
||||
HostsOrderByWait = "wait"
|
||||
HostsOrderByDiskUsage = "disk_usage"
|
||||
HostsOrderByLoad15 = "load15"
|
||||
)
|
||||
|
||||
var HostsValidOrderByKeys = []string{
|
||||
HostsOrderByCPU,
|
||||
HostsOrderByMemory,
|
||||
HostsOrderByWait,
|
||||
HostsOrderByDiskUsage,
|
||||
HostsOrderByLoad15,
|
||||
}
|
||||
244
pkg/types/inframonitoringtypes/hosts_test.go
Normal file
244
pkg/types/inframonitoringtypes/hosts_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHostsListRequest_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *PostableHosts
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time zero",
|
||||
req: &PostableHosts{
|
||||
Start: 0,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time negative",
|
||||
req: &PostableHosts{
|
||||
Start: -1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "end time zero",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 0,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time greater than end time",
|
||||
req: &PostableHosts{
|
||||
Start: 2000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time equal to end time",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit zero",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 0,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit negative",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: -10,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit exceeds max",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 5001,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "offset negative",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: -5,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "filter by status ACTIVE",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
Filter: &HostFilter{FilterByStatus: HostStatusActive},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter by status INACTIVE",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
Filter: &HostFilter{FilterByStatus: HostStatusInactive},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter by status empty (zero value)",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter by status invalid value",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
Filter: &HostFilter{FilterByStatus: HostStatus{valuer.NewString("UNKNOWN")}},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy nil is valid",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key cpu and direction asc",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: HostsOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with invalid key",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "unknown",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key but invalid direction",
|
||||
req: &PostableHosts{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: HostsOrderByMemory,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirection{String: valuer.NewString("invalid")},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.req.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Ast(err, errors.TypeInvalidInput), "expected error to be of type InvalidInput")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
pkg/types/inframonitoringtypes/pods.go
Normal file
104
pkg/types/inframonitoringtypes/pods.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type Pods struct {
|
||||
Type ResponseType `json:"type"`
|
||||
Records []PodRecord `json:"records"`
|
||||
Total int `json:"total"`
|
||||
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck"`
|
||||
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention"`
|
||||
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
type PodRecord struct {
|
||||
PodUID string `json:"podUID,omitempty"`
|
||||
PodCPU float64 `json:"podCPU"`
|
||||
PodCPURequest float64 `json:"podCPURequest"`
|
||||
PodCPULimit float64 `json:"podCPULimit"`
|
||||
PodMemory float64 `json:"podMemory"`
|
||||
PodMemoryRequest float64 `json:"podMemoryRequest"`
|
||||
PodMemoryLimit float64 `json:"podMemoryLimit"`
|
||||
PodPhase PodPhase `json:"podPhase"`
|
||||
PodAge int64 `json:"podAge"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
}
|
||||
|
||||
// PostablePods is the request body for the v2 pods list API.
|
||||
type PostablePods struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// Validate ensures PostablePods contains acceptable values.
|
||||
func (req *PostablePods) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
|
||||
if req.Start <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid start time %d: start must be greater than 0",
|
||||
req.Start,
|
||||
)
|
||||
}
|
||||
|
||||
if req.End <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid end time %d: end must be greater than 0",
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Start >= req.End {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid time range: start (%d) must be less than end (%d)",
|
||||
req.Start,
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Limit < 1 || req.Limit > 5000 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
|
||||
}
|
||||
|
||||
if req.Offset < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(PodsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
}
|
||||
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates input immediately after decoding.
|
||||
func (req *PostablePods) UnmarshalJSON(data []byte) error {
|
||||
type raw PostablePods
|
||||
var decoded raw
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*req = PostablePods(decoded)
|
||||
return req.Validate()
|
||||
}
|
||||
45
pkg/types/inframonitoringtypes/pods_constants.go
Normal file
45
pkg/types/inframonitoringtypes/pods_constants.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
type PodPhase struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
PodPhasePending = PodPhase{valuer.NewString("pending")}
|
||||
PodPhaseRunning = PodPhase{valuer.NewString("running")}
|
||||
PodPhaseSucceeded = PodPhase{valuer.NewString("succeeded")}
|
||||
PodPhaseFailed = PodPhase{valuer.NewString("failed")}
|
||||
PodPhaseNone = PodPhase{valuer.NewString("")}
|
||||
)
|
||||
|
||||
func (PodPhase) Enum() []any {
|
||||
return []any{
|
||||
PodPhasePending,
|
||||
PodPhaseRunning,
|
||||
PodPhaseSucceeded,
|
||||
PodPhaseFailed,
|
||||
PodPhaseNone,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
PodsOrderByCPU = "cpu"
|
||||
PodsOrderByCPURequest = "cpu_request"
|
||||
PodsOrderByCPULimit = "cpu_limit"
|
||||
PodsOrderByMemory = "memory"
|
||||
PodsOrderByMemoryRequest = "memory_request"
|
||||
PodsOrderByMemoryLimit = "memory_limit"
|
||||
PodsOrderByPhase = "phase"
|
||||
)
|
||||
|
||||
var PodsValidOrderByKeys = []string{
|
||||
PodsOrderByCPU,
|
||||
PodsOrderByCPURequest,
|
||||
PodsOrderByCPULimit,
|
||||
PodsOrderByMemory,
|
||||
PodsOrderByMemoryRequest,
|
||||
PodsOrderByMemoryLimit,
|
||||
PodsOrderByPhase,
|
||||
}
|
||||
219
pkg/types/inframonitoringtypes/pods_test.go
Normal file
219
pkg/types/inframonitoringtypes/pods_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostablePods_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *PostablePods
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time zero",
|
||||
req: &PostablePods{
|
||||
Start: 0,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time negative",
|
||||
req: &PostablePods{
|
||||
Start: -1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "end time zero",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 0,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time greater than end time",
|
||||
req: &PostablePods{
|
||||
Start: 2000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time equal to end time",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit zero",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 0,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit negative",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: -10,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit exceeds max",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 5001,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "offset negative",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: -5,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy nil is valid",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key cpu and direction asc",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: PodsOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key phase and direction desc",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: PodsOrderByPhase,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with invalid key",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "unknown",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key but invalid direction",
|
||||
req: &PostablePods{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: PodsOrderByMemory,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirection{String: valuer.NewString("invalid")},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.req.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Ast(err, errors.TypeInvalidInput), "expected error to be of type InvalidInput")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
83
pkg/types/templatingtypes/templatingtypes.go
Normal file
83
pkg/types/templatingtypes/templatingtypes.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package templatingtypes holds the data types produced by the markdown
|
||||
// renderers under pkg/templating (e.g. Slack Block Kit JSON DTOs).
|
||||
package templatingtypes
|
||||
|
||||
// SectionBlock represents a Slack section block with mrkdwn text.
|
||||
type SectionBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *TextObject `json:"text"`
|
||||
}
|
||||
|
||||
// DividerBlock represents a Slack divider block.
|
||||
type DividerBlock struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// RichTextBlock is a container for rich text elements (lists, code blocks,
|
||||
// table and cell blocks).
|
||||
type RichTextBlock struct {
|
||||
Type string `json:"type"`
|
||||
Elements []any `json:"elements"`
|
||||
}
|
||||
|
||||
// TableBlock represents a Slack table rendered as a rich_text block with
|
||||
// preformatted text.
|
||||
type TableBlock struct {
|
||||
Type string `json:"type"`
|
||||
Rows [][]TableCell `json:"rows"`
|
||||
}
|
||||
|
||||
// TableCell is a cell in a TableBlock.
|
||||
type TableCell struct {
|
||||
Type string `json:"type"`
|
||||
Elements []any `json:"elements"`
|
||||
}
|
||||
|
||||
// TextObject is the text field inside a SectionBlock.
|
||||
type TextObject struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// RichTextList represents an ordered or unordered list.
|
||||
type RichTextList struct {
|
||||
Type string `json:"type"`
|
||||
Style string `json:"style"`
|
||||
Indent int `json:"indent"`
|
||||
Border int `json:"border"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Elements []any `json:"elements"`
|
||||
}
|
||||
|
||||
// RichTextPreformatted represents a code block.
|
||||
type RichTextPreformatted struct {
|
||||
Type string `json:"type"`
|
||||
Elements []any `json:"elements"`
|
||||
Border int `json:"border"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextInline represents inline text with optional styling, e.g. text
|
||||
// inside a list or a table cell.
|
||||
type RichTextInline struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextLink represents a link inside rich text, e.g. a link inside a list
|
||||
// or a table cell.
|
||||
type RichTextLink struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextStyle toggles inline styling on a rich-text element.
|
||||
type RichTextStyle struct {
|
||||
Bold bool `json:"bold,omitempty"`
|
||||
Italic bool `json:"italic,omitempty"`
|
||||
Strike bool `json:"strike,omitempty"`
|
||||
Code bool `json:"code,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user