mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-08 11:30:32 +01:00
Compare commits
15 Commits
tvats-pkg-
...
feat/servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b9515385 | ||
|
|
382cd57a6a | ||
|
|
fefef70d84 | ||
|
|
9e94ee30b9 | ||
|
|
519355a692 | ||
|
|
55bc8592ed | ||
|
|
8fa566d1d0 | ||
|
|
0b1aba04f4 | ||
|
|
5c8d33290b | ||
|
|
30424a3829 | ||
|
|
d2ede7bb16 | ||
|
|
c622bbd112 | ||
|
|
b92d5e934b | ||
|
|
c20520da5f | ||
|
|
0ccb26cbfc |
@@ -2547,7 +2547,8 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
status:
|
||||
@@ -2597,6 +2598,103 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNodeCondition:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesNodeCountsByReadiness:
|
||||
properties:
|
||||
notReady:
|
||||
type: integer
|
||||
ready:
|
||||
type: integer
|
||||
required:
|
||||
- ready
|
||||
- notReady
|
||||
type: object
|
||||
InframonitoringtypesNodeRecord:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
nodeCPU:
|
||||
format: double
|
||||
type: number
|
||||
nodeCPUAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeCountsByReadiness:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
|
||||
nodeMemory:
|
||||
format: double
|
||||
type: number
|
||||
nodeMemoryAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeName:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- nodeCPU
|
||||
- nodeCPUAllocatable
|
||||
- nodeMemory
|
||||
- nodeMemoryAllocatable
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNodes:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
|
||||
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
|
||||
InframonitoringtypesPodCountsByPhase:
|
||||
properties:
|
||||
failed:
|
||||
type: integer
|
||||
pending:
|
||||
type: integer
|
||||
running:
|
||||
type: integer
|
||||
succeeded:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
required:
|
||||
- pending
|
||||
- running
|
||||
- succeeded
|
||||
- failed
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesPodPhase:
|
||||
enum:
|
||||
- pending
|
||||
@@ -2604,18 +2702,15 @@ components:
|
||||
- succeeded
|
||||
- failed
|
||||
- unknown
|
||||
- ""
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesPodRecord:
|
||||
properties:
|
||||
failedPodCount:
|
||||
type: integer
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
pendingPodCount:
|
||||
type: integer
|
||||
podAge:
|
||||
format: int64
|
||||
type: integer
|
||||
@@ -2628,6 +2723,8 @@ components:
|
||||
podCPURequest:
|
||||
format: double
|
||||
type: number
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podMemory:
|
||||
format: double
|
||||
type: number
|
||||
@@ -2641,12 +2738,6 @@ components:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
|
||||
podUID:
|
||||
type: string
|
||||
runningPodCount:
|
||||
type: integer
|
||||
succeededPodCount:
|
||||
type: integer
|
||||
unknownPodCount:
|
||||
type: integer
|
||||
required:
|
||||
- podUID
|
||||
- podCPU
|
||||
@@ -2656,11 +2747,7 @@ components:
|
||||
- podMemoryRequest
|
||||
- podMemoryLimit
|
||||
- podPhase
|
||||
- pendingPodCount
|
||||
- runningPodCount
|
||||
- succeededPodCount
|
||||
- failedPodCount
|
||||
- unknownPodCount
|
||||
- podCountsByPhase
|
||||
- podAge
|
||||
- meta
|
||||
type: object
|
||||
@@ -2714,6 +2801,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableNodes:
|
||||
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
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostablePods:
|
||||
properties:
|
||||
end:
|
||||
@@ -11618,12 +11731,83 @@ paths:
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/nodes:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
|
||||
CPU usage, CPU allocatable, memory working set, memory allocatable, per-group
|
||||
nodeCountsByReadiness ({ ready, notReady } from each node''s latest k8s.node.condition_ready
|
||||
in the window) and per-group podCountsByPhase ({ pending, running, succeeded,
|
||||
failed, unknown } for pods scheduled on the listed nodes). Each node includes
|
||||
metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is
|
||||
''list'' for the default k8s.node.name grouping (each row is one node with
|
||||
its current condition string: ready / not_ready / no_data) or ''grouped_list''
|
||||
for custom groupBy keys (each row aggregates nodes in the group; condition
|
||||
stays no_data). Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
operationId: ListNodes
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodes'
|
||||
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 Nodes 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/unknown),
|
||||
utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data),
|
||||
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
|
||||
@@ -11631,13 +11815,13 @@ paths:
|
||||
cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit.
|
||||
The response type is ''list'' for the default k8s.pod.uid grouping (each row
|
||||
is one pod with its current phase) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates pods in the group with per-phase counts: pendingPodCount,
|
||||
runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived
|
||||
from each pod''s latest phase in the window). Also reports missing required
|
||||
metrics and whether the requested time range falls before the data retention
|
||||
boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory,
|
||||
podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no
|
||||
data is available for that field.'
|
||||
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
|
||||
{ pending, running, succeeded, failed, unknown } derived from each pod''s
|
||||
latest phase in the window). Also reports missing required metrics and whether
|
||||
the requested time range falls before the data retention boundary. Numeric
|
||||
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
|
||||
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
operationId: ListPods
|
||||
requestBody:
|
||||
content:
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@ant-design/icons": "4.8.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/lang-javascript": "6.2.3",
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@dnd-kit/core": "6.1.0",
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@visx/shape": "3.5.0",
|
||||
"@visx/tooltip": "3.3.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@xyflow/react": "12.10.2",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
@@ -114,7 +116,6 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-drag-listview": "2.0.0",
|
||||
"react-error-boundary": "4.0.11",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-full-screen": "1.1.1",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
"react-helmet-async": "1.3.0",
|
||||
@@ -239,10 +240,12 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.(js|jsx|ts|tsx)": [
|
||||
"oxlint --fix",
|
||||
"oxfmt --write",
|
||||
"sh -c tsgo --noEmit"
|
||||
],
|
||||
"*.(js|jsx|ts|tsx|scss|css)": [
|
||||
"oxlint --fix --quiet --no-error-on-unmatched-pattern",
|
||||
"oxfmt --write"
|
||||
],
|
||||
"*.(scss|css)": [
|
||||
"stylelint"
|
||||
]
|
||||
|
||||
@@ -13,8 +13,10 @@ import type {
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
InframonitoringtypesPostableNodesDTO,
|
||||
InframonitoringtypesPostablePodsDTO,
|
||||
ListHosts200,
|
||||
ListNodes200,
|
||||
ListPods200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -107,7 +109,91 @@ export const useListHosts = <
|
||||
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/unknown), 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 six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const listNodes = (
|
||||
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNodes200>({
|
||||
url: `/api/v2/infra_monitoring/nodes`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableNodesDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNodesMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listNodes'];
|
||||
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 listNodes>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listNodes(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListNodesMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNodes>>
|
||||
>;
|
||||
export type ListNodesMutationBody =
|
||||
BodyType<InframonitoringtypesPostableNodesDTO>;
|
||||
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const useListNodes = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListNodesMutationOptions(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/unknown/no_data), 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 six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const listPods = (
|
||||
|
||||
@@ -4579,7 +4579,7 @@ export interface InframonitoringtypesHostFilterDTO {
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesHostRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesHostRecordDTO {
|
||||
@@ -4652,35 +4652,127 @@ export interface InframonitoringtypesHostsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesNodeConditionDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesNodeCountsByReadinessDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
notReady: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
ready: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesNodeRecordDTOMeta = {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesNodeRecordDTO {
|
||||
condition: InframonitoringtypesNodeConditionDTO;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesNodeRecordDTOMeta;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPUAllocatable: number;
|
||||
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemoryAllocatable: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nodeName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNodesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesNodeRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodCountsByPhaseDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pending: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
succeeded: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesPodPhaseDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
succeeded = 'succeeded',
|
||||
failed = 'failed',
|
||||
unknown = 'unknown',
|
||||
'' = '',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesPodRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesPodRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failedPodCount: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesPodRecordDTOMeta;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pendingPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -4701,6 +4793,7 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
* @format double
|
||||
*/
|
||||
podCPURequest: number;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -4721,18 +4814,6 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
* @type string
|
||||
*/
|
||||
podUID: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
runningPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
succeededPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknownPodCount: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodsDTO {
|
||||
@@ -4782,6 +4863,34 @@ export interface InframonitoringtypesPostableHostsDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableNodesDTO {
|
||||
/**
|
||||
* @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 InframonitoringtypesPostablePodsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -9133,6 +9242,14 @@ export type ListHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNodes200 = {
|
||||
data: InframonitoringtypesNodesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPods200 = {
|
||||
data: InframonitoringtypesPodsDTO;
|
||||
/**
|
||||
|
||||
@@ -589,6 +589,16 @@ function TanStackTableInner<TData>(
|
||||
{showPagination && pagination && (
|
||||
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
|
||||
{prefixPaginationContent}
|
||||
{pagination.showTotalCount && effectiveTotalCount > 0 && (
|
||||
<span
|
||||
className={viewStyles.paginationTotalCount}
|
||||
data-testid="pagination-total-count"
|
||||
>
|
||||
Showing {(page - 1) * limit + 1} -{' '}
|
||||
{Math.min(page * limit, effectiveTotalCount)} of {effectiveTotalCount}
|
||||
{pagination.totalCountLabel ? ` ${pagination.totalCountLabel}` : ''}
|
||||
</span>
|
||||
)}
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={limit}
|
||||
|
||||
@@ -117,6 +117,10 @@
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationPageSize {
|
||||
@@ -124,6 +128,11 @@
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.paginationTotalCount {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.tanstackLoadingOverlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -188,6 +188,87 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders total count when showTotalCount is true', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 100,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const totalCount = screen.getByTestId('pagination-total-count');
|
||||
expect(totalCount).toBeInTheDocument();
|
||||
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 100');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders total count with label when totalCountLabel is provided', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 50,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: 'Pods',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const totalCount = screen.getByTestId('pagination-total-count');
|
||||
expect(totalCount).toBeInTheDocument();
|
||||
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 50 Pods');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render total count when showTotalCount is false', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 100,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('pagination-total-count'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render total count when total is 0', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 0,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('pagination-total-count'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
|
||||
@@ -115,6 +115,8 @@ export type PaginationProps = {
|
||||
total: number;
|
||||
defaultPage?: number;
|
||||
defaultLimit?: number;
|
||||
showTotalCount?: boolean;
|
||||
totalCountLabel?: string;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
|
||||
@@ -286,6 +286,8 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
total: totalCount,
|
||||
defaultLimit: 10,
|
||||
defaultPage: 1,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: entity.charAt(0).toUpperCase() + entity.slice(1),
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
/>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/* eslint-disable */
|
||||
//@ts-nocheck
|
||||
import { memo } from 'react';
|
||||
import ForceGraph2D from 'react-force-graph-2d';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { getGraphData, getTooltip, transformLabel } from './utils';
|
||||
|
||||
function ServiceMap({ fgRef, serviceMap }: any): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { nodes, links } = getGraphData(serviceMap, isDarkMode);
|
||||
|
||||
const graphData = { nodes, links };
|
||||
|
||||
let zoomLevel = 1;
|
||||
|
||||
return (
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
cooldownTicks={100}
|
||||
graphData={graphData}
|
||||
linkLabel={getTooltip}
|
||||
linkAutoColorBy={(d) => d.target}
|
||||
linkDirectionalParticles="value"
|
||||
linkDirectionalParticleSpeed={(d) => d.value}
|
||||
nodeCanvasObject={(node, ctx) => {
|
||||
const label = transformLabel(node.id, zoomLevel);
|
||||
let { fontSize } = node;
|
||||
fontSize = (fontSize * 3) / zoomLevel;
|
||||
ctx.font = `${fontSize}px Roboto`;
|
||||
const { width } = node;
|
||||
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, width, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = isDarkMode ? '#ffffff' : '#000000';
|
||||
ctx.fillText(label, node.x, node.y);
|
||||
}}
|
||||
onLinkHover={(node) => {
|
||||
const tooltip = document.querySelector('.graph-tooltip');
|
||||
if (tooltip && node) {
|
||||
tooltip.innerHTML = getTooltip(node);
|
||||
}
|
||||
}}
|
||||
onZoom={(zoom) => {
|
||||
zoomLevel = zoom.k;
|
||||
}}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServiceMap);
|
||||
@@ -1,6 +1,6 @@
|
||||
//@ts-nocheck
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { connect } from 'react-redux';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
@@ -16,27 +16,9 @@ import { AppState } from 'store/reducers';
|
||||
import styled from 'styled-components';
|
||||
import { GlobalTime } from 'types/actions/globalTime';
|
||||
|
||||
import Map from './Map';
|
||||
import Map from './components/Map/Map';
|
||||
|
||||
const Container = styled.div`
|
||||
.force-graph-container {
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.force-graph-container .graph-tooltip {
|
||||
background: black;
|
||||
padding: 1px;
|
||||
.keyval {
|
||||
display: flex;
|
||||
.key {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.val {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const Container = styled.div``;
|
||||
|
||||
interface ServiceMapProps extends RouteComponentProps<any> {
|
||||
serviceMap: ServiceMapStore;
|
||||
@@ -48,7 +30,8 @@ interface ServiceMapProps extends RouteComponentProps<any> {
|
||||
}
|
||||
interface graphNode {
|
||||
id: string;
|
||||
group: number;
|
||||
status: 'healthy' | 'error';
|
||||
name: string;
|
||||
}
|
||||
interface graphLink {
|
||||
source: string;
|
||||
@@ -64,8 +47,6 @@ export interface graphDataType {
|
||||
}
|
||||
|
||||
function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
const fgRef = useRef();
|
||||
|
||||
const { getDetailedServiceMapItems, globalTime, serviceMap } = props;
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
@@ -78,10 +59,6 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
getDetailedServiceMapItems(globalTime, queries);
|
||||
}, [globalTime, getDetailedServiceMapItems, queries]);
|
||||
|
||||
useEffect(() => {
|
||||
fgRef.current && fgRef.current.d3Force('charge').strength(-400);
|
||||
});
|
||||
|
||||
if (serviceMap.loading) {
|
||||
return <Spinner size="large" tip="Loading..." />;
|
||||
}
|
||||
@@ -108,7 +85,7 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
|
||||
}
|
||||
/>
|
||||
|
||||
<Map fgRef={fgRef} serviceMap={serviceMap} />
|
||||
<Map serviceMap={serviceMap} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Edge, EdgeProps, getBezierPath } from '@xyflow/react';
|
||||
|
||||
import { EDGE_DASH_PERIOD, getDashAnimation } from '../Map/Map.constants';
|
||||
|
||||
export interface FlowEdgeData extends Record<string, unknown> {
|
||||
p99: number;
|
||||
callRate: number;
|
||||
errorRate: number;
|
||||
maxCallRate: number;
|
||||
}
|
||||
|
||||
// Matches @xyflow/react's BaseEdge default — the wider transparent path that
|
||||
// catches hover for the tooltip even though the visible dashes are thin.
|
||||
const INTERACTION_WIDTH = 20;
|
||||
|
||||
function FlowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps<Edge<FlowEdgeData>>): JSX.Element {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const callRate = data?.callRate ?? 0;
|
||||
const maxCallRate = data?.maxCallRate ?? 0;
|
||||
const { duration } = getDashAnimation(callRate, maxCallRate);
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
id={id}
|
||||
className="react-flow__edge-path"
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
style={style}
|
||||
markerEnd={markerEnd}
|
||||
>
|
||||
{duration > 0 && (
|
||||
// Positive `stroke-dashoffset` shifts the dash pattern toward the
|
||||
// path's start, so visually the dashes flow target -> source
|
||||
// (callee -> caller), matching the original particle direction.
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="0"
|
||||
to={EDGE_DASH_PERIOD}
|
||||
dur={`${duration}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</path>
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
strokeOpacity={0}
|
||||
strokeWidth={INTERACTION_WIDTH}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowEdge;
|
||||
@@ -0,0 +1,27 @@
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
padding: 12px;
|
||||
min-width: 160px;
|
||||
font-size: 12px;
|
||||
font-family: Inter, sans-serif;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
color: var(--popover-foreground);
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import styles from './LinkTooltip.module.scss';
|
||||
|
||||
export interface LinkTooltipData {
|
||||
p99: string | number;
|
||||
callRate: string | number;
|
||||
errorRate: string | number;
|
||||
}
|
||||
|
||||
export interface LinkTooltipProps {
|
||||
tooltip: LinkTooltipData;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const POINTER_OFFSET = 12;
|
||||
|
||||
function LinkTooltip({ tooltip, x, y }: LinkTooltipProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ top: y + POINTER_OFFSET, left: x + POINTER_OFFSET }}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>P99 latency:</span>
|
||||
<span className={styles.value}>{tooltip.p99}ms</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Request:</span>
|
||||
<span className={styles.value}>{tooltip.callRate}/sec</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Error Rate:</span>
|
||||
<span className={styles.value}>{tooltip.errorRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkTooltip;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Geometry of a service node as drawn on the map. Pills are split into a
|
||||
// colored icon box and a tinted body; the small monospace service-id label
|
||||
// rendered above the pill is what `NODE_OUTER_HEIGHT` accounts for so dagre
|
||||
// reserves enough vertical room and `Map.tsx` can centre the visual mass on
|
||||
// the simulated coordinate.
|
||||
export const NODE_WIDTH = 184;
|
||||
export const NODE_HEIGHT = 58;
|
||||
export const LABEL_HEIGHT = 16;
|
||||
export const NODE_LABEL_GAP = 6;
|
||||
export const NODE_OUTER_HEIGHT = NODE_HEIGHT + LABEL_HEIGHT + NODE_LABEL_GAP;
|
||||
|
||||
// Edge dash pattern. `EDGE_DASH_PERIOD` (dash + gap) is the loop distance the
|
||||
// marching-dash animation must travel for a seamless wrap; deriving it from
|
||||
// the parts keeps it locked to the dasharray.
|
||||
const EDGE_DASH_LENGTH = 5;
|
||||
const EDGE_DASH_GAP = 4;
|
||||
export const EDGE_DASH_ARRAY = `${EDGE_DASH_LENGTH} ${EDGE_DASH_GAP}`;
|
||||
export const EDGE_DASH_PERIOD = EDGE_DASH_LENGTH + EDGE_DASH_GAP;
|
||||
|
||||
// Per-edge marching-dash speed scales with the edge's call rate *relative to
|
||||
// the busiest edge in the current graph*, on a log10 ladder. The busiest edge
|
||||
// always pegs the fastest march; the slowest gets a slow drift. This keeps
|
||||
// the visualisation legible whether the busiest service handles 5 req/sec or 5k.
|
||||
export const DASH_FAST_SECS = 0.2;
|
||||
export const DASH_SLOW_SECS = 1.1;
|
||||
|
||||
// Compute per-period duration for an edge's call rate, scaled against the max
|
||||
// call rate observed across the graph. A duration of 0 means "no call rate,
|
||||
// don't animate". Pure so it can be unit-tested without rendering the edge.
|
||||
export function getDashAnimation(
|
||||
callRate: number,
|
||||
maxCallRate: number,
|
||||
): { duration: number } {
|
||||
if (callRate <= 0) {
|
||||
return { duration: 0 };
|
||||
}
|
||||
// Defensive: if a stale/zero max sneaks in, treat this edge as the max so
|
||||
// `factor` stays in [0, 1] rather than going to Infinity or NaN.
|
||||
const effectiveMax = Math.max(maxCallRate, callRate);
|
||||
const logRate = Math.log10(callRate + 1);
|
||||
const logMax = Math.log10(effectiveMax + 1);
|
||||
const factor = logMax > 0 ? logRate / logMax : 1;
|
||||
const duration = DASH_SLOW_SECS - factor * (DASH_SLOW_SECS - DASH_FAST_SECS);
|
||||
return { duration };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 124px);
|
||||
position: relative;
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
// ReactFlow defaults edge pointer-events to `visibleStroke`, which means
|
||||
// our thin dashed line only captures hover on the painted dash segments.
|
||||
// Force `stroke` on the wide invisible interaction path so the entire edge
|
||||
// length is hoverable for the tooltip.
|
||||
:global(.react-flow__edge-interaction) {
|
||||
pointer-events: stroke;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.react-flow__edge) {
|
||||
pointer-events: stroke;
|
||||
}
|
||||
}
|
||||
286
frontend/src/modules/Servicemap/components/Map/Map.tsx
Normal file
286
frontend/src/modules/Servicemap/components/Map/Map.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Edge,
|
||||
Node,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
|
||||
import {
|
||||
EDGE_DASH_ARRAY,
|
||||
NODE_OUTER_HEIGHT,
|
||||
NODE_WIDTH,
|
||||
} from './Map.constants';
|
||||
import styles from './Map.module.scss';
|
||||
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
|
||||
import LinkTooltip from '../LinkTooltip/LinkTooltip';
|
||||
import {
|
||||
computeNodePositions,
|
||||
getEdgeColor,
|
||||
getGraphData,
|
||||
getLinkTooltip,
|
||||
LinkTooltip as LinkTooltipData,
|
||||
} from '../../utils';
|
||||
|
||||
const nodeTypes = { service: ServiceNode };
|
||||
const edgeTypes = { flow: FlowEdge };
|
||||
|
||||
const BG_COLOR = 'var(--l2-background)';
|
||||
|
||||
const BASE_EDGE_STYLE = {
|
||||
strokeWidth: 1.25,
|
||||
strokeDasharray: EDGE_DASH_ARRAY,
|
||||
};
|
||||
interface HoverState {
|
||||
tooltip: LinkTooltipData;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Opacity applied to dimmed nodes/edges while a node is being hovered. Picked
|
||||
// to push background elements far enough out of focus that the highlighted
|
||||
// neighborhood reads as a single cluster, without making them unreadable.
|
||||
const DIM_NODE_OPACITY = 0.18;
|
||||
const DIM_EDGE_OPACITY = 0.12;
|
||||
const DIM_TRANSITION = 'opacity 0.15s ease-out';
|
||||
|
||||
function ServiceMap({ serviceMap }: any): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [hovered, setHovered] = useState<HoverState | null>(null);
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
|
||||
const { nodes: rawNodes, links } = useMemo(
|
||||
() => getGraphData(serviceMap, isDarkMode),
|
||||
[serviceMap, isDarkMode],
|
||||
);
|
||||
|
||||
const positions = useMemo(
|
||||
() => computeNodePositions(rawNodes, links),
|
||||
[rawNodes, links],
|
||||
);
|
||||
|
||||
const initialNodes: Node<ServiceNodeData>[] = useMemo(
|
||||
() =>
|
||||
rawNodes.map((node) => {
|
||||
const center = positions[node.id] ?? { x: 0, y: 0 };
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'service',
|
||||
// `position` is the top-left of the node bounding box; centre the
|
||||
// pill (plus its above-label) on the simulated coordinate.
|
||||
position: {
|
||||
x: center.x - NODE_WIDTH / 2,
|
||||
y: center.y - NODE_OUTER_HEIGHT / 2,
|
||||
},
|
||||
data: { label: node.id, status: node.status },
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
};
|
||||
}),
|
||||
[rawNodes, positions],
|
||||
);
|
||||
|
||||
// Dash march speed is scaled relative to the busiest edge in the current
|
||||
// graph, so each render of the edge layer needs the per-graph max.
|
||||
const maxCallRate = useMemo(
|
||||
() => links.reduce((max, link) => Math.max(max, link.callRate ?? 0), 0),
|
||||
[links],
|
||||
);
|
||||
|
||||
// Edge stroke is driven by the target node's health, so build a quick
|
||||
// lookup once per graph to avoid an O(n) scan per edge.
|
||||
const nodeStatusById = useMemo(() => {
|
||||
const map: Record<string, 'healthy' | 'error'> = {};
|
||||
rawNodes.forEach((node) => {
|
||||
map[node.id] = node.status;
|
||||
});
|
||||
return map;
|
||||
}, [rawNodes]);
|
||||
|
||||
const initialEdges: Edge<FlowEdgeData>[] = useMemo(
|
||||
() =>
|
||||
links.map((link, i) => ({
|
||||
id: `${link.source}->${link.target}-${i}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
type: 'flow',
|
||||
data: {
|
||||
p99: link.p99,
|
||||
callRate: link.callRate,
|
||||
errorRate: link.errorRate,
|
||||
maxCallRate,
|
||||
},
|
||||
style: {
|
||||
...BASE_EDGE_STYLE,
|
||||
stroke: getEdgeColor(nodeStatusById[link.target] ?? 'healthy'),
|
||||
},
|
||||
})),
|
||||
[links, maxCallRate, nodeStatusById],
|
||||
);
|
||||
|
||||
const [flowNodes, setFlowNodes, onNodesChange] =
|
||||
useNodesState<Node<ServiceNodeData>>(initialNodes);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] =
|
||||
useEdgesState<Edge<FlowEdgeData>>(initialEdges);
|
||||
|
||||
// Reset internal node/edge state when the source graph changes (filters,
|
||||
// time range, theme). User drag positions during a stable graph are kept.
|
||||
useEffect(() => {
|
||||
setFlowNodes(initialNodes);
|
||||
}, [initialNodes, setFlowNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlowEdges(initialEdges);
|
||||
}, [initialEdges, setFlowEdges]);
|
||||
|
||||
// Undirected adjacency: when hovering a node we want the cluster of its
|
||||
// callers AND callees lit, regardless of the edge's source/target order.
|
||||
const adjacency = useMemo(() => {
|
||||
const map: Record<string, Set<string>> = {};
|
||||
links.forEach((link) => {
|
||||
(map[link.source] ??= new Set()).add(link.target);
|
||||
(map[link.target] ??= new Set()).add(link.source);
|
||||
});
|
||||
return map;
|
||||
}, [links]);
|
||||
|
||||
const isNodeHighlighted = useCallback(
|
||||
(id: string): boolean => {
|
||||
if (!hoveredNodeId) {
|
||||
return true;
|
||||
}
|
||||
if (id === hoveredNodeId) {
|
||||
return true;
|
||||
}
|
||||
return adjacency[hoveredNodeId]?.has(id) ?? false;
|
||||
},
|
||||
[hoveredNodeId, adjacency],
|
||||
);
|
||||
|
||||
const isEdgeHighlighted = useCallback(
|
||||
(source: string, target: string): boolean => {
|
||||
if (!hoveredNodeId) {
|
||||
return true;
|
||||
}
|
||||
// Only edges directly touching the hovered node stay lit; edges
|
||||
// between two of its neighbours are dimmed too.
|
||||
return source === hoveredNodeId || target === hoveredNodeId;
|
||||
},
|
||||
[hoveredNodeId],
|
||||
);
|
||||
|
||||
// Display lists wrap the live `flowNodes`/`flowEdges` with a per-element
|
||||
// opacity derived from the hover state. Keeping this separate from the
|
||||
// state setters means a hover doesn't perturb drag positions or the
|
||||
// re-init useEffect above.
|
||||
const displayNodes = useMemo(
|
||||
() =>
|
||||
flowNodes.map((node) => ({
|
||||
...node,
|
||||
style: {
|
||||
...node.style,
|
||||
opacity: isNodeHighlighted(node.id) ? 1 : DIM_NODE_OPACITY,
|
||||
transition: DIM_TRANSITION,
|
||||
},
|
||||
})),
|
||||
[flowNodes, isNodeHighlighted],
|
||||
);
|
||||
|
||||
const displayEdges = useMemo(
|
||||
() =>
|
||||
flowEdges.map((edge) => ({
|
||||
...edge,
|
||||
style: {
|
||||
...edge.style,
|
||||
opacity: isEdgeHighlighted(edge.source, edge.target)
|
||||
? 1
|
||||
: DIM_EDGE_OPACITY,
|
||||
transition: DIM_TRANSITION,
|
||||
},
|
||||
})),
|
||||
[flowEdges, isEdgeHighlighted],
|
||||
);
|
||||
|
||||
const handleNodeMouseEnter = useCallback(
|
||||
(_event: React.MouseEvent, node: Node): void => {
|
||||
setHoveredNodeId(node.id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNodeMouseLeave = useCallback((): void => {
|
||||
setHoveredNodeId(null);
|
||||
}, []);
|
||||
|
||||
const handleEdgeMouseEnter = (event: React.MouseEvent, edge: Edge): void => {
|
||||
setHovered({
|
||||
tooltip: getLinkTooltip(edge.data as any),
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdgeMouseMove = (event: React.MouseEvent, edge: Edge): void => {
|
||||
setHovered((prev) =>
|
||||
prev
|
||||
? { ...prev, x: event.clientX, y: event.clientY }
|
||||
: {
|
||||
tooltip: getLinkTooltip(edge.data as any),
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdgeMouseLeave = (): void => {
|
||||
setHovered(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={displayEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
minZoom={0.2}
|
||||
maxZoom={4}
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode={isDarkMode ? 'dark' : 'light'}
|
||||
onNodeMouseEnter={handleNodeMouseEnter}
|
||||
onNodeMouseLeave={handleNodeMouseLeave}
|
||||
onEdgeMouseEnter={handleEdgeMouseEnter}
|
||||
onEdgeMouseMove={handleEdgeMouseMove}
|
||||
onEdgeMouseLeave={handleEdgeMouseLeave}
|
||||
>
|
||||
<Background
|
||||
bgColor={BG_COLOR}
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
{hovered && (
|
||||
<LinkTooltip tooltip={hovered.tooltip} x={hovered.x} y={hovered.y} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServiceMap);
|
||||
@@ -0,0 +1,110 @@
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.id {
|
||||
// `max-width` is set inline from `NODE_WIDTH` so truncation stays in
|
||||
// lockstep with the pill below.
|
||||
margin-bottom: 6px;
|
||||
padding-left: 4px;
|
||||
font-family: 'Geist Mono', 'SF Mono', 'Roboto Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
position: relative;
|
||||
color: var(--l1-foreground);
|
||||
// Frosted glass: the .healthy/.error backgrounds are intentionally
|
||||
// translucent (color-mix with transparent), so this blurs whatever sits
|
||||
// behind the pill — the dotted background grid and any edges that pass
|
||||
// through the node bounds — for a softer fill.
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.healthy {
|
||||
border-color: var(--success-background);
|
||||
background: color-mix(in srgb, var(--success-background) 32%, transparent);
|
||||
|
||||
.iconBox {
|
||||
background: var(--success-background);
|
||||
color: var(--l1-background);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: var(--danger-background);
|
||||
background: color-mix(in srgb, var(--danger-background) 32%, transparent);
|
||||
|
||||
.iconBox {
|
||||
background: var(--danger-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.iconBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.handle {
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Handle, Node, NodeProps, Position } from '@xyflow/react';
|
||||
import cx from 'classnames';
|
||||
import { HardDrive } from '@signozhq/icons';
|
||||
|
||||
import { NODE_HEIGHT, NODE_WIDTH } from '../Map/Map.constants';
|
||||
import styles from './ServiceNode.module.scss';
|
||||
|
||||
export type ServiceNodeStatus = 'healthy' | 'error';
|
||||
|
||||
export interface ServiceNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
status: ServiceNodeStatus;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 24;
|
||||
|
||||
// Render a friendlier name inside the pill: split on hyphens / underscores /
|
||||
// whitespace and title-case each word. The lowercase service id is preserved
|
||||
// above the pill for unambiguous lookup.
|
||||
function formatDisplayName(label: string): string {
|
||||
const parts = label.split(/[-_\s]+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return label;
|
||||
}
|
||||
return parts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function ServiceNode({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
|
||||
const { status, label } = data;
|
||||
const displayName = formatDisplayName(label);
|
||||
const statusLabel = status === 'error' ? 'Errors' : 'Healthy';
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.id} title={label} style={{ maxWidth: NODE_WIDTH }}>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
data-testid="service-node-pill"
|
||||
className={cx(styles.pill, styles[status])}
|
||||
style={{ width: NODE_WIDTH, height: NODE_HEIGHT }}
|
||||
>
|
||||
<div className={styles.iconBox}>
|
||||
<HardDrive size={ICON_SIZE} strokeWidth={1.75} aria-hidden />
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.name}>{displayName}</div>
|
||||
<div className={styles.statusText}>{statusLabel}</div>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} className={styles.handle} />
|
||||
<Handle type="source" position={Position.Right} className={styles.handle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceNode;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Position } from '@xyflow/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
|
||||
import { EDGE_DASH_PERIOD } from '../Map/Map.constants';
|
||||
|
||||
// Stub getBezierPath so assertions don't depend on the internal path geometry
|
||||
// — we only care that FlowEdge wires its inputs through and animates the
|
||||
// stroke-dashoffset at a relative speed.
|
||||
jest.mock('@xyflow/react', () => {
|
||||
const actual = jest.requireActual('@xyflow/react');
|
||||
return {
|
||||
...actual,
|
||||
getBezierPath: ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
}: {
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
}): [string, number, number, number, number] => [
|
||||
`M${sourceX},${sourceY} L${targetX},${targetY}`,
|
||||
(sourceX + targetX) / 2,
|
||||
(sourceY + targetY) / 2,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const baseEdgeProps = {
|
||||
id: 'edge-1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
targetX: 100,
|
||||
targetY: 0,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
style: { stroke: '#000' },
|
||||
markerEnd: 'url(#arrow)',
|
||||
} as const;
|
||||
|
||||
function renderEdge(data: FlowEdgeData | undefined): ReturnType<typeof render> {
|
||||
return render(<FlowEdge {...(baseEdgeProps as any)} data={data} />);
|
||||
}
|
||||
|
||||
const SAMPLE_DATA: FlowEdgeData = {
|
||||
p99: 1000000,
|
||||
callRate: 25,
|
||||
errorRate: 0,
|
||||
maxCallRate: 1000,
|
||||
};
|
||||
|
||||
function getVisiblePath(container: HTMLElement): SVGPathElement {
|
||||
const path = container.querySelector<SVGPathElement>(
|
||||
'path.react-flow__edge-path',
|
||||
);
|
||||
if (!path) {
|
||||
throw new Error('expected to find react-flow__edge-path path');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('FlowEdge', () => {
|
||||
it('forwards id, path, style, and markerEnd to the visible edge path', () => {
|
||||
const { container } = renderEdge(SAMPLE_DATA);
|
||||
|
||||
const visible = getVisiblePath(container);
|
||||
expect(visible).toHaveAttribute('id', 'edge-1');
|
||||
// Visible path uses the forward bezier (source -> target).
|
||||
expect(visible).toHaveAttribute('d', 'M0,0 L100,0');
|
||||
expect(visible).toHaveAttribute('marker-end', 'url(#arrow)');
|
||||
expect(visible).toHaveStyle({ stroke: '#000' });
|
||||
});
|
||||
|
||||
it('renders a transparent wider interaction path so hover is robust', () => {
|
||||
// Without this, react-flow's default hover wouldn't be triggered and
|
||||
// the link tooltip would only appear when the cursor lands on a 1.25px
|
||||
// painted dash segment.
|
||||
const { container } = renderEdge(SAMPLE_DATA);
|
||||
|
||||
const interaction = container.querySelector<SVGPathElement>(
|
||||
'path.react-flow__edge-interaction',
|
||||
);
|
||||
expect(interaction).not.toBeNull();
|
||||
expect(interaction).toHaveAttribute('d', 'M0,0 L100,0');
|
||||
expect(interaction).toHaveAttribute('stroke-opacity', '0');
|
||||
});
|
||||
|
||||
it('omits the dash animation when callRate is zero', () => {
|
||||
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 0 });
|
||||
|
||||
expect(container.querySelectorAll('animate')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('omits the dash animation when data is missing', () => {
|
||||
const { container } = renderEdge(undefined);
|
||||
|
||||
expect(container.querySelectorAll('animate')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('animates stroke-dashoffset by exactly one dash period so the loop is seamless', () => {
|
||||
const { container } = renderEdge(SAMPLE_DATA);
|
||||
|
||||
const animate = container.querySelector('animate');
|
||||
expect(animate).not.toBeNull();
|
||||
expect(animate).toHaveAttribute('attributeName', 'stroke-dashoffset');
|
||||
expect(animate).toHaveAttribute('from', '0');
|
||||
expect(animate).toHaveAttribute('to', String(EDGE_DASH_PERIOD));
|
||||
expect(animate).toHaveAttribute('repeatCount', 'indefinite');
|
||||
});
|
||||
|
||||
it('sets a faster dash duration for the busiest edge than for a quieter one', () => {
|
||||
// Relative scaling: same maxCallRate, higher callRate -> shorter period.
|
||||
const { container: busy } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 1000,
|
||||
maxCallRate: 1000,
|
||||
});
|
||||
const { container: quiet } = renderEdge({
|
||||
...SAMPLE_DATA,
|
||||
callRate: 1,
|
||||
maxCallRate: 1000,
|
||||
});
|
||||
|
||||
const parseDur = (root: HTMLElement): number => {
|
||||
const animate = root.querySelector('animate');
|
||||
const dur = animate?.getAttribute('dur') ?? '';
|
||||
return parseFloat(dur);
|
||||
};
|
||||
|
||||
expect(parseDur(busy)).toBeLessThan(parseDur(quiet));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import LinkTooltip, { LinkTooltipData } from '../LinkTooltip/LinkTooltip';
|
||||
|
||||
const baseTooltip: LinkTooltipData = {
|
||||
p99: 12.34,
|
||||
callRate: 5.6,
|
||||
errorRate: 0.1,
|
||||
};
|
||||
|
||||
describe('LinkTooltip', () => {
|
||||
it('renders p99, request, and error rate rows with their suffixes', () => {
|
||||
render(<LinkTooltip tooltip={baseTooltip} x={0} y={0} />);
|
||||
|
||||
expect(screen.getByText('P99 latency:')).toBeInTheDocument();
|
||||
expect(screen.getByText('12.34ms')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Request:')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.6/sec')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Error Rate:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.1%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string-typed metric values verbatim', () => {
|
||||
render(
|
||||
<LinkTooltip
|
||||
tooltip={{ p99: '0', callRate: '0', errorRate: '0' }}
|
||||
x={0}
|
||||
y={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('0ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('0/sec')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions itself offset from the cursor coordinates', () => {
|
||||
const { container } = render(
|
||||
<LinkTooltip tooltip={baseTooltip} x={100} y={200} />,
|
||||
);
|
||||
|
||||
// POINTER_OFFSET is 12 in the component; the tooltip should sit at
|
||||
// (x + 12, y + 12) so it does not occlude the hovered edge segment.
|
||||
const tooltip = container.firstChild as HTMLElement;
|
||||
expect(tooltip).toHaveStyle({ top: '212px', left: '112px' });
|
||||
});
|
||||
|
||||
it('handles negative coordinates without breaking the offset math', () => {
|
||||
const { container } = render(
|
||||
<LinkTooltip tooltip={baseTooltip} x={-50} y={-30} />,
|
||||
);
|
||||
|
||||
const tooltip = container.firstChild as HTMLElement;
|
||||
expect(tooltip).toHaveStyle({ top: '-18px', left: '-38px' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
DASH_FAST_SECS,
|
||||
DASH_SLOW_SECS,
|
||||
getDashAnimation,
|
||||
} from '../Map/Map.constants';
|
||||
|
||||
describe('getDashAnimation', () => {
|
||||
it('returns duration: 0 for non-positive call rates so the dash march is skipped', () => {
|
||||
expect(getDashAnimation(0, 1000)).toStrictEqual({ duration: 0 });
|
||||
expect(getDashAnimation(-5, 1000)).toStrictEqual({ duration: 0 });
|
||||
});
|
||||
|
||||
it('saturates at DASH_FAST_SECS when callRate equals max', () => {
|
||||
// Whatever the absolute scale, the busiest edge should peg the
|
||||
// visualisation — that's the point of the relative scaling.
|
||||
const EPS = 1e-9;
|
||||
[5, 50, 500, 5_000, 1_000_000].forEach((rate) => {
|
||||
const { duration } = getDashAnimation(rate, rate);
|
||||
expect(duration).toBeGreaterThanOrEqual(DASH_FAST_SECS - EPS);
|
||||
expect(duration).toBeLessThanOrEqual(DASH_FAST_SECS + EPS);
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps to DASH_FAST_SECS even if max is stale or zero', () => {
|
||||
// Defensive: if max somehow lags behind callRate, factor still clamps to 1.
|
||||
const EPS = 1e-9;
|
||||
expect(getDashAnimation(1000, 0).duration).toBeLessThanOrEqual(
|
||||
DASH_FAST_SECS + EPS,
|
||||
);
|
||||
expect(getDashAnimation(1000, 100).duration).toBeLessThanOrEqual(
|
||||
DASH_FAST_SECS + EPS,
|
||||
);
|
||||
});
|
||||
|
||||
it('produces different durations for the same callRate at different scales', () => {
|
||||
// 50 req/sec is "busy" in a 50-max graph but "trickle" in a 5k-max graph.
|
||||
const busy = getDashAnimation(50, 50);
|
||||
const trickle = getDashAnimation(50, 5000);
|
||||
expect(busy.duration).toBeLessThan(trickle.duration);
|
||||
});
|
||||
|
||||
it('monotonically decreases per-period duration as rate climbs toward max', () => {
|
||||
const max = 5000;
|
||||
const rates = [0.5, 5, 50, 500, max];
|
||||
const durations = rates.map((r) => getDashAnimation(r, max).duration);
|
||||
for (let i = 1; i < durations.length; i += 1) {
|
||||
expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps positive-rate duration bounded between DASH_FAST_SECS and DASH_SLOW_SECS', () => {
|
||||
// At saturation the formula computes to DASH_FAST_SECS up to floating
|
||||
// point error (~1e-16), so allow a small epsilon.
|
||||
const EPS = 1e-9;
|
||||
const cases: Array<[number, number]> = [
|
||||
[0.01, 1000],
|
||||
[1, 1000],
|
||||
[10, 1000],
|
||||
[100, 1000],
|
||||
[1000, 1000],
|
||||
[1000, 0], // defensive max
|
||||
[1_000_000, 1_000_000],
|
||||
];
|
||||
cases.forEach(([rate, max]) => {
|
||||
const { duration } = getDashAnimation(rate, max);
|
||||
expect(duration).toBeGreaterThanOrEqual(DASH_FAST_SECS - EPS);
|
||||
expect(duration).toBeLessThanOrEqual(DASH_SLOW_SECS + EPS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
|
||||
import { NODE_HEIGHT, NODE_WIDTH } from '../Map/Map.constants';
|
||||
|
||||
// `Handle` requires a ReactFlowProvider to mount. We don't exercise its
|
||||
// connection logic from this component, so a stub keeps the test isolated to
|
||||
// ServiceNode's own rendering responsibilities.
|
||||
jest.mock('@xyflow/react', () => {
|
||||
const actual = jest.requireActual('@xyflow/react');
|
||||
return {
|
||||
...actual,
|
||||
Handle: ({
|
||||
type,
|
||||
position,
|
||||
}: {
|
||||
type: string;
|
||||
position: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid={`handle-${type}`} data-position={position} />
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const baseNodeProps = {
|
||||
id: 'frontend',
|
||||
type: 'service',
|
||||
dragging: false,
|
||||
isConnectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
zIndex: 0,
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
draggable: true,
|
||||
selected: false,
|
||||
} as const;
|
||||
|
||||
function renderNode(data: ServiceNodeData): ReturnType<typeof render> {
|
||||
return render(<ServiceNode {...(baseNodeProps as any)} data={data} />);
|
||||
}
|
||||
|
||||
describe('ServiceNode', () => {
|
||||
it('renders the raw service id above the pill so users can map it back to backend names', () => {
|
||||
// The above-pill text stays verbatim (no case change, no separator
|
||||
// rewrite) because that is the canonical service identifier.
|
||||
renderNode({ label: 'checkout-service', status: 'healthy' });
|
||||
|
||||
const id = screen.getByText('checkout-service');
|
||||
expect(id).toBeInTheDocument();
|
||||
expect(id).toHaveAttribute('title', 'checkout-service');
|
||||
});
|
||||
|
||||
it('renders a title-cased display name inside the pill, splitting on hyphens and underscores', () => {
|
||||
renderNode({ label: 'checkout-service', status: 'healthy' });
|
||||
|
||||
expect(screen.getByText('Checkout Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the raw label inside the pill when there are no separators to split on', () => {
|
||||
// Single token gets a leading capital but isn't otherwise transformed —
|
||||
// we don't try to split unknown camel/compound boundaries.
|
||||
renderNode({ label: 'redis', status: 'healthy' });
|
||||
|
||||
expect(screen.getByText('Redis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Healthy" inside the pill when status is healthy', () => {
|
||||
renderNode({ label: 'frontend', status: 'healthy' });
|
||||
|
||||
expect(screen.getByText('Healthy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Errors" inside the pill when status is error', () => {
|
||||
// The non-healthy state visually flips the icon-box and body tints to
|
||||
// the danger color via the .error class — assert via the status text
|
||||
// since CSS module classnames are hashed and brittle to match on.
|
||||
renderNode({ label: 'frontend', status: 'error' });
|
||||
|
||||
expect(screen.getByText('Errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sizes the pill to NODE_WIDTH x NODE_HEIGHT', () => {
|
||||
// All pills render at the same configured dimensions — there is no
|
||||
// per-node sizing, so layout in dagre stays predictable.
|
||||
renderNode({ label: 'frontend', status: 'healthy' });
|
||||
|
||||
expect(screen.getByTestId('service-node-pill')).toHaveStyle({
|
||||
width: `${NODE_WIDTH}px`,
|
||||
height: `${NODE_HEIGHT}px`,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a target handle on the left and a source handle on the right', () => {
|
||||
renderNode({ label: 'frontend', status: 'healthy' });
|
||||
|
||||
const target = screen.getByTestId('handle-target');
|
||||
const source = screen.getByTestId('handle-source');
|
||||
|
||||
expect(target).toHaveAttribute('data-position', 'left');
|
||||
expect(source).toHaveAttribute('data-position', 'right');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
//@ts-nocheck
|
||||
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import {
|
||||
cloneDeep,
|
||||
find,
|
||||
@@ -12,27 +13,7 @@ import {
|
||||
|
||||
import { graphDataType } from './ServiceMap';
|
||||
|
||||
const MIN_WIDTH = 10;
|
||||
const MAX_WIDTH = 20;
|
||||
const DEFAULT_FONT_SIZE = 6;
|
||||
|
||||
export const getDimensions = (
|
||||
num: number,
|
||||
highest: number,
|
||||
): {
|
||||
fontSize: number;
|
||||
width: number;
|
||||
} => {
|
||||
const percentage = (num / highest) * 100;
|
||||
const width = (percentage * (MAX_WIDTH - MIN_WIDTH)) / 100 + MIN_WIDTH;
|
||||
const fontSize = DEFAULT_FONT_SIZE;
|
||||
return {
|
||||
fontSize,
|
||||
width,
|
||||
};
|
||||
};
|
||||
|
||||
export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
|
||||
const { items } = serviceMap;
|
||||
const services = Object.values(groupBy(items, 'child')).map((e) => {
|
||||
return {
|
||||
@@ -42,7 +23,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
};
|
||||
});
|
||||
const highestCallCount = maxBy(items, (e) => e?.callCount)?.callCount;
|
||||
const highestCallRate = maxBy(services, (e) => e?.callRate)?.callRate;
|
||||
|
||||
const divNum = Number(
|
||||
String(1).padEnd(highestCallCount.toString().length, '0'),
|
||||
@@ -62,31 +42,13 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
const uniqParent = uniqBy(cloneDeep(items), 'parent').map((e) => e.parent);
|
||||
const uniqChild = uniqBy(cloneDeep(items), 'child').map((e) => e.child);
|
||||
const uniqNodes = uniq([...uniqParent, ...uniqChild]);
|
||||
const nodes = uniqNodes.map((node, i) => {
|
||||
const nodes = uniqNodes.map((node) => {
|
||||
const service = find(services, (service) => service.serviceName === node);
|
||||
let color = isDarkMode ? '#7CA568' : '#D5F2BB';
|
||||
if (!service) {
|
||||
return {
|
||||
id: node,
|
||||
group: i + 1,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
width: MIN_WIDTH,
|
||||
color,
|
||||
nodeVal: MIN_WIDTH,
|
||||
name: node,
|
||||
};
|
||||
}
|
||||
if (service.errorRate > 0) {
|
||||
color = isDarkMode ? '#DB836E' : '#F98989';
|
||||
}
|
||||
const { fontSize, width } = getDimensions(service.callRate, highestCallRate);
|
||||
const status: 'healthy' | 'error' =
|
||||
service && service.errorRate > 0 ? 'error' : 'healthy';
|
||||
return {
|
||||
id: node,
|
||||
group: i + 1,
|
||||
fontSize,
|
||||
width,
|
||||
color,
|
||||
nodeVal: width,
|
||||
status,
|
||||
name: node,
|
||||
};
|
||||
});
|
||||
@@ -96,20 +58,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getZoomPx = (): number => {
|
||||
const { width } = window.screen;
|
||||
if (width < 1400) {
|
||||
return 190;
|
||||
}
|
||||
if (width > 1400 && width < 1700) {
|
||||
return 380;
|
||||
}
|
||||
if (width > 1700) {
|
||||
return 470;
|
||||
}
|
||||
return 190;
|
||||
};
|
||||
|
||||
const getRound2DigitsAfterDecimal = (num: number): number => {
|
||||
if (num === 0) {
|
||||
return 0;
|
||||
@@ -117,27 +65,30 @@ const getRound2DigitsAfterDecimal = (num: number): number => {
|
||||
return num.toFixed(20).match(/^-?\d*\.?0*\d{0,2}/)[0];
|
||||
};
|
||||
|
||||
export const getTooltip = (link: {
|
||||
export interface LinkTooltip {
|
||||
p99: string | number;
|
||||
callRate: string | number;
|
||||
errorRate: string | number;
|
||||
}
|
||||
|
||||
export const getLinkTooltip = (link: {
|
||||
p99: number;
|
||||
errorRate: number;
|
||||
callRate: number;
|
||||
id: string;
|
||||
}): string => {
|
||||
return `<div style="color:#333333;padding:12px;background: white;border-radius: 2px;">
|
||||
<div class="keyval">
|
||||
<div class="key">P99 latency:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.p99 / 1000000)}ms</div>
|
||||
</div>
|
||||
<div class="keyval">
|
||||
<div class="key">Request:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.callRate)}/sec</div>
|
||||
</div>
|
||||
<div class="keyval">
|
||||
<div class="key">Error Rate:</div>
|
||||
<div class="val">${getRound2DigitsAfterDecimal(link.errorRate)}%</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
}): LinkTooltip => ({
|
||||
p99: getRound2DigitsAfterDecimal(link.p99 / 1000000),
|
||||
callRate: getRound2DigitsAfterDecimal(link.callRate),
|
||||
errorRate: getRound2DigitsAfterDecimal(link.errorRate),
|
||||
});
|
||||
|
||||
// Edges inherit the target node's health: red when the callee has errors,
|
||||
// green otherwise. Mixed with transparent so the dashed strokes read as a
|
||||
// softer accent against the busy node fills, while still letting a glance
|
||||
// at the map surface which downstream services are unhappy.
|
||||
export const getEdgeColor = (targetStatus: 'healthy' | 'error'): string =>
|
||||
targetStatus === 'error'
|
||||
? 'color-mix(in srgb, var(--danger-background) 65%, transparent)'
|
||||
: 'color-mix(in srgb, var(--success-background) 65%, transparent)';
|
||||
|
||||
export const transformLabel = (label: string, zoomLevel: number): string => {
|
||||
//? 13 is the minimum label length. Scaling factor of 0.9 which is slightly less than 1
|
||||
@@ -150,3 +101,51 @@ export const transformLabel = (label: string, zoomLevel: number): string => {
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
// Layered DAG layout via dagre. For service maps the data flows
|
||||
// caller -> callee, so a left-to-right rank direction reads naturally and
|
||||
// minimises edge crossings vs. a force-directed simulation.
|
||||
//
|
||||
// `nodeBoxWidth`/`nodeBoxHeight` reserve the pill's bounding box plus the
|
||||
// monospace service-id rendered above it, with a little breathing room so
|
||||
// adjacent ranks don't overlap.
|
||||
export const computeNodePositions = (
|
||||
nodes: { id: string }[],
|
||||
links: { source: string; target: string }[],
|
||||
nodeBoxWidth = 220,
|
||||
nodeBoxHeight = 110,
|
||||
): Record<string, { x: number; y: number }> => {
|
||||
const result: Record<string, { x: number; y: number }> = {};
|
||||
if (nodes.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const g = new dagre.graphlib.Graph({ multigraph: true, compound: false });
|
||||
g.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 40,
|
||||
ranksep: 90,
|
||||
marginx: 40,
|
||||
marginy: 40,
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
nodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: nodeBoxWidth, height: nodeBoxHeight });
|
||||
});
|
||||
links.forEach((link, i) => {
|
||||
// `name` makes parallel edges (same source+target, different metrics)
|
||||
// safe under multigraph mode.
|
||||
g.setEdge(link.source, link.target, {}, `${link.source}-${link.target}-${i}`);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const laidOut = g.node(node.id);
|
||||
if (laidOut) {
|
||||
result[node.id] = { x: laidOut.x, y: laidOut.y };
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2953,6 +2953,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31"
|
||||
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
|
||||
|
||||
"@dagrejs/dagre@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-3.0.0.tgz#543f20188f7494db0f45d634f7b3760747f87f23"
|
||||
integrity sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==
|
||||
dependencies:
|
||||
"@dagrejs/graphlib" "4.0.1"
|
||||
|
||||
"@dagrejs/graphlib@4.0.1":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-4.0.1.tgz#a9cf907cc5ddf9140a64360ad487766f17d1ee36"
|
||||
integrity sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==
|
||||
|
||||
"@date-fns/tz@^1.4.1":
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4"
|
||||
@@ -6002,11 +6014,6 @@
|
||||
resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz"
|
||||
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
|
||||
|
||||
"@tweenjs/tween.js@18 - 25":
|
||||
version "25.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9"
|
||||
integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==
|
||||
|
||||
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
|
||||
version "0.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
|
||||
@@ -6124,6 +6131,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41"
|
||||
integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==
|
||||
|
||||
"@types/d3-drag@^3.0.7":
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
|
||||
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
|
||||
dependencies:
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/d3-format@3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d"
|
||||
@@ -6141,6 +6155,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz#c3bd70d025621f73cb3319e97e08ae4c9051c791"
|
||||
integrity sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==
|
||||
|
||||
"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.4":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
|
||||
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
|
||||
dependencies:
|
||||
"@types/d3-color" "*"
|
||||
|
||||
"@types/d3-interpolate@3.0.1", "@types/d3-interpolate@^3.0.0":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc"
|
||||
@@ -6160,6 +6181,11 @@
|
||||
dependencies:
|
||||
"@types/d3-time" "*"
|
||||
|
||||
"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
|
||||
version "3.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
|
||||
integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
|
||||
|
||||
"@types/d3-shape@^1.3.1":
|
||||
version "1.3.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259"
|
||||
@@ -6182,6 +6208,21 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819"
|
||||
integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==
|
||||
|
||||
"@types/d3-transition@^3.0.8":
|
||||
version "3.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
|
||||
integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
|
||||
dependencies:
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/d3-zoom@^3.0.8":
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
|
||||
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
|
||||
dependencies:
|
||||
"@types/d3-interpolate" "*"
|
||||
"@types/d3-selection" "*"
|
||||
|
||||
"@types/debug@^4.0.0":
|
||||
version "4.1.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
|
||||
@@ -7072,6 +7113,30 @@
|
||||
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
|
||||
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
|
||||
|
||||
"@xyflow/react@12.10.2":
|
||||
version "12.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.2.tgz#40f6d71944f674f0ffbb83c660f9473018adbe61"
|
||||
integrity sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==
|
||||
dependencies:
|
||||
"@xyflow/system" "0.0.76"
|
||||
classcat "^5.0.3"
|
||||
zustand "^4.4.0"
|
||||
|
||||
"@xyflow/system@0.0.76":
|
||||
version "0.0.76"
|
||||
resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.76.tgz#57da5e4d230cdbec56548a6d5eec115f22858259"
|
||||
integrity sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==
|
||||
dependencies:
|
||||
"@types/d3-drag" "^3.0.7"
|
||||
"@types/d3-interpolate" "^3.0.4"
|
||||
"@types/d3-selection" "^3.0.10"
|
||||
"@types/d3-transition" "^3.0.8"
|
||||
"@types/d3-zoom" "^3.0.8"
|
||||
d3-drag "^3.0.0"
|
||||
d3-interpolate "^3.0.1"
|
||||
d3-selection "^3.0.0"
|
||||
d3-zoom "^3.0.0"
|
||||
|
||||
"@zxing/text-encoding@0.9.0":
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
|
||||
@@ -7097,11 +7162,6 @@ abort-controller@^3.0.0:
|
||||
dependencies:
|
||||
event-target-shim "^5.0.0"
|
||||
|
||||
accessor-fn@1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.4.1.tgz"
|
||||
integrity sha512-P7yNKfmpuWLUwiRVk9RkRIPGjngemjZ7yANc0DL7otgDqEIWkEByMhShzfgQ5ZwCPEUmba4v1kOqCdGhpzY3ew==
|
||||
|
||||
acorn-globals@^7.0.0:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
|
||||
@@ -7999,11 +8059,6 @@ better-xlsx@^0.7.5:
|
||||
jszip "^3.2.2"
|
||||
kind-of "^6.0.3"
|
||||
|
||||
"bezier-js@3 - 6":
|
||||
version "6.1.3"
|
||||
resolved "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.3.tgz"
|
||||
integrity sha512-VPFvkyO98oCJ1Tsi+bFBrKEWLdefAj4DJVaWp3xTEsdCbunC7Pt/nTeIgu/UdskBNcmHv8TOfsgdMZb1GsICmg==
|
||||
|
||||
big-integer@^1.6.16:
|
||||
version "1.6.51"
|
||||
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
|
||||
@@ -8273,13 +8328,6 @@ caniuse-lite@^1.0.30001759:
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz#0279c498e862efb067938bba0a0aabafe8d0b730"
|
||||
integrity sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==
|
||||
|
||||
canvas-color-tracker@^1.3:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d"
|
||||
integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==
|
||||
dependencies:
|
||||
tinycolor2 "^1.6.0"
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||
@@ -8421,6 +8469,11 @@ class-variance-authority@^0.7.0:
|
||||
dependencies:
|
||||
clsx "^2.1.1"
|
||||
|
||||
classcat@^5.0.3:
|
||||
version "5.0.5"
|
||||
resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
|
||||
integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
|
||||
|
||||
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz"
|
||||
@@ -9055,7 +9108,7 @@ csstype@^3.1.2:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
|
||||
"d3-array@2 - 3", "d3-array@2.10.0 - 3":
|
||||
version "3.2.3"
|
||||
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
|
||||
integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ==
|
||||
@@ -9081,11 +9134,6 @@ d3-array@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
|
||||
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
|
||||
|
||||
d3-binarytree@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz"
|
||||
integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
|
||||
|
||||
"d3-color@1 - 3", d3-color@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz"
|
||||
@@ -9103,7 +9151,7 @@ d3-delaunay@6.0.2:
|
||||
resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz"
|
||||
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
|
||||
|
||||
"d3-drag@2 - 3":
|
||||
"d3-drag@2 - 3", d3-drag@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz"
|
||||
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
|
||||
@@ -9116,17 +9164,6 @@ d3-delaunay@6.0.2:
|
||||
resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz"
|
||||
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
|
||||
|
||||
"d3-force-3d@2 - 3":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.5.tgz"
|
||||
integrity sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg==
|
||||
dependencies:
|
||||
d3-binarytree "1"
|
||||
d3-dispatch "1 - 3"
|
||||
d3-octree "1"
|
||||
d3-quadtree "1 - 3"
|
||||
d3-timer "1 - 3"
|
||||
|
||||
"d3-format@1 - 3", d3-format@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz"
|
||||
@@ -9149,18 +9186,13 @@ d3-hierarchy@^1.1.4:
|
||||
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
|
||||
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
|
||||
|
||||
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1:
|
||||
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1, d3-interpolate@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
|
||||
d3-octree@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/d3-octree/-/d3-octree-1.0.2.tgz"
|
||||
integrity sha512-Qxg4oirJrNXauiuC94uKMbgxwnhdda9xRLl9ihq45srlJ4Ga3CSgqGcAL8iW7N5CIv4Oz8x3E734ulxyvHPvwA==
|
||||
|
||||
d3-path@1, d3-path@^1.0.5:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
|
||||
@@ -9171,20 +9203,7 @@ d3-polygon@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
|
||||
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
|
||||
|
||||
"d3-quadtree@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz"
|
||||
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
|
||||
|
||||
"d3-scale-chromatic@1 - 3":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz"
|
||||
integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
d3-interpolate "1 - 3"
|
||||
|
||||
"d3-scale@1 - 4", d3-scale@4.0.2:
|
||||
d3-scale@4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz"
|
||||
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
|
||||
@@ -9195,9 +9214,9 @@ d3-polygon@^1.0.3:
|
||||
d3-time "2.1.1 - 3"
|
||||
d3-time-format "2 - 4"
|
||||
|
||||
"d3-selection@2 - 3", d3-selection@3:
|
||||
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz"
|
||||
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
|
||||
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
|
||||
|
||||
d3-shape@^1.0.6, d3-shape@^1.2.0:
|
||||
@@ -9237,9 +9256,9 @@ d3-shape@^1.0.6, d3-shape@^1.2.0:
|
||||
d3-interpolate "1 - 3"
|
||||
d3-timer "1 - 3"
|
||||
|
||||
"d3-zoom@2 - 3":
|
||||
d3-zoom@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz"
|
||||
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
|
||||
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
|
||||
dependencies:
|
||||
d3-dispatch "1 - 3"
|
||||
@@ -10460,15 +10479,6 @@ flatted@^3.4.2:
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
|
||||
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
|
||||
|
||||
float-tooltip@^1.7:
|
||||
version "1.7.5"
|
||||
resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f"
|
||||
integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==
|
||||
dependencies:
|
||||
d3-selection "2 - 3"
|
||||
kapsule "^1.16"
|
||||
preact "10"
|
||||
|
||||
flubber@^0.4.2:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
|
||||
@@ -10505,27 +10515,6 @@ for-each@^0.3.5:
|
||||
dependencies:
|
||||
is-callable "^1.2.7"
|
||||
|
||||
force-graph@^1.51:
|
||||
version "1.51.1"
|
||||
resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.1.tgz#c967249bf6ad2cb4a3ba89ed4c6d79895bd70fe1"
|
||||
integrity sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==
|
||||
dependencies:
|
||||
"@tweenjs/tween.js" "18 - 25"
|
||||
accessor-fn "1"
|
||||
bezier-js "3 - 6"
|
||||
canvas-color-tracker "^1.3"
|
||||
d3-array "1 - 3"
|
||||
d3-drag "2 - 3"
|
||||
d3-force-3d "2 - 3"
|
||||
d3-scale "1 - 4"
|
||||
d3-scale-chromatic "1 - 3"
|
||||
d3-selection "2 - 3"
|
||||
d3-zoom "2 - 3"
|
||||
float-tooltip "^1.7"
|
||||
index-array-by "1"
|
||||
kapsule "^1.16"
|
||||
lodash-es "4"
|
||||
|
||||
foreground-child@^3.1.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
|
||||
@@ -11673,11 +11662,6 @@ indent-string@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz"
|
||||
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
|
||||
|
||||
index-array-by@1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.1.tgz"
|
||||
integrity sha512-Zu6THdrxQdyTuT2uA5FjUoBEsFHPzHcPIj18FszN6yXKHxSfGcR4TPLabfuT//E25q1Igyx9xta2WMvD/x9P/g==
|
||||
|
||||
inflected@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/inflected/-/inflected-2.1.0.tgz#2816ac17a570bbbc8303ca05bca8bf9b3f959687"
|
||||
@@ -12383,11 +12367,6 @@ jake@^10.8.5:
|
||||
filelist "^1.0.4"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
jerrypick@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.1.tgz"
|
||||
integrity sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==
|
||||
|
||||
jest-changed-files@30.2.0:
|
||||
version "30.2.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c"
|
||||
@@ -13103,13 +13082,6 @@ junk@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
|
||||
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
|
||||
|
||||
kapsule@^1.16:
|
||||
version "1.16.3"
|
||||
resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30"
|
||||
integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==
|
||||
dependencies:
|
||||
lodash-es "4"
|
||||
|
||||
keyv@^4.0.0:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||
@@ -13334,7 +13306,7 @@ locate-path@^7.1.0:
|
||||
dependencies:
|
||||
p-locate "^6.0.0"
|
||||
|
||||
lodash-es@4, lodash-es@^4.17.21:
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
@@ -15645,11 +15617,6 @@ powershell-utils@^0.1.0:
|
||||
resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2"
|
||||
integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==
|
||||
|
||||
preact@10:
|
||||
version "10.28.4"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.4.tgz#8ffab01c5c0590535bdaecdd548801f44c6e483a"
|
||||
integrity sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==
|
||||
|
||||
preact@^10.19.3:
|
||||
version "10.22.0"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.22.0.tgz#a50f38006ae438d255e2631cbdaf7488e6dd4e16"
|
||||
@@ -15711,7 +15678,7 @@ progress@^2.0.3:
|
||||
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
|
||||
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
|
||||
|
||||
prop-types@15, prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@@ -16335,15 +16302,6 @@ react-fast-compare@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
|
||||
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
|
||||
|
||||
react-force-graph-2d@^1.29.1:
|
||||
version "1.29.1"
|
||||
resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda"
|
||||
integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==
|
||||
dependencies:
|
||||
force-graph "^1.51"
|
||||
prop-types "15"
|
||||
react-kapsule "^2.5"
|
||||
|
||||
react-full-screen@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-full-screen/-/react-full-screen-1.1.1.tgz#b707d56891015a71c503a65dbab3086d75be97d7"
|
||||
@@ -16413,13 +16371,6 @@ react-is@^18.3.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-kapsule@^2.5:
|
||||
version "2.5.7"
|
||||
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
|
||||
integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==
|
||||
dependencies:
|
||||
jerrypick "^1.1.1"
|
||||
|
||||
react-lottie@1.2.10:
|
||||
version "1.2.10"
|
||||
resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.10.tgz#399f78a448a7833b2380d74fc489ecf15f8d18c7"
|
||||
@@ -18489,7 +18440,7 @@ tiny-warning@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tinycolor2@1.6.0, tinycolor2@^1.6.0:
|
||||
tinycolor2@1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
|
||||
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
|
||||
@@ -19194,7 +19145,7 @@ use-sidecar@^1.1.3:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
use-sync-external-store@1.6.0, use-sync-external-store@^1.2.2:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||
@@ -19837,6 +19788,13 @@ zustand@5.0.11:
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
|
||||
integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==
|
||||
|
||||
zustand@^4.4.0:
|
||||
version "4.5.7"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
|
||||
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
|
||||
dependencies:
|
||||
use-sync-external-store "^1.2.2"
|
||||
|
||||
zwitch@^2.0.0, zwitch@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
|
||||
|
||||
@@ -35,7 +35,7 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
|
||||
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/unknown), 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 six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.",
|
||||
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/unknown/no_data), 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 six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.",
|
||||
Request: new(inframonitoringtypes.PostablePods),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(inframonitoringtypes.Pods),
|
||||
@@ -48,5 +48,24 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/infra_monitoring/nodes", handler.New(
|
||||
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListNodes),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListNodes",
|
||||
Tags: []string{"inframonitoring"},
|
||||
Summary: "List Nodes for Infra Monitoring",
|
||||
Description: "Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.",
|
||||
Request: new(inframonitoringtypes.PostableNodes),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(inframonitoringtypes.Nodes),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Code is a dotted, hierarchical identifier registered at process start. It
|
||||
// encodes domain (subsystem), op (verb), optional sub (qualifier), and a
|
||||
// terminal reason. Codes are values; two Codes with the same string are equal
|
||||
// by value and safe to compare with ==.
|
||||
type Code struct{ s string }
|
||||
|
||||
// String returns the dotted code as it appears on the wire. Empty for the
|
||||
// zero value.
|
||||
func (c Code) String() string { return c.s }
|
||||
|
||||
// codePattern allows 2-4 dotted segments, each starting with a lowercase
|
||||
// letter and continuing with [a-z0-9_]. One segment is too broad (use a
|
||||
// domain prefix); five or more means the domain should be split.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$`)
|
||||
|
||||
// Meta is the per-code default envelope applied by constructors before
|
||||
// per-call options. Every field has a natural per-code default — an auth
|
||||
// code always wants Reauthenticate, every documented code wants its docs
|
||||
// URL — so the registry is the right place to declare them once.
|
||||
type Meta struct {
|
||||
Category Category
|
||||
Fault Fault
|
||||
Retry Retry
|
||||
Remediation Remediation
|
||||
Refs map[RefKind]string
|
||||
}
|
||||
|
||||
// Retry tells the caller how and when to retry. After is meaningful only
|
||||
// when Policy == RetryAfter.
|
||||
type Retry struct {
|
||||
Policy RetryPolicy
|
||||
After time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = map[string]Meta{}
|
||||
)
|
||||
|
||||
// Register installs a code with its default Meta and returns the Code value.
|
||||
// It panics on a malformed code string or a duplicate registration — both
|
||||
// indicate a programming error that must be caught at boot, not at first
|
||||
// failure.
|
||||
//
|
||||
// Call from the owning domain's package init or top-level var block:
|
||||
//
|
||||
// var CodeUnknownFunction = errors.Register("query.parse.unknown_function", errors.Meta{
|
||||
// Category: errors.CategoryInvalidInput,
|
||||
// Fault: errors.FaultCaller,
|
||||
// Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
// })
|
||||
func Register(s string, meta Meta) Code {
|
||||
if !codePattern.MatchString(s) {
|
||||
panic("errors/v2: malformed code: " + s)
|
||||
}
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[s]; ok {
|
||||
panic("errors/v2: duplicate code: " + s)
|
||||
}
|
||||
registry[s] = meta
|
||||
return Code{s: s}
|
||||
}
|
||||
|
||||
// MetaOf returns the Meta a code was registered with. Returns the zero Meta
|
||||
// and false for unregistered or zero codes.
|
||||
func MetaOf(c Code) (Meta, bool) {
|
||||
if c.s == "" {
|
||||
return Meta{}, false
|
||||
}
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
m, ok := registry[c.s]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// registerOrGet is the internal idempotent register used by adapters that
|
||||
// may see the same code (e.g. legacy.<v1>) more than once across the process
|
||||
// lifetime. It panics on malformed codes — duplicate codes silently keep the
|
||||
// existing Meta.
|
||||
func registerOrGet(s string, meta Meta) Code {
|
||||
if !codePattern.MatchString(s) {
|
||||
panic("errors/v2: malformed code: " + s)
|
||||
}
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[s]; !ok {
|
||||
registry[s] = meta
|
||||
}
|
||||
return Code{s: s}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package v2
|
||||
|
||||
// The enums in this file are closed sets. Each value is a package-level var of
|
||||
// an unexported-field struct, so external code cannot synthesize new values —
|
||||
// it must reference one of the defined ones. String() returns the stable
|
||||
// snake_case wire name; once shipped, those names are append-only.
|
||||
|
||||
// Category groups errors by what kind of failure occurred. It is the coarsest
|
||||
// branch-worthy axis and is intended to be a superset of gRPC status codes
|
||||
// extended with cases SigNoz cares about (e.g. license issues land under
|
||||
// FailedDependency or ResourceExhausted depending on context).
|
||||
type Category struct{ s string }
|
||||
|
||||
func (c Category) String() string { return c.s }
|
||||
|
||||
var (
|
||||
CategoryInvalidInput = Category{"invalid_input"} // request was malformed or violated a documented constraint.
|
||||
CategoryNotFound = Category{"not_found"} // referenced resource does not exist.
|
||||
CategoryAlreadyExists = Category{"already_exists"} // resource creation conflicts with an existing one.
|
||||
CategoryConflict = Category{"conflict"} // concurrent modification or state mismatch (e.g. stale revision).
|
||||
CategoryPrecondition = Category{"precondition"} // a required precondition (system or caller-asserted) was not met.
|
||||
CategoryUnauthenticated = Category{"unauthenticated"} // credentials are missing or invalid.
|
||||
CategoryForbidden = Category{"forbidden"} // authenticated but not authorized for this action.
|
||||
CategoryResourceExhausted = Category{"resource_exhausted"} // quota, rate limit, or other budget exceeded.
|
||||
CategoryFailedDependency = Category{"failed_dependency"} // an upstream service we depend on failed (db, license, etc.).
|
||||
CategoryUnavailable = Category{"unavailable"} // service is temporarily down; retry with backoff.
|
||||
CategoryTimeout = Category{"timeout"} // deadline exceeded before the operation completed.
|
||||
CategoryCanceled = Category{"canceled"} // caller or context canceled the operation.
|
||||
CategoryUnimplemented = Category{"unimplemented"} // operation is not supported (or not yet) by this server.
|
||||
CategoryDataLoss = Category{"data_loss"} // unrecoverable data corruption or loss detected.
|
||||
CategoryInternal = Category{"internal"} // bug — invariant broken; should not occur in normal operation.
|
||||
)
|
||||
|
||||
// Fault attributes responsibility. An agent uses this to decide whether to
|
||||
// fix the request (Caller), retry/escalate (Server, Upstream), or page a
|
||||
// human (Operator).
|
||||
type Fault struct{ s string }
|
||||
|
||||
func (f Fault) String() string { return f.s }
|
||||
|
||||
var (
|
||||
FaultCaller = Fault{"caller"}
|
||||
FaultServer = Fault{"server"}
|
||||
FaultUpstream = Fault{"upstream"}
|
||||
FaultOperator = Fault{"operator"}
|
||||
)
|
||||
|
||||
// RetryPolicy tells the caller how to behave on retry. Backoff implies the
|
||||
// caller should use its own backoff schedule; After means honor Retry.After
|
||||
// exactly; AfterFix and AfterAuth signal that retry is pointless until the
|
||||
// caller fixes the request or re-authenticates.
|
||||
type RetryPolicy struct{ s string }
|
||||
|
||||
func (r RetryPolicy) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RetryNever = RetryPolicy{"never"}
|
||||
RetryImmediate = RetryPolicy{"immediate"}
|
||||
RetryBackoff = RetryPolicy{"backoff"}
|
||||
RetryAfter = RetryPolicy{"after"}
|
||||
RetryAfterFix = RetryPolicy{"after_fix"}
|
||||
RetryAfterAuth = RetryPolicy{"after_auth"}
|
||||
)
|
||||
|
||||
// Remediation names the single recommended next action. It does not execute.
|
||||
type Remediation struct{ s string }
|
||||
|
||||
func (r Remediation) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RemediationNone = Remediation{"none"}
|
||||
RemediationFixInput = Remediation{"fix_input"}
|
||||
RemediationReauthenticate = Remediation{"reauthenticate"}
|
||||
RemediationWaitAndRetry = Remediation{"wait_and_retry"}
|
||||
RemediationFailover = Remediation{"failover"}
|
||||
RemediationContactOperator = Remediation{"contact_operator"}
|
||||
RemediationFileBug = Remediation{"file_bug"}
|
||||
RemediationUpgradeLicense = Remediation{"upgrade_license"}
|
||||
)
|
||||
|
||||
// RefKind classifies a reference URL attached to the error.
|
||||
type RefKind struct{ s string }
|
||||
|
||||
func (r RefKind) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RefDocs = RefKind{"docs"}
|
||||
RefRunbook = RefKind{"runbook"}
|
||||
RefDashboard = RefKind{"dashboard"}
|
||||
RefTrace = RefKind{"trace"}
|
||||
RefSource = RefKind{"source"}
|
||||
RefIssue = RefKind{"issue"}
|
||||
)
|
||||
@@ -1,248 +0,0 @@
|
||||
// Package v2 is the redesigned pkg/errors.
|
||||
//
|
||||
// Every branch-worthy field on the Error struct is a closed enum and every
|
||||
// variable part is a typed key/value. The intent is to make errors first-class
|
||||
// data for programmatic consumers — SDK clients, UI surfaces, alerting, and
|
||||
// LLM agents — without sacrificing human readability.
|
||||
//
|
||||
// Domain and op are encoded into Code (e.g. "query.parse.unknown_function")
|
||||
// rather than carried as separate struct fields. Frames[0] is the
|
||||
// authoritative call-site location, captured at construction time.
|
||||
package v2
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Error is the redesigned error value. *Error is the canonical form passed
|
||||
// around — the zero value is unused, construct via New / Newf / Wrap / Wrapf.
|
||||
//
|
||||
// Frames are intentionally not a struct field: resolving captured PCs into
|
||||
// func/file/line is the dominant construction cost, so we capture PCs eagerly
|
||||
// at construction time (so the snapshot is faithful to the call site) and
|
||||
// resolve them lazily via Frames() only when something actually inspects them.
|
||||
type Error struct {
|
||||
// WHAT
|
||||
Category Category
|
||||
Code Code
|
||||
Title string
|
||||
Detail string
|
||||
|
||||
// WHY / WHO
|
||||
Cause error
|
||||
Fault Fault
|
||||
|
||||
// WHAT NEXT
|
||||
Retry Retry
|
||||
Remediation Remediation
|
||||
Refs map[RefKind]string
|
||||
|
||||
// CONTEXT
|
||||
Attrs map[string]any
|
||||
TraceID string
|
||||
SpanID string
|
||||
|
||||
// stack is the captured PCs plus a memoized []Frame; never read directly,
|
||||
// always go through Frames().
|
||||
stack *frameStack
|
||||
}
|
||||
|
||||
// Frames returns the captured stack, resolved to func/file/line on first
|
||||
// access. Frames[0] is the constructor's caller. Safe for concurrent use.
|
||||
func (e *Error) Frames() []Frame {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.stack.frames()
|
||||
}
|
||||
|
||||
// New creates an Error for a registered Code. Defaults from the registered
|
||||
// Meta are applied first; opts override per call site.
|
||||
func New(code Code, title string, opts ...Option) *Error {
|
||||
e := &Error{Code: code, Title: title, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Newf is New with fmt.Sprintf-style formatting for the title.
|
||||
func Newf(code Code, format string, args ...any) *Error {
|
||||
e := &Error{Code: code, Title: fmt.Sprintf(format, args...), stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// Wrap creates an Error that wraps cause. The new error's Title is the
|
||||
// caller-supplied title (not the cause's message), so Error() reports what
|
||||
// went wrong at this layer — the cause is reachable via Unwrap.
|
||||
func Wrap(cause error, code Code, title string, opts ...Option) *Error {
|
||||
e := &Error{Code: code, Title: title, Cause: cause, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Wrapf is Wrap with fmt.Sprintf-style formatting for the title.
|
||||
func Wrapf(cause error, code Code, format string, args ...any) *Error {
|
||||
e := &Error{Code: code, Title: fmt.Sprintf(format, args...), Cause: cause, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// applyMeta copies default values from the registered Meta into a fresh
|
||||
// Error. It runs before per-call options so options win.
|
||||
func applyMeta(e *Error) {
|
||||
meta, ok := MetaOf(e.Code)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if (e.Category == Category{}) {
|
||||
e.Category = meta.Category
|
||||
}
|
||||
if (e.Fault == Fault{}) {
|
||||
e.Fault = meta.Fault
|
||||
}
|
||||
if (e.Retry == Retry{}) {
|
||||
e.Retry = meta.Retry
|
||||
}
|
||||
if (e.Remediation == Remediation{}) {
|
||||
e.Remediation = meta.Remediation
|
||||
}
|
||||
if len(meta.Refs) > 0 {
|
||||
if e.Refs == nil {
|
||||
e.Refs = make(map[RefKind]string, len(meta.Refs))
|
||||
}
|
||||
for k, v := range meta.Refs {
|
||||
if _, exists := e.Refs[k]; !exists {
|
||||
e.Refs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns the Title (the message specifically attached at this wrap
|
||||
// site), not the cause's message. This fixes the v1 surprise where Error()
|
||||
// returned the wrapped cause's text.
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return e.Title
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped cause, enabling errors.Is / errors.As.
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Format implements fmt.Formatter.
|
||||
//
|
||||
// %s, %v → Title only
|
||||
// %+v → full chain: code, title, frames, attrs, recursive cause
|
||||
func (e *Error) Format(f fmt.State, verb rune) {
|
||||
switch verb {
|
||||
case 's':
|
||||
_, _ = io.WriteString(f, e.Title)
|
||||
case 'v':
|
||||
if f.Flag('+') {
|
||||
_, _ = io.WriteString(f, e.fullString())
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(f, e.Title)
|
||||
case 'q':
|
||||
fmt.Fprintf(f, "%q", e.Title)
|
||||
default:
|
||||
fmt.Fprintf(f, "%%!%c(*errors/v2.Error)", verb)
|
||||
}
|
||||
}
|
||||
|
||||
// fullString produces the %+v rendering. Format is intentionally
|
||||
// human-readable rather than machine-parseable; consumers that want structure
|
||||
// should marshal to JSON.
|
||||
func (e *Error) fullString() string {
|
||||
var b strings.Builder
|
||||
e.appendFull(&b, 0)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *Error) appendFull(b *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
fmt.Fprintf(b, "%s[%s] %s\n", indent, e.Code.s, e.Title)
|
||||
if e.Detail != "" {
|
||||
fmt.Fprintf(b, "%s detail: %s\n", indent, e.Detail)
|
||||
}
|
||||
if len(e.Attrs) > 0 {
|
||||
// Stable key order for deterministic output.
|
||||
keys := make([]string, 0, len(e.Attrs))
|
||||
for k := range e.Attrs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Fprintf(b, "%s attrs:\n", indent)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(b, "%s %s=%v\n", indent, k, e.Attrs[k])
|
||||
}
|
||||
}
|
||||
if frames := e.Frames(); len(frames) > 0 {
|
||||
fmt.Fprintf(b, "%s frames:\n", indent)
|
||||
for _, fr := range frames {
|
||||
fmt.Fprintf(b, "%s %s\n%s %s:%s\n", indent, fr.Func, indent, fr.File, strconv.Itoa(fr.Line))
|
||||
}
|
||||
}
|
||||
if e.Cause != nil {
|
||||
fmt.Fprintf(b, "%scaused by:\n", indent)
|
||||
var ce *Error
|
||||
if stderrors.As(e.Cause, &ce) && ce != nil {
|
||||
ce.appendFull(b, depth+1)
|
||||
} else {
|
||||
fmt.Fprintf(b, "%s %s\n", indent, e.Cause.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AsError extracts a *Error from anywhere in err's wrap chain. It is the
|
||||
// common shortcut around errors.As for code that always wants this package's
|
||||
// type.
|
||||
func AsError(err error) (*Error, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
var e *Error
|
||||
if stderrors.As(err, &e) {
|
||||
return e, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Is reports whether err or any error in its chain has the given Code.
|
||||
// Convenience wrapper that's friendlier than errors.As at call sites that
|
||||
// only care about code identity.
|
||||
func Is(err error, code Code) bool {
|
||||
e, ok := AsError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for e != nil {
|
||||
if e.Code == code {
|
||||
return true
|
||||
}
|
||||
next, ok := AsError(e.Cause)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = next
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package v2
|
||||
|
||||
// This file is a self-contained walkthrough of how a domain integrates with
|
||||
// pkg/errors/v2. It mirrors what a real pkg/<domain>/errors.go looks like in
|
||||
// practice — registering codes, constructing typed errors at failure sites,
|
||||
// and consuming them at API boundaries. The "example.*" namespace is reserved
|
||||
// for these demo codes so they never collide with a real domain's
|
||||
// registrations.
|
||||
|
||||
// 1. Register codes at package init time. Each Register call panics on
|
||||
// malformed code or duplicate registration, so misconfiguration is caught
|
||||
// at process boot, not at first failure.
|
||||
var (
|
||||
// A caller-fault, fix-the-input error: rejected before any work happens.
|
||||
exampleCodeInvalidQuery = Register("example.query.invalid_filter", Meta{
|
||||
Category: CategoryInvalidInput,
|
||||
Fault: FaultCaller,
|
||||
Remediation: RemediationFixInput,
|
||||
Retry: Retry{Policy: RetryAfterFix},
|
||||
Refs: map[RefKind]string{
|
||||
RefDocs: "https://signoz.io/docs/query/filters",
|
||||
},
|
||||
})
|
||||
|
||||
// A quota error: the caller's request was well-formed but their plan
|
||||
// doesn't allow it. The recommended remediation is structural (upgrade),
|
||||
// not "try again later."
|
||||
exampleCodeQuotaExceeded = Register("example.billing.quota_exceeded", Meta{
|
||||
Category: CategoryResourceExhausted,
|
||||
Fault: FaultCaller,
|
||||
Remediation: RemediationUpgradeLicense,
|
||||
Retry: Retry{Policy: RetryNever},
|
||||
})
|
||||
)
|
||||
|
||||
// 2. Construct errors at the failure site. Notice that variable parts of
|
||||
// the message (the offending field, the limits) live in typed Attrs, not in
|
||||
// the title prose — a downstream agent can read them without parsing English.
|
||||
func exampleRejectInvalidFilter(field string) *Error {
|
||||
return New(exampleCodeInvalidQuery, "filter is not supported",
|
||||
WithAttr("field", field),
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Consume errors at the API boundary. Branching on Category gives the
|
||||
// HTTP status; Retry tells an SDK how to behave; Fault drives logging
|
||||
// classification (caller errors are warnings, server/upstream errors page).
|
||||
func exampleClassifyForHTTP(err error) (status int, retry RetryPolicy) {
|
||||
e, ok := AsError(err)
|
||||
if !ok {
|
||||
return 500, RetryNever
|
||||
}
|
||||
switch e.Category {
|
||||
case CategoryInvalidInput, CategoryPrecondition:
|
||||
status = 400
|
||||
case CategoryUnauthenticated:
|
||||
status = 401
|
||||
case CategoryForbidden:
|
||||
status = 403
|
||||
case CategoryNotFound:
|
||||
status = 404
|
||||
case CategoryConflict, CategoryAlreadyExists:
|
||||
status = 409
|
||||
case CategoryResourceExhausted:
|
||||
status = 429
|
||||
case CategoryUnavailable, CategoryTimeout:
|
||||
status = 503
|
||||
case CategoryUnimplemented:
|
||||
status = 501
|
||||
default:
|
||||
status = 500
|
||||
}
|
||||
return status, e.Retry.Policy
|
||||
}
|
||||
|
||||
// 4. Identify a specific failure mode by Code. Is walks the cause chain so
|
||||
// a wrapper at the HTTP layer still matches when the root cause was raised
|
||||
// deep in the call graph.
|
||||
func exampleIsQuotaExceeded(err error) bool {
|
||||
return Is(err, exampleCodeQuotaExceeded)
|
||||
}
|
||||
|
||||
// The example helpers are reference-only: they exist to document call-site
|
||||
// patterns, not to be called from anywhere in the binary. This anchor keeps
|
||||
// them visible to readers (and the linter) without exporting demo code.
|
||||
var _ = []any{
|
||||
exampleRejectInvalidFilter,
|
||||
exampleClassifyForHTTP,
|
||||
exampleIsQuotaExceeded,
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Frame is a single line in the call stack. Frames[0] is the constructor's
|
||||
// caller — the authoritative "where this error came from" — and downstream
|
||||
// consumers can filter (e.g. "frames inside our code") without regex
|
||||
// reparsing of a pre-formatted stack string.
|
||||
type Frame struct {
|
||||
Func string `json:"func,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// frameStack carries the PCs captured at construction plus the resolved
|
||||
// []Frame slice, behind a sync.Once. Resolving frames into func/file/line is
|
||||
// expensive (runtime.CallersFrames walks the symbol table); the vast majority
|
||||
// of errors are constructed and never inspected, so we only pay that cost
|
||||
// when a consumer actually asks for frames (Frames()/MarshalJSON/%+v).
|
||||
//
|
||||
// The PC capture itself is cheap and happens at construction so that
|
||||
// Frames[0] is a faithful "where" record of the original call site.
|
||||
type frameStack struct {
|
||||
pcs []uintptr
|
||||
|
||||
once sync.Once
|
||||
resolved []Frame
|
||||
}
|
||||
|
||||
// captureStack is called by every constructor. skip drops runtime.Callers,
|
||||
// captureStack itself, and the constructor frame so that the first PC is the
|
||||
// user code that invoked the constructor.
|
||||
func captureStack(skip int) *frameStack {
|
||||
const depth = 32
|
||||
pcs := make([]uintptr, depth)
|
||||
n := runtime.Callers(skip, pcs)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return &frameStack{pcs: pcs[:n:n]}
|
||||
}
|
||||
|
||||
// frames resolves the captured PCs into []Frame. The resolution is memoized
|
||||
// — concurrent calls are safe and only one of them does the work.
|
||||
func (s *frameStack) frames() []Frame {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.once.Do(func() {
|
||||
cf := runtime.CallersFrames(s.pcs)
|
||||
out := make([]Frame, 0, len(s.pcs))
|
||||
for {
|
||||
f, more := cf.Next()
|
||||
out = append(out, Frame{Func: f.Function, File: f.File, Line: f.Line})
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.resolved = out
|
||||
})
|
||||
return s.resolved
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// CodeUnknown is the sentinel returned when AsJSON / AsURLValues are called
|
||||
// on a non-*Error. A consumer that sees this on the wire should read it as
|
||||
// "the producer did not raise a v2 Error and we projected it through the
|
||||
// fallback path" — i.e. somewhere upstream is still using std errors or v1.
|
||||
var CodeUnknown = Register("unknown.unset", Meta{
|
||||
Category: CategoryInternal,
|
||||
Fault: FaultServer,
|
||||
Retry: Retry{Policy: RetryNever},
|
||||
})
|
||||
|
||||
// JSON is the wire envelope for an Error. It is intentionally a superset of
|
||||
// v1's pkg/errors.JSON: SDK clients that only read v1's {code, message, url,
|
||||
// errors[]} keep working, while v2 consumers can branch on the new typed
|
||||
// fields (category, fault, retry, remediation, attrs, refs, cause).
|
||||
type JSON struct {
|
||||
Code string `json:"code" required:"true"`
|
||||
Title string `json:"title" required:"true"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Fault string `json:"fault,omitempty"`
|
||||
Retry *RetryJSON `json:"retry,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Attrs map[string]any `json:"attrs,omitempty"`
|
||||
Refs map[string]string `json:"refs,omitempty"`
|
||||
Frames []Frame `json:"frames,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
Cause *CauseJSON `json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
// RetryJSON renders Retry as an object so consumers can branch on policy
|
||||
// before consulting AfterMS. AfterMS is omitted unless policy is "after".
|
||||
type RetryJSON struct {
|
||||
Policy string `json:"policy"`
|
||||
AfterMS int64 `json:"after_ms,omitempty"`
|
||||
}
|
||||
|
||||
// CauseJSON is the thin recursive shape for a cause chain. Only code, title,
|
||||
// and a nested cause are guaranteed — producers may add more, consumers must
|
||||
// not rely on it.
|
||||
type CauseJSON struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Cause *CauseJSON `json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
// AsJSON projects any error onto the v2 wire envelope. If cause is a
|
||||
// *Error (anywhere in its wrap chain) every field is filled from it;
|
||||
// otherwise the result is a CodeUnknown envelope with Title=cause.Error()
|
||||
// so the wire shape is always valid and never panics.
|
||||
func AsJSON(cause error) *JSON {
|
||||
if cause == nil {
|
||||
return nil
|
||||
}
|
||||
e, ok := AsError(cause)
|
||||
if !ok {
|
||||
return &JSON{
|
||||
Code: CodeUnknown.s,
|
||||
Title: cause.Error(),
|
||||
Category: CategoryInternal.s,
|
||||
Fault: FaultServer.s,
|
||||
}
|
||||
}
|
||||
return errorToJSON(e)
|
||||
}
|
||||
|
||||
func errorToJSON(e *Error) *JSON {
|
||||
out := &JSON{
|
||||
Code: e.Code.s,
|
||||
Title: e.Title,
|
||||
Detail: e.Detail,
|
||||
Category: e.Category.s,
|
||||
Fault: e.Fault.s,
|
||||
Remediation: e.Remediation.s,
|
||||
Attrs: e.Attrs,
|
||||
TraceID: e.TraceID,
|
||||
SpanID: e.SpanID,
|
||||
}
|
||||
if (e.Retry.Policy != RetryPolicy{}) {
|
||||
out.Retry = &RetryJSON{Policy: e.Retry.Policy.s}
|
||||
if e.Retry.Policy == RetryAfter && e.Retry.After > 0 {
|
||||
out.Retry.AfterMS = e.Retry.After.Milliseconds()
|
||||
}
|
||||
}
|
||||
if len(e.Refs) > 0 {
|
||||
out.Refs = make(map[string]string, len(e.Refs))
|
||||
for k, v := range e.Refs {
|
||||
out.Refs[k.s] = v
|
||||
}
|
||||
}
|
||||
if frames := e.Frames(); len(frames) > 0 {
|
||||
out.Frames = frames
|
||||
}
|
||||
if e.Cause != nil {
|
||||
out.Cause = causeToJSON(e.Cause)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func causeToJSON(err error) *CauseJSON {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
c := &CauseJSON{Code: e.Code.s, Title: e.Title}
|
||||
if e.Cause != nil {
|
||||
c.Cause = causeToJSON(e.Cause)
|
||||
}
|
||||
return c
|
||||
}
|
||||
// Non-*Error leaf: only Title is set, no Code.
|
||||
return &CauseJSON{Title: err.Error()}
|
||||
}
|
||||
|
||||
// AsURLValues projects an error onto a flat url.Values, matching v1's shape
|
||||
// for callers (e.g. OAuth/SSO redirects) that smuggle errors back through a
|
||||
// query string. Complex fields (attrs, refs, retry, frames, cause) are
|
||||
// JSON-marshaled into a single value rather than spread across multiple
|
||||
// keys, since query strings have no good representation for nested data.
|
||||
func AsURLValues(cause error) url.Values {
|
||||
j := AsJSON(cause)
|
||||
if j == nil {
|
||||
return url.Values{}
|
||||
}
|
||||
v := url.Values{
|
||||
"code": {j.Code},
|
||||
"title": {j.Title},
|
||||
}
|
||||
if j.Detail != "" {
|
||||
v.Set("detail", j.Detail)
|
||||
}
|
||||
if j.Category != "" {
|
||||
v.Set("category", j.Category)
|
||||
}
|
||||
if j.Fault != "" {
|
||||
v.Set("fault", j.Fault)
|
||||
}
|
||||
if j.Remediation != "" {
|
||||
v.Set("remediation", j.Remediation)
|
||||
}
|
||||
if j.TraceID != "" {
|
||||
v.Set("trace_id", j.TraceID)
|
||||
}
|
||||
if j.SpanID != "" {
|
||||
v.Set("span_id", j.SpanID)
|
||||
}
|
||||
if j.Retry != nil {
|
||||
if b, err := json.Marshal(j.Retry); err == nil {
|
||||
v.Set("retry", string(b))
|
||||
}
|
||||
}
|
||||
if len(j.Refs) > 0 {
|
||||
if b, err := json.Marshal(j.Refs); err == nil {
|
||||
v.Set("refs", string(b))
|
||||
}
|
||||
}
|
||||
if len(j.Attrs) > 0 {
|
||||
if b, err := json.Marshal(j.Attrs); err == nil {
|
||||
v.Set("attrs", string(b))
|
||||
}
|
||||
}
|
||||
if j.Cause != nil {
|
||||
if b, err := json.Marshal(j.Cause); err == nil {
|
||||
v.Set("cause", string(b))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package v2
|
||||
|
||||
import "time"
|
||||
|
||||
// Option mutates an Error during construction. Options are applied after the
|
||||
// registered Meta defaults so a per-call WithFault wins over the code's
|
||||
// default Fault.
|
||||
type Option func(*Error)
|
||||
|
||||
// WithTitle overrides the title (used when Newf's formatted string is not
|
||||
// what you want, or after a Wrap that took its title from the cause).
|
||||
func WithTitle(s string) Option { return func(e *Error) { e.Title = s } }
|
||||
|
||||
// WithDetail adds a long, user-safe explanation. Detail must never include
|
||||
// raw cause text; the cause is already in the chain.
|
||||
func WithDetail(s string) Option { return func(e *Error) { e.Detail = s } }
|
||||
|
||||
// WithCategory overrides the registered Category.
|
||||
func WithCategory(c Category) Option { return func(e *Error) { e.Category = c } }
|
||||
|
||||
// WithFault overrides the registered Fault.
|
||||
func WithFault(f Fault) Option { return func(e *Error) { e.Fault = f } }
|
||||
|
||||
// WithRetry overrides the registered Retry.
|
||||
func WithRetry(r Retry) Option { return func(e *Error) { e.Retry = r } }
|
||||
|
||||
// WithRetryAfter is a convenience for the common RetryAfter case.
|
||||
func WithRetryAfter(d time.Duration) Option {
|
||||
return func(e *Error) { e.Retry = Retry{Policy: RetryAfter, After: d} }
|
||||
}
|
||||
|
||||
// WithRemediation overrides the registered Remediation.
|
||||
//
|
||||
// Convention for "did you mean" hints: stash a []string under
|
||||
// Attrs["suggestions"], ranked best-first. Each element should be a complete,
|
||||
// copy-pasteable replacement — not an explanation of what went wrong (use
|
||||
// WithDetail for that). Once 3-4 domains adopt the convention identically,
|
||||
// promote to a first-class field.
|
||||
func WithRemediation(r Remediation) Option { return func(e *Error) { e.Remediation = r } }
|
||||
|
||||
// WithRef adds (or replaces) a single reference URL keyed by kind.
|
||||
func WithRef(kind RefKind, url string) Option {
|
||||
return func(e *Error) {
|
||||
if e.Refs == nil {
|
||||
e.Refs = make(map[RefKind]string, 1)
|
||||
}
|
||||
e.Refs[kind] = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttr sets a single typed attribute. Prefer typed per-domain helpers
|
||||
// (e.g. WithQueryAttrs(q Query)) over raw WithAttr at call sites — they keep
|
||||
// the attr keys consistent and let the compiler reject typos.
|
||||
func WithAttr(key string, value any) Option {
|
||||
return func(e *Error) {
|
||||
if e.Attrs == nil {
|
||||
e.Attrs = make(map[string]any, 1)
|
||||
}
|
||||
e.Attrs[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttrs merges a map of attributes; later keys win.
|
||||
func WithAttrs(attrs map[string]any) Option {
|
||||
return func(e *Error) {
|
||||
if len(attrs) == 0 {
|
||||
return
|
||||
}
|
||||
if e.Attrs == nil {
|
||||
e.Attrs = make(map[string]any, len(attrs))
|
||||
}
|
||||
for k, v := range attrs {
|
||||
e.Attrs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrace stamps the error with OTel trace and span IDs so the JSON
|
||||
// response can link back to the originating span.
|
||||
func WithTrace(traceID, spanID string) Option {
|
||||
return func(e *Error) {
|
||||
e.TraceID = traceID
|
||||
e.SpanID = spanID
|
||||
}
|
||||
}
|
||||
@@ -69,3 +69,27 @@ func (h *handler) ListPods(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) ListNodes(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.PostableNodes
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListNodes(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func buildHostRecords(
|
||||
Wait: -1,
|
||||
Load15: -1,
|
||||
DiskUsage: -1,
|
||||
Meta: map[string]any{},
|
||||
Meta: map[string]string{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
|
||||
@@ -23,3 +23,9 @@ type podPhaseCounts struct {
|
||||
Failed int
|
||||
Unknown int
|
||||
}
|
||||
|
||||
// nodeConditionCounts holds per-group node counts bucketed by latest condition_ready in window.
|
||||
type nodeConditionCounts struct {
|
||||
Ready int
|
||||
NotReady int
|
||||
}
|
||||
|
||||
@@ -242,3 +242,98 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &inframonitoringtypes.Nodes{}
|
||||
|
||||
if req.OrderBy == nil {
|
||||
req.OrderBy = &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: inframonitoringtypes.NodesOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{nodeNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
}
|
||||
|
||||
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, nodesTableMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(missingMetrics) > 0 {
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
|
||||
resp.Records = []inframonitoringtypes.NodeRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
if req.End < int64(minFirstReportedUnixMilli) {
|
||||
resp.EndTimeBeforeRetention = true
|
||||
resp.Records = []inframonitoringtypes.NodeRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
|
||||
|
||||
metadataMap, err := m.getNodesTableMetadata(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
pageGroups, err := m.getTopNodeGroups(ctx, orgID, req, metadataMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
resp.Records = []inframonitoringtypes.NodeRecord{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
|
||||
// Start/End/Filter/GroupBy from PostablePods.
|
||||
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
|
||||
Start: req.Start,
|
||||
End: req.End,
|
||||
Filter: req.Filter,
|
||||
GroupBy: req.GroupBy,
|
||||
}, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, nodeNameAttrKey)
|
||||
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
312
pkg/modules/inframonitoring/implinframonitoring/nodes.go
Normal file
312
pkg/modules/inframonitoring/implinframonitoring/nodes.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"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/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// buildNodeRecords assembles the page records. Condition counts come from
|
||||
// conditionCounts in both modes. In list mode (isNodeNameInGroupBy=true) each
|
||||
// group is one node, so exactly one count is 1; Condition is derived from
|
||||
// which one. In grouped_list mode Condition stays NodeConditionNoData.
|
||||
func buildNodeRecords(
|
||||
isNodeNameInGroupBy bool,
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
pageGroups []map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
nodeConditionCounts map[string]nodeConditionCounts,
|
||||
podPhaseCounts map[string]podPhaseCounts,
|
||||
) []inframonitoringtypes.NodeRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
records := make([]inframonitoringtypes.NodeRecord, 0, len(pageGroups))
|
||||
for _, labels := range pageGroups {
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
nodeName := labels[nodeNameAttrKey]
|
||||
|
||||
record := inframonitoringtypes.NodeRecord{ // initialize with default values
|
||||
NodeName: nodeName,
|
||||
Condition: inframonitoringtypes.NodeConditionNoData,
|
||||
NodeCPU: -1,
|
||||
NodeCPUAllocatable: -1,
|
||||
NodeMemory: -1,
|
||||
NodeMemoryAllocatable: -1,
|
||||
Meta: map[string]string{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
if v, exists := metrics["A"]; exists {
|
||||
record.NodeCPU = v
|
||||
}
|
||||
if v, exists := metrics["B"]; exists {
|
||||
record.NodeCPUAllocatable = v
|
||||
}
|
||||
if v, exists := metrics["C"]; exists {
|
||||
record.NodeMemory = v
|
||||
}
|
||||
if v, exists := metrics["D"]; exists {
|
||||
record.NodeMemoryAllocatable = v
|
||||
}
|
||||
}
|
||||
|
||||
if nodeConditionCountsForGroup, ok := nodeConditionCounts[compositeKey]; ok {
|
||||
record.NodeCountsByReadiness = inframonitoringtypes.NodeCountsByReadiness{
|
||||
Ready: nodeConditionCountsForGroup.Ready,
|
||||
NotReady: nodeConditionCountsForGroup.NotReady,
|
||||
}
|
||||
|
||||
// In list mode each group is one node; the count==1 bucket identifies the condition.
|
||||
if isNodeNameInGroupBy {
|
||||
switch {
|
||||
case nodeConditionCountsForGroup.Ready == 1:
|
||||
record.Condition = inframonitoringtypes.NodeConditionReady
|
||||
case nodeConditionCountsForGroup.NotReady == 1:
|
||||
record.Condition = inframonitoringtypes.NodeConditionNotReady
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if podPhaseCountsForGroup, ok := podPhaseCounts[compositeKey]; ok {
|
||||
record.PodCountsByPhase = inframonitoringtypes.PodCountsByPhase{
|
||||
Pending: podPhaseCountsForGroup.Pending,
|
||||
Running: podPhaseCountsForGroup.Running,
|
||||
Succeeded: podPhaseCountsForGroup.Succeeded,
|
||||
Failed: podPhaseCountsForGroup.Failed,
|
||||
Unknown: podPhaseCountsForGroup.Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func (m *module) getTopNodeGroups(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableNodes,
|
||||
metadataMap map[string]map[string]string,
|
||||
) ([]map[string]string, error) {
|
||||
orderByKey := req.OrderBy.Key.Name
|
||||
queryNamesForOrderBy := orderByToNodesQueryNames[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.newNodesTableListQuery().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) getNodesTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range nodeAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
|
||||
// node's latest condition_ready value (0 / 1) in the requested window.
|
||||
// Pipeline:
|
||||
//
|
||||
// timeSeriesFPs: fp ↔ (node_name, groupBy cols) from the time_series table.
|
||||
// User filter + page-groups filter applied here.
|
||||
// latestConditionPerNode: INNER JOIN samples × timeSeriesFPs, collapsed to
|
||||
// the latest condition value per node via argMax(value, unix_milli).
|
||||
// countNodesPerCondition: per-group uniqExactIf into ready/not_ready buckets.
|
||||
//
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
func (m *module) getPerGroupNodeConditionCounts(
|
||||
ctx context.Context,
|
||||
req *inframonitoringtypes.PostableNodes,
|
||||
pageGroups []map[string]string,
|
||||
) (map[string]nodeConditionCounts, error) {
|
||||
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
|
||||
return map[string]nodeConditionCounts{}, nil
|
||||
}
|
||||
|
||||
// Merged filter expression (user filter + page-groups IN clauses).
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
|
||||
|
||||
// Resolve tables. Same convention as pods.
|
||||
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(req.Start), uint64(req.End), nil,
|
||||
)
|
||||
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
|
||||
uint64(req.Start), uint64(req.End),
|
||||
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
|
||||
)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
|
||||
|
||||
// ----- timeSeriesFPs -----
|
||||
timeSeriesFPs := sqlbuilder.NewSelectBuilder()
|
||||
timeSeriesFPsSelectCols := []string{
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(nodeNameAttrKey)),
|
||||
}
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
}
|
||||
timeSeriesFPs.Select(timeSeriesFPsSelectCols...)
|
||||
timeSeriesFPs.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
timeSeriesFPs.Where(
|
||||
timeSeriesFPs.E("metric_name", nodeConditionMetricName),
|
||||
timeSeriesFPs.GE("unix_milli", adjustedStart),
|
||||
timeSeriesFPs.L("unix_milli", adjustedEnd),
|
||||
)
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
timeSeriesFPs.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
timeSeriesFPsGroupBy := []string{"fingerprint", "node_name"}
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
|
||||
}
|
||||
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
|
||||
timeSeriesFPsSQL, timeSeriesFPsArgs := timeSeriesFPs.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- latestConditionPerNode -----
|
||||
latestConditionPerNode := sqlbuilder.NewSelectBuilder()
|
||||
latestConditionPerNodeSelectCols := []string{"tsfp.node_name AS node_name"}
|
||||
latestConditionPerNodeGroupBy := []string{"node_name"}
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
|
||||
latestConditionPerNodeGroupBy = append(latestConditionPerNodeGroupBy, col)
|
||||
}
|
||||
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols,
|
||||
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS condition_value", valueCol),
|
||||
)
|
||||
latestConditionPerNode.Select(latestConditionPerNodeSelectCols...)
|
||||
latestConditionPerNode.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN time_series_fps AS tsfp ON samples.fingerprint = tsfp.fingerprint",
|
||||
telemetrymetrics.DBName, samplesTable,
|
||||
))
|
||||
latestConditionPerNode.Where(
|
||||
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
|
||||
latestConditionPerNode.GE("samples.unix_milli", req.Start),
|
||||
latestConditionPerNode.L("samples.unix_milli", req.End),
|
||||
"tsfp.node_name != ''",
|
||||
)
|
||||
latestConditionPerNode.GroupBy(latestConditionPerNodeGroupBy...)
|
||||
latestConditionPerNodeSQL, latestConditionPerNodeArgs := latestConditionPerNode.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- countNodesPerCondition (outer SELECT) -----
|
||||
countNodesPerConditionSelectCols := make([]string, 0, len(req.GroupBy)+2)
|
||||
countNodesPerConditionGroupBy := make([]string, 0, len(req.GroupBy))
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols, col)
|
||||
countNodesPerConditionGroupBy = append(countNodesPerConditionGroupBy, col)
|
||||
}
|
||||
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols,
|
||||
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS ready_count", inframonitoringtypes.NodeConditionNumReady),
|
||||
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS not_ready_count", inframonitoringtypes.NodeConditionNumNotReady),
|
||||
)
|
||||
countNodesPerConditionSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM latest_condition_per_node GROUP BY %s",
|
||||
strings.Join(countNodesPerConditionSelectCols, ", "),
|
||||
strings.Join(countNodesPerConditionGroupBy, ", "),
|
||||
)
|
||||
|
||||
// Combine CTEs + outer.
|
||||
cteFragments := []string{
|
||||
fmt.Sprintf("time_series_fps AS (%s)", timeSeriesFPsSQL),
|
||||
fmt.Sprintf("latest_condition_per_node AS (%s)", latestConditionPerNodeSQL),
|
||||
}
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + countNodesPerConditionSQL
|
||||
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, nil)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]nodeConditionCounts)
|
||||
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 ready, notReady uint64
|
||||
scanPtrs = append(scanPtrs, &ready, ¬Ready)
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[compositeKeyFromList(groupVals)] = nodeConditionCounts{
|
||||
Ready: int(ready),
|
||||
NotReady: int(notReady),
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 (
|
||||
nodeNameAttrKey = "k8s.node.name"
|
||||
nodeConditionMetricName = "k8s.node.condition_ready"
|
||||
)
|
||||
|
||||
var nodeNameGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: nodeNameAttrKey,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
// nodesTableMetricNamesList drives the existence/retention check.
|
||||
// Includes condition_ready and pod.phase also.
|
||||
var nodesTableMetricNamesList = []string{
|
||||
"k8s.node.cpu.usage",
|
||||
"k8s.node.allocatable_cpu",
|
||||
"k8s.node.memory.working_set",
|
||||
"k8s.node.allocatable_memory",
|
||||
"k8s.node.condition_ready",
|
||||
"k8s.pod.phase",
|
||||
}
|
||||
|
||||
var nodeAttrKeysForMetadata = []string{
|
||||
"k8s.node.uid",
|
||||
"k8s.cluster.name",
|
||||
}
|
||||
|
||||
var orderByToNodesQueryNames = map[string][]string{
|
||||
inframonitoringtypes.NodesOrderByCPU: {"A"},
|
||||
inframonitoringtypes.NodesOrderByCPUAllocatable: {"B"},
|
||||
inframonitoringtypes.NodesOrderByMemory: {"C"},
|
||||
inframonitoringtypes.NodesOrderByMemoryAllocatable: {"D"},
|
||||
}
|
||||
|
||||
// newNodesTableListQuery builds the composite QB v5 request for the nodes list.
|
||||
// Node condition is derived separately via getPerGroupNodeConditionCounts (works
|
||||
// for both list and grouped_list modes), so no condition query is included here.
|
||||
func (m *module) newNodesTableListQuery() *qbtypes.QueryRangeRequest {
|
||||
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.node.cpu.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query B: CPU allocatable.
|
||||
// TimeAggregationLatest is the closest v5 equivalent of v1's AnyLast;
|
||||
// allocatable values change rarely so divergence in practice is negligible.
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.node.allocatable_cpu",
|
||||
TimeAggregation: metrictypes.TimeAggregationLatest,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query C: Memory working set
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.node.memory.working_set",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query D: Memory allocatable. Same Latest caveat as Query B.
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.node.allocatable_memory",
|
||||
TimeAggregation: metrictypes.TimeAggregationLatest,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: queries,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// buildPodRecords assembles the page records. Phase counts come from
|
||||
// phaseCounts in both modes. In list mode (isPodUIDInGroupBy=true) each
|
||||
// group is one pod, so exactly one count is 1; PodPhase is derived from
|
||||
// which one. In grouped_list mode PodPhase stays PodPhaseNone.
|
||||
// which one. In grouped_list mode PodPhase stays PodPhaseNoData.
|
||||
func buildPodRecords(
|
||||
isPodUIDInGroupBy bool,
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
@@ -38,7 +38,7 @@ func buildPodRecords(
|
||||
|
||||
record := inframonitoringtypes.PodRecord{ // initialize with default values
|
||||
PodUID: podUID,
|
||||
PodPhase: inframonitoringtypes.PodPhaseNone,
|
||||
PodPhase: inframonitoringtypes.PodPhaseNoData,
|
||||
PodCPU: -1,
|
||||
PodCPURequest: -1,
|
||||
PodCPULimit: -1,
|
||||
@@ -46,7 +46,7 @@ func buildPodRecords(
|
||||
PodMemoryRequest: -1,
|
||||
PodMemoryLimit: -1,
|
||||
PodAge: -1,
|
||||
Meta: map[string]any{},
|
||||
Meta: map[string]string{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
@@ -71,11 +71,13 @@ func buildPodRecords(
|
||||
}
|
||||
|
||||
if phaseCountsForGroup, ok := phaseCounts[compositeKey]; ok {
|
||||
record.PendingPodCount = phaseCountsForGroup.Pending
|
||||
record.RunningPodCount = phaseCountsForGroup.Running
|
||||
record.SucceededPodCount = phaseCountsForGroup.Succeeded
|
||||
record.FailedPodCount = phaseCountsForGroup.Failed
|
||||
record.UnknownPodCount = phaseCountsForGroup.Unknown
|
||||
record.PodCountsByPhase = inframonitoringtypes.PodCountsByPhase{
|
||||
Pending: phaseCountsForGroup.Pending,
|
||||
Running: phaseCountsForGroup.Running,
|
||||
Succeeded: phaseCountsForGroup.Succeeded,
|
||||
Failed: phaseCountsForGroup.Failed,
|
||||
Unknown: phaseCountsForGroup.Unknown,
|
||||
}
|
||||
|
||||
// In list mode each group is one pod; the count==1 bucket identifies the phase.
|
||||
if isPodUIDInGroupBy {
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
type Handler interface {
|
||||
ListHosts(http.ResponseWriter, *http.Request)
|
||||
ListPods(http.ResponseWriter, *http.Request)
|
||||
ListNodes(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)
|
||||
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,15 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
errors "github.com/SigNoz/signoz/pkg/errors/v2"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// HAVING-expression validator codes. All three are caller-fault, fix-the-input
|
||||
// errors — the user wrote an expression we cannot turn into SQL — so retry
|
||||
// is pointless until the expression itself changes.
|
||||
var (
|
||||
codeHavingStringLiteral = errors.Register("querybuilder.having.string_literal", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
codeHavingInvalidReference = errors.Register("querybuilder.having.invalid_reference", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
codeHavingSyntaxError = errors.Register("querybuilder.having.syntax_error", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
)
|
||||
|
||||
// havingExpressionRewriteVisitor walks the parse tree of a HavingExpression in a single
|
||||
// pass, simultaneously rewriting user-facing references to their SQL column names and
|
||||
// collecting any references that could not be resolved.
|
||||
@@ -306,10 +281,10 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// This is checked before invalid references so that "contains string literals" takes
|
||||
// priority when a bare string literal is also an unresolvable operand.
|
||||
if v.hasStringLiteral {
|
||||
return "", errors.New(codeHavingStringLiteral,
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"`Having` expression contains string literals",
|
||||
errors.WithDetail("Aggregator results are numeric"),
|
||||
)
|
||||
).WithAdditional("Aggregator results are numeric")
|
||||
}
|
||||
|
||||
if len(v.invalid) > 0 {
|
||||
@@ -319,10 +294,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
validKeys = append(validKeys, k)
|
||||
}
|
||||
sort.Strings(validKeys)
|
||||
opts := []errors.Option{
|
||||
errors.WithAttr("invalid_refs", v.invalid),
|
||||
errors.WithAttr("valid_refs", validKeys),
|
||||
}
|
||||
additional := []string{"Valid references are: [" + strings.Join(validKeys, ", ") + "]"}
|
||||
if len(v.invalid) == 1 {
|
||||
inv := v.invalid[0]
|
||||
// Only suggest for plain identifier typos, not for unresolved function
|
||||
@@ -331,13 +303,15 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// a simple string substitution produce a corrupt expression.
|
||||
isFuncCall := strings.Contains(original, inv+"(")
|
||||
if match, dist := closestMatch(inv, validKeys); !isFuncCall && !strings.Contains(match, "(") && dist <= 3 {
|
||||
opts = append(opts, errors.WithAttr("suggestions", []string{strings.ReplaceAll(original, inv, match)}))
|
||||
corrected := strings.ReplaceAll(original, inv, match)
|
||||
additional = append(additional, "Suggestion: `"+corrected+"`")
|
||||
}
|
||||
}
|
||||
return "", errors.New(codeHavingInvalidReference,
|
||||
fmt.Sprintf("Invalid references in `Having` expression: [%s]", strings.Join(v.invalid, ", ")),
|
||||
opts...,
|
||||
)
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Invalid references in `Having` expression: [%s]",
|
||||
strings.Join(v.invalid, ", "),
|
||||
).WithAdditional(additional...)
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -354,20 +328,17 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
if detail == "" {
|
||||
detail = "check the expression syntax"
|
||||
}
|
||||
opts := []errors.Option{
|
||||
errors.WithDetail(detail),
|
||||
errors.WithAttr("syntax_errors", msgs),
|
||||
}
|
||||
additional := []string{detail}
|
||||
// For single-error expressions, try to produce an actionable suggestion.
|
||||
if len(allSyntaxErrors) == 1 {
|
||||
if s := havingSuggestion(allSyntaxErrors[0], original); s != "" {
|
||||
opts = append(opts, errors.WithAttr("suggestions", []string{s}))
|
||||
additional = append(additional, "Suggestion: `"+s+"`")
|
||||
}
|
||||
}
|
||||
return "", errors.New(codeHavingSyntaxError,
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Syntax error in `Having` expression",
|
||||
opts...,
|
||||
)
|
||||
).WithAdditional(additional...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -18,16 +18,16 @@ type Hosts struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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]string `json:"meta" required:"true"`
|
||||
}
|
||||
|
||||
type RequiredMetricsCheck struct {
|
||||
|
||||
110
pkg/types/inframonitoringtypes/nodes.go
Normal file
110
pkg/types/inframonitoringtypes/nodes.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type Nodes struct {
|
||||
Type ResponseType `json:"type" required:"true"`
|
||||
Records []NodeRecord `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"`
|
||||
}
|
||||
|
||||
// NodeCountsByReadiness buckets node counts by their latest k8s.node.condition_ready
|
||||
// value in the time window. Reusable across record types (node / cluster).
|
||||
type NodeCountsByReadiness struct {
|
||||
Ready int `json:"ready" required:"true"`
|
||||
NotReady int `json:"notReady" required:"true"`
|
||||
}
|
||||
|
||||
type NodeRecord struct {
|
||||
NodeName string `json:"nodeName" required:"true"`
|
||||
Condition NodeCondition `json:"condition" required:"true"`
|
||||
NodeCountsByReadiness NodeCountsByReadiness `json:"nodeCountsByReadiness" required:"true"`
|
||||
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
|
||||
NodeCPU float64 `json:"nodeCPU" required:"true"`
|
||||
NodeCPUAllocatable float64 `json:"nodeCPUAllocatable" required:"true"`
|
||||
NodeMemory float64 `json:"nodeMemory" required:"true"`
|
||||
NodeMemoryAllocatable float64 `json:"nodeMemoryAllocatable" required:"true"`
|
||||
Meta map[string]string `json:"meta" required:"true"`
|
||||
}
|
||||
|
||||
// PostableNodes is the request body for the v2 nodes list API.
|
||||
type PostableNodes struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableNodes contains acceptable values.
|
||||
func (req *PostableNodes) 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(NodesValidOrderByKeys, 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 *PostableNodes) UnmarshalJSON(data []byte) error {
|
||||
type raw PostableNodes
|
||||
var decoded raw
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*req = PostableNodes(decoded)
|
||||
return req.Validate()
|
||||
}
|
||||
42
pkg/types/inframonitoringtypes/nodes_constants.go
Normal file
42
pkg/types/inframonitoringtypes/nodes_constants.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
type NodeCondition struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
NodeConditionReady = NodeCondition{valuer.NewString("ready")}
|
||||
NodeConditionNotReady = NodeCondition{valuer.NewString("not_ready")}
|
||||
NodeConditionNoData = NodeCondition{valuer.NewString("no_data")}
|
||||
)
|
||||
|
||||
func (NodeCondition) Enum() []any {
|
||||
return []any{
|
||||
NodeConditionReady,
|
||||
NodeConditionNotReady,
|
||||
NodeConditionNoData,
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric values emitted by the k8s.node.condition_ready metric
|
||||
// (source: OTel kubeletstats receiver).
|
||||
const (
|
||||
NodeConditionNumReady = 1
|
||||
NodeConditionNumNotReady = 0
|
||||
)
|
||||
|
||||
const (
|
||||
NodesOrderByCPU = "cpu"
|
||||
NodesOrderByCPUAllocatable = "cpu_allocatable"
|
||||
NodesOrderByMemory = "memory"
|
||||
NodesOrderByMemoryAllocatable = "memory_allocatable"
|
||||
)
|
||||
|
||||
var NodesValidOrderByKeys = []string{
|
||||
NodesOrderByCPU,
|
||||
NodesOrderByCPUAllocatable,
|
||||
NodesOrderByMemory,
|
||||
NodesOrderByMemoryAllocatable,
|
||||
}
|
||||
255
pkg/types/inframonitoringtypes/nodes_test.go
Normal file
255
pkg/types/inframonitoringtypes/nodes_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
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 TestPostableNodes_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *PostableNodes
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time zero",
|
||||
req: &PostableNodes{
|
||||
Start: 0,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time negative",
|
||||
req: &PostableNodes{
|
||||
Start: -1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "end time zero",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 0,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time greater than end time",
|
||||
req: &PostableNodes{
|
||||
Start: 2000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time equal to end time",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit zero",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 0,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit negative",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: -10,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit exceeds max",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 5001,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "offset negative",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: -5,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy nil is valid",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key cpu and direction asc",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key cpu_allocatable and direction desc",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByCPUAllocatable,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key memory_allocatable and direction asc",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByMemoryAllocatable,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with condition key is rejected",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "condition",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy with invalid key",
|
||||
req: &PostableNodes{
|
||||
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: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByMemory,
|
||||
},
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,22 +17,28 @@ type Pods struct {
|
||||
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// PodCountsByPhase buckets pod counts by their latest phase in the time window.
|
||||
// Reusable across record types (pod / namespace / cluster).
|
||||
type PodCountsByPhase struct {
|
||||
Pending int `json:"pending" required:"true"`
|
||||
Running int `json:"running" required:"true"`
|
||||
Succeeded int `json:"succeeded" required:"true"`
|
||||
Failed int `json:"failed" required:"true"`
|
||||
Unknown int `json:"unknown" required:"true"`
|
||||
}
|
||||
|
||||
type PodRecord struct {
|
||||
PodUID string `json:"podUID" required:"true"`
|
||||
PodCPU float64 `json:"podCPU" required:"true"`
|
||||
PodCPURequest float64 `json:"podCPURequest" required:"true"`
|
||||
PodCPULimit float64 `json:"podCPULimit" required:"true"`
|
||||
PodMemory float64 `json:"podMemory" required:"true"`
|
||||
PodMemoryRequest float64 `json:"podMemoryRequest" required:"true"`
|
||||
PodMemoryLimit float64 `json:"podMemoryLimit" required:"true"`
|
||||
PodPhase PodPhase `json:"podPhase" required:"true"`
|
||||
PendingPodCount int `json:"pendingPodCount" required:"true"`
|
||||
RunningPodCount int `json:"runningPodCount" required:"true"`
|
||||
SucceededPodCount int `json:"succeededPodCount" required:"true"`
|
||||
FailedPodCount int `json:"failedPodCount" required:"true"`
|
||||
UnknownPodCount int `json:"unknownPodCount" required:"true"`
|
||||
PodAge int64 `json:"podAge" required:"true"`
|
||||
Meta map[string]interface{} `json:"meta" required:"true"`
|
||||
PodUID string `json:"podUID" required:"true"`
|
||||
PodCPU float64 `json:"podCPU" required:"true"`
|
||||
PodCPURequest float64 `json:"podCPURequest" required:"true"`
|
||||
PodCPULimit float64 `json:"podCPULimit" required:"true"`
|
||||
PodMemory float64 `json:"podMemory" required:"true"`
|
||||
PodMemoryRequest float64 `json:"podMemoryRequest" required:"true"`
|
||||
PodMemoryLimit float64 `json:"podMemoryLimit" required:"true"`
|
||||
PodPhase PodPhase `json:"podPhase" required:"true"`
|
||||
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
|
||||
PodAge int64 `json:"podAge" required:"true"`
|
||||
Meta map[string]string `json:"meta" required:"true"`
|
||||
}
|
||||
|
||||
// PostablePods is the request body for the v2 pods list API.
|
||||
|
||||
@@ -12,7 +12,7 @@ var (
|
||||
PodPhaseSucceeded = PodPhase{valuer.NewString("succeeded")}
|
||||
PodPhaseFailed = PodPhase{valuer.NewString("failed")}
|
||||
PodPhaseUnknown = PodPhase{valuer.NewString("unknown")}
|
||||
PodPhaseNone = PodPhase{valuer.NewString("")}
|
||||
PodPhaseNoData = PodPhase{valuer.NewString("no_data")}
|
||||
)
|
||||
|
||||
func (PodPhase) Enum() []any {
|
||||
@@ -22,7 +22,7 @@ func (PodPhase) Enum() []any {
|
||||
PodPhaseSucceeded,
|
||||
PodPhaseFailed,
|
||||
PodPhaseUnknown,
|
||||
PodPhaseNone,
|
||||
PodPhaseNoData,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user