Compare commits
33 Commits
v2-read-pa
...
infraM/goo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
245ef858f9 | ||
|
|
d95a2164d9 | ||
|
|
de48f99455 | ||
|
|
bad3850117 | ||
|
|
f9a770c50b | ||
|
|
5a8ca9573c | ||
|
|
6136eb3258 | ||
|
|
e62bfb4a4c | ||
|
|
dd64bdb5cc | ||
|
|
51d5d3ea35 | ||
|
|
7eb3f7df32 | ||
|
|
5eb3b5e3e0 | ||
|
|
b88ee12cd5 | ||
|
|
6f3dd0b7ad | ||
|
|
d32d93cd39 | ||
|
|
e9a931788c | ||
|
|
589e33eb39 | ||
|
|
e51c464417 | ||
|
|
91064d0b63 | ||
|
|
04637bd960 | ||
|
|
0703fbf961 | ||
|
|
4e45620b72 | ||
|
|
a9506f1354 | ||
|
|
0500d14054 | ||
|
|
e2e7caf1ca | ||
|
|
a9051c4e9e | ||
|
|
17f4b4af91 | ||
|
|
759d2f6db5 | ||
|
|
b9156ac4b0 | ||
|
|
1300dbc1c2 | ||
|
|
0f657b946e | ||
|
|
376c1d9fcc | ||
|
|
e90783d3ed |
10
cmd/authz.go
@@ -93,9 +93,13 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
registry := coretypes.NewRegistry()
|
||||
|
||||
allowedResources := map[string]bool{
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
)
|
||||
@@ -25,8 +25,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterLogSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: telemetrylogs.DBName,
|
||||
TableName: telemetrylogs.LogsV2LocalTableName,
|
||||
DBName: logstelemetryschema.DBName,
|
||||
TableName: logstelemetryschema.LogsV2LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultLogsRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -36,8 +36,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterSpanSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: telemetrytraces.DBName,
|
||||
TableName: telemetrytraces.SpanIndexV3LocalTableName,
|
||||
DBName: tracestelemetryschema.DBName,
|
||||
TableName: tracestelemetryschema.SpanIndexV3LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultTracesRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -47,8 +47,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterDatapointCount,
|
||||
Unit: zeustypes.MeterUnitCount,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: telemetrymetrics.DBName,
|
||||
TableName: telemetrymetrics.SamplesV4LocalTableName,
|
||||
DBName: metricstelemetryschema.DBName,
|
||||
TableName: metricstelemetryschema.SamplesV4LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultMetricsRetentionDays,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1496,6 +1496,9 @@ components:
|
||||
- redis
|
||||
- cloudsql_postgres
|
||||
- memorystore_redis
|
||||
- computeengine
|
||||
- gke
|
||||
- cloudstorage
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
@@ -153,7 +153,7 @@ func (provider *Provider) Collect(
|
||||
func buildOriginQuery(meterName string) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("toInt64(ifNull(min(unix_milli), 0))")
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.Where(sb.Equal("metric_name", meterName))
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func buildQuery(meterName string, segment *retentiontypes.RetentionPolicySegment
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", segment.StartMs),
|
||||
|
||||
@@ -8,16 +8,16 @@ import (
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
)
|
||||
|
||||
var (
|
||||
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
|
||||
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
|
||||
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
|
||||
reductionRulesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.ReductionRulesTableName
|
||||
metadataTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.AttributesMetadataTableName
|
||||
bufferSeriesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.TimeseriesV4BufferTableName
|
||||
)
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
@@ -192,7 +192,7 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
@@ -229,8 +229,8 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
// reduced sample tables.
|
||||
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, metricstelemetryschema.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -300,9 +300,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
|
||||
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
|
||||
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
|
||||
ingestedTable := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName
|
||||
reducedLast := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedLastTableName
|
||||
reducedSum := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedSumTableName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
|
||||
@@ -352,16 +352,16 @@ func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []str
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
ingested, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
last, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
sum, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uin
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint)", "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -470,7 +470,7 @@ func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string
|
||||
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
@@ -485,7 +485,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
@@ -499,7 +499,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
|
||||
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
@@ -507,7 +507,7 @@ func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
sb.From(metricstelemetryschema.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
|
||||
2
frontend/docs/assets/drawer-example.svg
Normal file
|
After Width: | Height: | Size: 139 KiB |
2
frontend/docs/assets/edit-example.svg
Normal file
|
After Width: | Height: | Size: 49 KiB |
2
frontend/docs/assets/list-example.svg
Normal file
|
After Width: | Height: | Size: 86 KiB |
2
frontend/docs/assets/quick-filters-example.svg
Normal file
|
After Width: | Height: | Size: 64 KiB |
136
frontend/docs/authz-guide.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# AuthZ Guide
|
||||
|
||||
How to structure a page so it works with the permission system.
|
||||
|
||||
We are migrating from the `ADMIN | EDITOR | VIEWER` roles to per-action checks. Instead of granting `VIEWER` and
|
||||
exposing everything, a user can now be granted access to a single resource, eg: `Logs` only.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Core rules](#core-rules)
|
||||
- [Page patterns](#page-patterns)
|
||||
- [What "blocked" means](#what-blocked-means)
|
||||
- [Migration checklist](#migration-checklist)
|
||||
- [Testing](#testing)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Check whether the resource your page represents is supported: see
|
||||
[permissions.type.ts](../src/lib/authz/hooks/useAuthZ/permissions.type.ts) for the current resources and their allowed
|
||||
verbs.
|
||||
|
||||
**If the resource is not listed there, skip authz for now**. The backend does not enforce it yet, so any frontend
|
||||
check would be decorative. Revisit once the resource is generated into that file (it is auto-generated from the
|
||||
backend).
|
||||
|
||||
## Core rules
|
||||
|
||||
These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||
1. **A page is always reachable, regardless of permission.** The intended action must be known before permission can
|
||||
be checked, so the route renders first and individual pieces are gated.
|
||||
2. **`update` requires both `read` and `update`.** A user who can write but cannot read the current value must not get
|
||||
an edit affordance.
|
||||
3. **`delete` is independent of `read`.** Delete controls stay visible for a user who holds `delete` but not `read`.
|
||||
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
|
||||
detail route).
|
||||
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
|
||||
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
|
||||
## Page patterns
|
||||
|
||||
### List page
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
|
||||
- Title, description, search filters and action buttons stay visible.
|
||||
- Filters and any control that drives the table are non-interactive.
|
||||
- The create button stays enabled if the user holds `create`.
|
||||
|
||||
### Edit page
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the content with `withAuthZContent`, not the whole route.
|
||||
- Keep delete visible (rule 3).
|
||||
- Keep update blocked (rule 2).
|
||||
|
||||
### Drawer
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the drawer body, not the drawer itself.
|
||||
- Prefer routing that carries the resource ID in the URL, so delete stays reachable without `read`.
|
||||
- Keep update blocked (rule 2).
|
||||
- Watch for sub-resources the user may still be allowed to act on (rule 6).
|
||||
|
||||
### Create
|
||||
|
||||
Without `create`:
|
||||
|
||||
| Entry point | Gate with |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| Button | `AuthZButton` |
|
||||
| Dedicated create page | `withAuthZPage` |
|
||||
| Create drawer opened directly (deep link) | `withAuthZContent` on the body + `AuthZButton` on footer actions |
|
||||
|
||||
### Delete
|
||||
|
||||
Gate the control with `AuthZButton`, or `AuthZTooltip` for a non-button trigger such as an icon or menu item.
|
||||
|
||||
### Quick filters
|
||||
|
||||

|
||||
|
||||
## What "blocked" means
|
||||
|
||||
Blocking is always a visible denial, never a silent removal. Use the components in
|
||||
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
|
||||
denial message and the loading state.
|
||||
|
||||
| Scope | Component | Denied state |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------- |
|
||||
| Button | `AuthZButton` | Disabled + tooltip |
|
||||
| Any element | `AuthZTooltip` | Disabled child + tooltip |
|
||||
| Section | `withAuthZContent` / `AuthZGuardContent` | Inline `PermissionDeniedCallout` |
|
||||
| Page / route | `withAuthZPage` / `AuthZGuardPage` | `PermissionDeniedFullPage` |
|
||||
| Custom fallback | `withAuthZ` / `AuthZGuard` | Whatever you pass as `fallback` |
|
||||
|
||||
Prefer the HOC (`withAuthZ*`); reach for the JSX guard (`AuthZGuard*`) when the gate depends on conditional rendering
|
||||
and an HOC cannot express it. The components README has the full decision tree and how to build the `checks` array.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Use the existing components. Create a new one only if none fit.
|
||||
|
||||
- [ ] Can I apply a `withAuthZ*` variant directly, or must I extract the content into a component first?
|
||||
- If extraction is needed, declare the new content component in the same file to keep the diff small, then move it
|
||||
to its own file in a follow-up commit or PR.
|
||||
- [ ] Is the layout structured to respect the [core rules](#core-rules)?
|
||||
- If not, raise it in the frontend Slack channel before reshaping the page.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
|
||||
## Testing
|
||||
|
||||
### Devtool
|
||||
|
||||
Press `Cmd + K` (`Ctrl + K` on Windows/Linux) to open the shortcuts, search for `AuthZ`, and pick the first
|
||||
result. The devtool simulates granted and denied permissions across the UI.
|
||||
|
||||
Available only in local development, it is stripped from production builds.
|
||||
|
||||
### Unit tests
|
||||
|
||||
[`lib/authz/utils/README.md`](../src/lib/authz/utils/README.md) covers the `*.authz.test.tsx` naming convention, the
|
||||
MSW handlers (`setupAuthzAdmin`, `setupAuthzDenyAll`, `setupAuthzDeny`, `setupAuthzAllow`,
|
||||
`setupAuthzGrantByPrefix`), and how to test the loading state.
|
||||
@@ -14,7 +14,10 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { routePermission } from 'utils/permission';
|
||||
import {
|
||||
routePermission,
|
||||
routeWithInitialAuthZSupport,
|
||||
} from 'utils/permission';
|
||||
|
||||
import routes, {
|
||||
LIST_LICENSES,
|
||||
@@ -42,7 +45,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -226,7 +228,16 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
const hasInitialAuthZSupport = Object.hasOwn(
|
||||
routeWithInitialAuthZSupport,
|
||||
key,
|
||||
);
|
||||
|
||||
if (
|
||||
route &&
|
||||
route.find((e) => e === user.role) === undefined &&
|
||||
hasInitialAuthZSupport === false
|
||||
) {
|
||||
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { routeWithInitialAuthZSupport } from 'utils/permission';
|
||||
|
||||
import PrivateRoute from '../Private';
|
||||
|
||||
@@ -178,6 +179,7 @@ function createMockAppContext(
|
||||
isFetchingHosts: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
userFetchError: null,
|
||||
activeLicenseFetchError: null,
|
||||
hostsFetchError: null,
|
||||
@@ -198,24 +200,39 @@ function createMockAppContext(
|
||||
};
|
||||
}
|
||||
|
||||
// Roles that no authz-aware route grants through the legacy routePermission table
|
||||
const DENIED_ROLES: ROLES[] = [
|
||||
USER_ROLES.ANONYMOUS as ROLES,
|
||||
USER_ROLES.AUTHOR as ROLES,
|
||||
];
|
||||
|
||||
const PERMITTED_ROLES: ROLES[] = [
|
||||
USER_ROLES.ADMIN as ROLES,
|
||||
USER_ROLES.EDITOR as ROLES,
|
||||
USER_ROLES.VIEWER as ROLES,
|
||||
];
|
||||
|
||||
interface AuthzRouteCase {
|
||||
path: string;
|
||||
deniedRoles: ROLES[];
|
||||
}
|
||||
|
||||
interface RenderPrivateRouteOptions {
|
||||
initialRoute?: string;
|
||||
appContext?: Partial<IAppContext>;
|
||||
isCloudUser?: boolean;
|
||||
}
|
||||
|
||||
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
const {
|
||||
initialRoute = ROUTES.HOME,
|
||||
appContext = {},
|
||||
isCloudUser = true,
|
||||
} = options;
|
||||
function buildPrivateRouteTree(
|
||||
options: RenderPrivateRouteOptions,
|
||||
): ReactElement {
|
||||
const { initialRoute = ROUTES.HOME, appContext = {} } = options;
|
||||
|
||||
mockIsCloudUser = isCloudUser;
|
||||
const contextValue = createMockAppContext({
|
||||
...appContext,
|
||||
});
|
||||
|
||||
const contextValue = createMockAppContext(appContext);
|
||||
|
||||
render(
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<AppContext.Provider value={contextValue}>
|
||||
@@ -229,10 +246,15 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
</PrivateRoute>
|
||||
</AppContext.Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
mockIsCloudUser = options.isCloudUser ?? true;
|
||||
render(buildPrivateRouteTree(options));
|
||||
}
|
||||
|
||||
// Generic assertion helpers for navigation behavior
|
||||
// Using location-based assertions since Private.tsx now uses Redirect component
|
||||
|
||||
@@ -1432,6 +1454,25 @@ describe('PrivateRoute', () => {
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
|
||||
'should render the unauthorized page for a %s instead of bouncing off it',
|
||||
(role) => {
|
||||
// routePermission.UN_AUTHORIZED must grant every role: a role denied here
|
||||
// is redirected to /un-authorized and then redirected away from it again,
|
||||
// which loops until React bails out with a blank screen.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.UN_AUTHORIZED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.UN_AUTHORIZED);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
@@ -1477,6 +1518,174 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZ Support (routeWithInitialAuthZSupport)', () => {
|
||||
// Routes in routeWithInitialAuthZSupport always bypass legacy role check
|
||||
const AUTHZ_ROUTE_CASES: Record<
|
||||
keyof typeof routeWithInitialAuthZSupport,
|
||||
AuthzRouteCase
|
||||
> = {
|
||||
// Everything under /settings resolves to the non-exact SETTINGS route
|
||||
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
ROLES_SETTINGS: { path: ROUTES.ROLES_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
ROLE_CREATE: { path: ROUTES.ROLE_CREATE, deniedRoles: DENIED_ROLES },
|
||||
ROLE_DETAILS: {
|
||||
path: ROUTES.ROLE_DETAILS.replace(':roleId', 'role-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
ROLE_EDIT: {
|
||||
path: ROUTES.ROLE_EDIT.replace(':roleId', 'role-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
SERVICE_ACCOUNTS_SETTINGS: {
|
||||
path: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
|
||||
TRACE_DETAIL: {
|
||||
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACE_DETAIL_OLD: {
|
||||
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
|
||||
// route definition comes last, and both keys are authz-aware either way.
|
||||
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
|
||||
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
|
||||
OLD_LOGS_EXPLORER: {
|
||||
path: ROUTES.OLD_LOGS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: {
|
||||
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METER: { path: ROUTES.METER, deniedRoles: DENIED_ROLES },
|
||||
METER_EXPLORER: { path: ROUTES.METER_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
// SUPPORT already grants ANONYMOUS in routePermission, so only AUTHOR
|
||||
// exercises the redirect branch here.
|
||||
SUPPORT: {
|
||||
path: ROUTES.SUPPORT,
|
||||
deniedRoles: [USER_ROLES.AUTHOR as ROLES],
|
||||
},
|
||||
};
|
||||
|
||||
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(
|
||||
AUTHZ_ROUTE_CASES,
|
||||
).flatMap(([name, { path, deniedRoles }]) =>
|
||||
deniedRoles.map((role): [string, string, ROLES] => [name, path, role]),
|
||||
);
|
||||
|
||||
// Routes with no authz check - legacy role check still applies
|
||||
const NON_AUTHZ_ROUTE_CASES: [string, string, ROLES][] = [
|
||||
['ALERTS_NEW', ROUTES.ALERTS_NEW, USER_ROLES.VIEWER as ROLES],
|
||||
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
|
||||
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
|
||||
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
|
||||
[
|
||||
'METRICS_EXPLORER_VIEWS',
|
||||
ROUTES.METRICS_EXPLORER_VIEWS,
|
||||
USER_ROLES.ANONYMOUS as ROLES,
|
||||
],
|
||||
];
|
||||
|
||||
it.each(authzRouteRolePairs)(
|
||||
'should not redirect %s (%s) for role %s - authz routes bypass legacy role check',
|
||||
(_name, path, role) => {
|
||||
// Routes in routeWithInitialAuthZSupport always bypass the legacy role check.
|
||||
// Authorization is handled by downstream components via fine-grained authz.
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(path);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
|
||||
'should not redirect a %s from an authz-aware route',
|
||||
(role) => {
|
||||
// All roles pass through - authorization handled downstream
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.LOGS_EXPLORER);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NON_AUTHZ_ROUTE_CASES)(
|
||||
'should redirect %s (%s) for role %s - non-authz routes use legacy role check',
|
||||
async (_name, path, role) => {
|
||||
// Routes NOT in routeWithInitialAuthZSupport still use legacy role check
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(authzRouteRolePairs)(
|
||||
'should still redirect unauthenticated users away from %s (%s) with role %s',
|
||||
async (_name, path, role) => {
|
||||
// The authz bypass only relaxes the role check, never the login check.
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: false,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.LOGIN);
|
||||
},
|
||||
);
|
||||
|
||||
it('should still redirect to workspace locked from an authz-aware route', async () => {
|
||||
// Workspace guards run before the role check and must not be bypassed.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicense: createMockLicense({ platform: LicensePlatform.CLOUD }),
|
||||
trialInfo: createMockTrialInfo({ workSpaceBlock: true }),
|
||||
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
|
||||
},
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Old channel route redirects', () => {
|
||||
it.each([
|
||||
['/settings/channels', '/alerts', 'tab=Channels'],
|
||||
|
||||
@@ -2815,6 +2815,9 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
redis = 'redis',
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
memorystore_redis = 'memorystore_redis',
|
||||
computeengine = 'computeengine',
|
||||
gke = 'gke',
|
||||
cloudstorage = 'cloudstorage',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="800" viewBox="-19.2 -28.483 166.401 170.898"><g transform="translate(0 -7.034)"><linearGradient id="a" x1="64" x2="64" y1="7.034" y2="120.789" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4387fd"/><stop offset="1" stop-color="#4683ea"/></linearGradient><path fill="url(#a)" d="M27.79 115.217 1.54 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.96-5.75h52.5a11.5 11.5 0 0 1 9.959 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.959 5.75h-52.5a11.5 11.5 0 0 1-9.96-5.75z"/></g><g transform="translate(0 -7.034)"><defs><path id="b" d="M27.791 115.217 1.541 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.959-5.75h52.5a11.5 11.5 0 0 1 9.96 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.96 5.75h-52.5a11.5 11.5 0 0 1-9.959-5.75z"/></defs><clipPath id="c"><use xlink:href="#b" width="100%" height="100%" overflow="visible"/></clipPath><path d="m49.313 53.875-7.01 6.99 5.957 5.958-5.898 10.476 44.635 44.636 10.816.002L118.936 84 85.489 50.55z" clip-path="url(#c)" opacity=".07"/></g><path fill="#fff" d="M84.7 43.236H43.264c-.667 0-1.212.546-1.212 1.214v8.566c0 .666.546 1.212 1.212 1.212H84.7c.667 0 1.213-.546 1.213-1.212v-8.568c0-.666-.545-1.213-1.212-1.213m-6.416 7.976a2.484 2.484 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477c1.37 0 2.48 1.103 2.48 2.477a2.48 2.48 0 0 1-2.48 2.48m6.415 8.491-41.436.002c-.667 0-1.212.546-1.212 1.214v8.565c0 .666.546 1.213 1.212 1.213H84.7c.667 0 1.213-.547 1.213-1.213v-8.567c0-.666-.545-1.214-1.212-1.214m-6.416 7.976a2.483 2.483 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477 2.48 2.48 0 1 1 0 4.956"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}.cls-4{fill:#fff;}</style></defs><title>Icon_24px_CloudStorage_Color</title><g data-name="Product Icons"><rect class="cls-1" x="2" y="4" width="20" height="7"/><rect class="cls-2" x="20" y="4" width="2" height="7"/><polygon class="cls-3" points="22 4 20 4 20 11 22 4"/><rect class="cls-2" x="2" y="4" width="2" height="7"/><rect class="cls-4" x="6" y="7" width="6" height="1"/><rect class="cls-4" x="15" y="6" width="3" height="3" rx="1.5"/><rect class="cls-1" x="2" y="13" width="20" height="7"/><rect class="cls-2" x="20" y="13" width="2" height="7"/><polygon class="cls-3" points="22 13 20 13 20 20 22 13"/><rect class="cls-2" x="2" y="13" width="2" height="7"/><rect class="cls-4" x="6" y="16" width="6" height="1"/><rect class="cls-4" x="15" y="15" width="3" height="3" rx="1.5"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 958 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M19.73 6.56a1.73 1.73 0 0 0-1.68 1.68 1.83 1.83 0 0 0 .89 1.48v6.9l-5.16 3.06.8 1.28 5.55-3.25a.84.84 0 0 0 .4-.69v-7.3a1.64 1.64 0 0 0 .89-1.48 1.61 1.61 0 0 0-1.69-1.68" style="fill:#aecbfa"/><path d="m18 5.48-5.61-3.16a1.18 1.18 0 0 0-.79 0L5.25 6a1.72 1.72 0 0 0-2.68 1.35A1.73 1.73 0 0 0 4.26 9a1.73 1.73 0 0 0 1.68-1.65L12 3.9l5.15 3ZM11.2 18.5a1.57 1.57 0 0 0-.89.29l-5.16-3V9.92H3.56v6.31a.84.84 0 0 0 .4.69L9.52 20v.09a1.69 1.69 0 0 0 3.37 0 1.65 1.65 0 0 0-1.69-1.59" data-name="Path" style="fill:#aecbfa"/><path d="M16.96 8.63 12.1 5.78 7.13 8.63l4.97 2.77z" data-name="Path" style="fill:#669df6"/><path d="M12.1 12.38 6.84 9.32v2.47l5.26 2.96zM12.1 15.73l-5.26-3.05v2.07l5.26 3.06z" style="fill:#669df6"/><path d="M12.09 12.38v2.37l5.26-3.06V9.33Zm4.32-.94a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76M12.09 15.73v2.07l5.26-3.05v-2.07Zm4.32-1.07a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76" style="fill:#4285f4"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#4285f4;}.cls-1,.cls-2,.cls-4{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3,.cls-4{fill:#aecbfa;}</style></defs><title>Icon_24px_K8Engine_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="14.68 13.06 19.23 15.69 19.23 16.68 14.29 13.83 14.68 13.06"/><polygon class="cls-2" points="9.98 13.65 4.77 16.66 4.45 15.86 9.53 12.92 9.98 13.65"/><rect class="cls-3" x="11.55" y="3.29" width="0.86" height="5.78"/><path class="cls-4" d="M3.25,7V17L12,22l8.74-5V7L12,2Zm15.63,8.89L12,19.78,5.12,15.89V8.11L12,4.22l6.87,3.89v7.78Z"/><polygon class="cls-4" points="11.98 11.5 15.96 9.21 11.98 6.91 8.01 9.21 11.98 11.5"/><polygon class="cls-2" points="11.52 12.3 7.66 10.01 7.66 14.6 11.52 16.89 11.52 12.3"/><polygon class="cls-1" points="12.48 12.3 12.48 16.89 16.34 14.6 16.34 10.01 12.48 12.3"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 988 B After Width: | Height: | Size: 941 B |
@@ -438,7 +438,11 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
|
||||
@@ -49,7 +49,11 @@ import { unquote } from 'utils/stringUtils';
|
||||
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
|
||||
import {
|
||||
queryExamples,
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -353,7 +357,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchKeySuggestions = useMemo(
|
||||
() => debounce(fetchKeySuggestions, 300),
|
||||
() => debounce(fetchKeySuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchKeySuggestions],
|
||||
);
|
||||
|
||||
@@ -584,7 +588,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchValueSuggestions = useMemo(
|
||||
() => debounce(fetchValueSuggestions, 300),
|
||||
() => debounce(fetchValueSuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchValueSuggestions],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
|
||||
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
|
||||
|
||||
export const SUGGESTION_FETCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// TODO: move to using TelemetrytypesFieldContextDTO when we migrate getKeySuggestions API (Part of https://github.com/SigNoz/engineering-pod/issues/5289)
|
||||
export const FIELD_CONTEXTS = [
|
||||
'attribute',
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
|
||||
// tests aren't paced by it; coalescing semantics stay intact.
|
||||
jest.mock('../QuerySearch/constants', () => ({
|
||||
...jest.requireActual('../QuerySearch/constants'),
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS: 30,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
return {
|
||||
|
||||
@@ -88,6 +88,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
{...props}
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
data-testid={context?.getRowTestId?.(rowData)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={clearHovered}
|
||||
>
|
||||
@@ -154,6 +155,12 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevTestId = prev.context?.getRowTestId?.(prevData) ?? '';
|
||||
const nextTestId = next.context?.getRowTestId?.(nextData) ?? '';
|
||||
if (prevTestId !== nextTestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
||||
const nextStyle = next.context?.getRowStyle?.(nextData);
|
||||
if (prevStyle !== nextStyle) {
|
||||
|
||||
@@ -33,7 +33,6 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
// Stable references via destructuring, keep them as is
|
||||
const onRowClick = context?.onRowClick;
|
||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||
const onRowDeactivate = context?.onRowDeactivate;
|
||||
const isRowActive = context?.isRowActive;
|
||||
const getRowKeyData = context?.getRowKeyData;
|
||||
const rowIndex = row.index;
|
||||
@@ -52,21 +51,9 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
}
|
||||
|
||||
const isActive = isRowActive?.(rowData) ?? false;
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
onRowClick?.(rowData, itemKey, { isActive });
|
||||
},
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
@@ -120,7 +107,6 @@ function areRowCellsPropsEqual<TData>(
|
||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
||||
|
||||
@@ -92,11 +92,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
getGroupKey,
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
@@ -378,11 +378,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
@@ -396,11 +396,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
|
||||
@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||
it('calls onRowClick with isActive: true when the row is active', async () => {
|
||||
// The table no longer owns open/close — it reports the active state and the
|
||||
// consumer routes the click. An active row must still fire onRowClick.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowClick with isActive: false when the row is not active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
isRowActive: () => false,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render renderRowActions before hover', () => {
|
||||
|
||||
@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
it('calls onRowClick with isActive: true when clicking the active row', async () => {
|
||||
// The consumer owns open/close routing — the table just reports the
|
||||
// active state via the click context.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.anything(),
|
||||
{ isActive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
|
||||
@@ -116,9 +116,11 @@ export * from './useTableParams';
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* // The table reports the click + the row's active state; the consumer owns open/close.
|
||||
* onRowClick={(row, itemKey, { isActive }) =>
|
||||
* setSelectedId(isActive ? undefined : itemKey)
|
||||
* }
|
||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||
* onRowDeactivate={() => setSelectedId(undefined)}
|
||||
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
||||
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
||||
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
||||
|
||||
@@ -81,15 +81,19 @@ export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type RowClickContext = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type TableRowContext<TData, TItemKey = string> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
@@ -178,12 +182,12 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
getGroupKey?: (row: TData) => Record<string, string>;
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
|
||||
@@ -104,6 +104,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
userPreferences,
|
||||
isFetchingUserPreferences,
|
||||
updateChangelog,
|
||||
toggleChangelogModal,
|
||||
showChangelogModal,
|
||||
@@ -724,12 +725,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set sidebar as loaded after user preferences are fetched
|
||||
// Set sidebar as loaded after user preferences fetch completes (success or error)
|
||||
useEffect(() => {
|
||||
if (userPreferences !== null) {
|
||||
if (!isFetchingUserPreferences) {
|
||||
setIsSidebarLoaded(true);
|
||||
}
|
||||
}, [userPreferences]);
|
||||
}, [isFetchingUserPreferences]);
|
||||
|
||||
// Use localStorage value as fallback until preferences are loaded
|
||||
const isSideNavPinned = isSidebarLoaded
|
||||
|
||||
@@ -60,3 +60,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ function LiveLogsList({
|
||||
data={formattedLogs}
|
||||
isLoading={false}
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -216,10 +219,13 @@ function LiveLogsList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
|
||||
@@ -323,3 +323,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -211,9 +211,11 @@ function LogsExplorerList({
|
||||
data={logs}
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={onEndReached}
|
||||
isRowActive={(log): boolean =>
|
||||
log.id === activeLog?.id || log.id === activeLogId
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowTestId={(log): string => `logs-table-row-${log.id}`}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -226,10 +228,13 @@ function LogsExplorerList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
@@ -282,6 +287,7 @@ function LogsExplorerList({
|
||||
options.maxLines,
|
||||
options.fontSize,
|
||||
activeLogIndex,
|
||||
activeLogId,
|
||||
logs,
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
|
||||
@@ -151,7 +151,9 @@ describe('JsonEditor', () => {
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'test-key-123{enter}');
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = await screen.findByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
import { expandAllCards, renderCreateRolePage } from './testUtils';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
@@ -21,35 +12,16 @@ afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
async function renderPage(): Promise<ReturnType<typeof render>> {
|
||||
const result = render(
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
await screen.findByTestId('permission-editor');
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('PermissionEditor', () => {
|
||||
describe('mode toggle', () => {
|
||||
it('renders permission editor with testId', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to interactive mode', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
@@ -59,7 +31,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('switches to JSON mode when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
@@ -70,7 +42,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('switches back to interactive mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
@@ -87,7 +59,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('resource cards', () => {
|
||||
it('renders all resource cards', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-card-factor-api-key'),
|
||||
@@ -99,7 +71,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('resource cards are collapsed by default', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -111,7 +83,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('expands resource card when header clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -125,7 +97,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('collapses expanded resource card when header clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -139,7 +111,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('shows granted count in resource card header', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
await expect(
|
||||
@@ -150,7 +122,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('action toggles', () => {
|
||||
it('renders action toggles for each available action', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -169,7 +141,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('defaults all actions to None scope', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -187,7 +159,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('changes scope to All when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -208,7 +180,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('updates granted count when scope changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -227,7 +199,7 @@ describe('PermissionEditor', () => {
|
||||
describe('Only Selected scope', () => {
|
||||
it('shows item input selector when Only Selected is chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -239,12 +211,14 @@ describe('PermissionEditor', () => {
|
||||
await within(createToggle).findByText('Only selected');
|
||||
await user.click(onlySelectedBtn);
|
||||
|
||||
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('item-input-selector-factor-api-key-read'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds item when typed and Enter pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -254,7 +228,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'api-key-001{enter}');
|
||||
|
||||
await expect(screen.findByText('api-key-001')).resolves.toBeInTheDocument();
|
||||
@@ -262,7 +238,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds item when Add button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -272,10 +248,14 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'api-key-002');
|
||||
|
||||
const addBtn = screen.getByTestId('item-input-selector-add-btn');
|
||||
const addBtn = screen.getByTestId(
|
||||
'item-input-selector-add-btn-factor-api-key-read',
|
||||
);
|
||||
await user.click(addBtn);
|
||||
|
||||
await expect(screen.findByText('api-key-002')).resolves.toBeInTheDocument();
|
||||
@@ -283,7 +263,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by comma', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -293,7 +273,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-a, key-b, key-c{enter}');
|
||||
|
||||
await expect(screen.findByText('key-a')).resolves.toBeInTheDocument();
|
||||
@@ -303,7 +285,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by space', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -313,7 +295,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-x key-y key-z{enter}');
|
||||
|
||||
await expect(screen.findByText('key-x')).resolves.toBeInTheDocument();
|
||||
@@ -323,7 +307,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('does not add duplicate items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -333,7 +317,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'same-key{enter}');
|
||||
await user.type(input, 'same-key{enter}');
|
||||
|
||||
@@ -343,7 +329,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('removes item when X clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -353,20 +339,138 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'removable-key{enter}');
|
||||
|
||||
const removeBtn = screen.getByRole('button', {
|
||||
name: /remove removable-key/i,
|
||||
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
|
||||
const removeBtn = within(badge).getByRole('button', {
|
||||
name: 'Remove removable-key',
|
||||
});
|
||||
await user.click(removeBtn);
|
||||
|
||||
expect(screen.queryByText('removable-key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each badge close button after the item it removes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-one key-two{enter}');
|
||||
|
||||
const firstBadge = await screen.findByTestId(
|
||||
'item-badge-factor-api-key-read-0',
|
||||
);
|
||||
const secondBadge = screen.getByTestId('item-badge-factor-api-key-read-1');
|
||||
|
||||
expect(
|
||||
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the full item value as a title so truncated badges stay readable', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'a-very-long-api-key-identifier-000001{enter}');
|
||||
|
||||
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
|
||||
expect(
|
||||
within(badge).getByTitle('a-very-long-api-key-identifier-000001'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves focus to the previous badge when closed with the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-one key-two{enter}');
|
||||
|
||||
const secondBadge = await screen.findByTestId(
|
||||
'item-badge-factor-api-key-read-1',
|
||||
);
|
||||
within(secondBadge).getByRole('button', { name: 'Remove key-two' }).focus();
|
||||
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
|
||||
expect(
|
||||
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
|
||||
).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not steal focus when a badge is closed with the mouse', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-one key-two{enter}');
|
||||
|
||||
const secondBadge = await screen.findByTestId(
|
||||
'item-badge-factor-api-key-read-1',
|
||||
);
|
||||
await user.click(
|
||||
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('key-two')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
|
||||
expect(
|
||||
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
|
||||
).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('shows Add button disabled when input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -376,7 +480,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const addBtn = screen.getByTestId('item-input-selector-add-btn');
|
||||
const addBtn = screen.getByTestId(
|
||||
'item-input-selector-add-btn-factor-api-key-read',
|
||||
);
|
||||
expect(addBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -384,7 +490,7 @@ describe('PermissionEditor', () => {
|
||||
describe('scope change confirmation dialog', () => {
|
||||
it('shows confirm dialog when leaving Only Selected with items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -394,7 +500,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'will-be-cleared{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
@@ -406,7 +514,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('clears items when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -416,7 +524,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'to-be-cleared{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
@@ -433,7 +543,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('keeps items when cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -443,7 +553,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'preserved-key{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('None'));
|
||||
@@ -455,12 +567,14 @@ describe('PermissionEditor', () => {
|
||||
screen.findByText('preserved-key'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('item-input-selector-factor-api-key-read'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show dialog when leaving Only Selected with no items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -479,7 +593,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('verbs without Only Selected option', () => {
|
||||
it('does not show Only Selected for list verb', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -501,7 +615,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('collapse/expand all resources', () => {
|
||||
it('shows expand/collapse toggle group', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
|
||||
@@ -509,7 +623,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('expands all cards when expand button clicked', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -523,7 +637,7 @@ describe('PermissionEditor', () => {
|
||||
describe('resource card error states', () => {
|
||||
it('shows error border on collapsed card with validation error', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
@@ -553,7 +667,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('hides error border when card is expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
@@ -590,7 +704,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('clears validation error when permission is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { expandAllCards, renderCreateRolePage } from './testUtils';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
async function selectOnlySelected(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
resource = 'logs',
|
||||
): Promise<void> {
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const card = screen.getByTestId(`resource-card-${resource}`);
|
||||
const readToggle = within(card).getByTestId(`action-toggle-${resource}-read`);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
}
|
||||
|
||||
async function selectLogsOnlySelected(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
await selectOnlySelected(user, 'logs');
|
||||
}
|
||||
|
||||
async function openWizard(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
resource = 'logs',
|
||||
): Promise<void> {
|
||||
await selectOnlySelected(user, resource);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(`telemetry-wizard-trigger-${resource}-read`),
|
||||
);
|
||||
await screen.findByTestId(`telemetry-wizard-dialog-${resource}-read`);
|
||||
}
|
||||
|
||||
async function openLogsWizard(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
await openWizard(user, 'logs');
|
||||
}
|
||||
|
||||
describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
it('shows wizard button for telemetry resources', async () => {
|
||||
const user = userEvent.setup();
|
||||
await selectLogsOnlySelected(user);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('telemetry-wizard-trigger-logs-read'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show wizard button for non-telemetry resources', async () => {
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('telemetry-wizard-trigger-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens wizard dialog when trigger clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await selectLogsOnlySelected(user);
|
||||
|
||||
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('telemetry-wizard-dialog-logs-read'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a query-type wildcard when the value is left empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/*'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer builder sub query', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
|
||||
expect(screen.queryByText('Builder Sub Query')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show PromQL for logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-query-type-option-promql-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show PromQL for traces', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openWizard(user, 'traces');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-traces-read'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-query-type-option-promql-traces-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows PromQL for metrics', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openWizard(user, 'metrics');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-metrics-read'));
|
||||
await user.click(
|
||||
await screen.findByTestId('wizard-query-type-option-promql-metrics-read'),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-metrics-read'));
|
||||
|
||||
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hardcodes the key and does not let it be edited', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
|
||||
expect(keyInput).toHaveValue('signoz.workspace.key.id');
|
||||
expect(keyInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides Key field for query types that do not support key scoping', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-key-input-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a key-scoped selector when the value is filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/signoz.workspace.key.id/123',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/signoz.workspace.key.id/123'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces the value with a wildcard when any resource is checked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByLabelText('Any value'));
|
||||
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('*');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/signoz.workspace.key.id/*'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the value when any resource is unchecked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const anyResource = screen.getByLabelText('Any value');
|
||||
await user.click(anyResource);
|
||||
expect(anyResource).toBeChecked();
|
||||
|
||||
await user.click(anyResource);
|
||||
|
||||
expect(anyResource).not.toBeChecked();
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('checks any resource when the value is typed as a wildcard', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '*');
|
||||
|
||||
expect(screen.getByLabelText('Any value')).toBeChecked();
|
||||
});
|
||||
|
||||
it('disables value scoping for query types that do not support it', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Any value')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('clears the value when switching to a query type without key scoping', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'clickhouse_sql/*',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('clickhouse_sql/*'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows a hand-edited selector on the query type and value inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(
|
||||
selectorInput,
|
||||
'clickhouse_sql/signoz.workspace.key.id/checkout',
|
||||
);
|
||||
|
||||
const selectTrigger = screen.getByTestId(
|
||||
'wizard-query-type-select-logs-read',
|
||||
);
|
||||
expect(within(selectTrigger).getByText('ClickHouse SQL')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
|
||||
'checkout',
|
||||
);
|
||||
});
|
||||
|
||||
it('checks any resource when the selector is edited to a wildcard value', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/*');
|
||||
|
||||
expect(screen.getByLabelText('Any value')).toBeChecked();
|
||||
});
|
||||
|
||||
it('keeps the key input hardcoded when the selector uses another key', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
|
||||
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('restores the hardcoded key in the selector once the value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
|
||||
|
||||
expect(selectorInput).toHaveValue(
|
||||
'builder_query/signoz.workspace.key.id/frontend2',
|
||||
);
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('blocks adding when the selector has an unknown query type', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'sql_query/*');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('"sql_query" is not a supported query type.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('blocks adding when the selector is emptied', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.clear(screen.getByTestId('wizard-selector-input-logs-read'));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Enter a selector.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds the hand-edited selector verbatim', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/a/b');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/signoz.workspace.key.id/a/b'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dialog after adding selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add duplicate selectors', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
|
||||
await screen.findByTestId('telemetry-wizard-dialog-logs-read');
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
const badges = screen.getAllByText('builder_query/*');
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resets wizard state when dialog is closed and reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
|
||||
const selectTrigger = await screen.findByTestId(
|
||||
'wizard-query-type-select-logs-read',
|
||||
);
|
||||
|
||||
expect(within(selectTrigger).getByText('Builder Query')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/*',
|
||||
);
|
||||
});
|
||||
|
||||
it('previews the query-type wildcard while the value is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/*',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Allow every "Builder Query" query.');
|
||||
});
|
||||
|
||||
it('describes a key-scoped selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent(
|
||||
'Allow signoz.workspace.key.id=123 for Builder Query queries.',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes an any-resource selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByLabelText('Any value'));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent(
|
||||
'Allow every signoz.workspace.key.id for Builder Query queries.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show query type descriptions', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Visual query builder for selecting data sources, filters, and aggregations',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
export async function renderCreateRolePage(): Promise<
|
||||
ReturnType<typeof render>
|
||||
> {
|
||||
const result = render(
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
await screen.findByTestId('permission-editor');
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = await screen.findByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { PermissionScope } from '../../types';
|
||||
import { getResourcePanel } from '../../permissions.config';
|
||||
import ItemInputSelector from './ItemInputSelector';
|
||||
import TelemetrySelectorWizard from './TelemetrySelectorWizard';
|
||||
|
||||
import styles from './ActionToggle.module.scss';
|
||||
import { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
@@ -39,10 +40,13 @@ function ActionToggle({
|
||||
onSelectedIdsChange,
|
||||
hasError = false,
|
||||
}: ActionToggleProps): JSX.Element {
|
||||
const panel = getResourcePanel(resource);
|
||||
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingScope, setPendingScope] = useState<PermissionScope | null>(null);
|
||||
|
||||
const displayLabel = getActionLabel(action);
|
||||
const selectorTestId = `${resource}-${action}`;
|
||||
|
||||
const scopeItems: Array<{ value: PermissionScope; label: string }> =
|
||||
useMemo(() => {
|
||||
@@ -121,11 +125,25 @@ function ActionToggle({
|
||||
<Divider />
|
||||
|
||||
<ItemInputSelector
|
||||
placeholder={getResourcePanel(resource).selectorPlaceholder}
|
||||
placeholder={panel.selectorPlaceholder}
|
||||
selectedIds={selectedIds}
|
||||
onChange={onSelectedIdsChange}
|
||||
docsAnchor={getResourcePanel(resource).docsAnchor}
|
||||
testId={selectorTestId}
|
||||
docsAnchor={panel.docsAnchor}
|
||||
hasError={hasError}
|
||||
prefixElement={
|
||||
panel.selectorType === 'telemetryBuilder' ? (
|
||||
<TelemetrySelectorWizard
|
||||
resource={resource}
|
||||
testId={selectorTestId}
|
||||
onAdd={(selector): void => {
|
||||
if (!selectedIds.includes(selector)) {
|
||||
onSelectedIdsChange([...selectedIds, selector]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
gap: var(--spacing-4);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--spacing-4);
|
||||
|
||||
--input-prefix-padding: var(--spacing-2);
|
||||
--input-suffix-padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -41,6 +42,11 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadge {
|
||||
--badge-border-radius: 4px;
|
||||
--badge-hover-background: var(--l2-background) !important;
|
||||
}
|
||||
|
||||
.itemInputSelectorInfoIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
@@ -51,55 +57,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.itemInputSelectorBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 140px;
|
||||
padding: 2px 4px 2px 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadgeLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadgeRemove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.itemInputSelectorHint {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Info, Plus, X } from '@signozhq/icons';
|
||||
import { Info, Plus } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -15,8 +16,10 @@ export interface ItemInputSelectorProps {
|
||||
placeholder: string;
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
testId: string;
|
||||
docsAnchor?: string;
|
||||
hasError?: boolean;
|
||||
prefixElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
function parseInputValues(input: string): string[] {
|
||||
@@ -30,11 +33,13 @@ function ItemInputSelector({
|
||||
placeholder,
|
||||
selectedIds,
|
||||
onChange,
|
||||
testId,
|
||||
docsAnchor = 'role',
|
||||
hasError = false,
|
||||
prefixElement,
|
||||
}: ItemInputSelectorProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const badgesRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addValues = useCallback(
|
||||
(input: string): void => {
|
||||
@@ -87,26 +92,24 @@ function ItemInputSelector({
|
||||
[selectedIds, onChange],
|
||||
);
|
||||
|
||||
const handleBadgeKeyDown = useCallback(
|
||||
(
|
||||
e: React.KeyboardEvent<HTMLButtonElement>,
|
||||
itemId: string,
|
||||
index: number,
|
||||
): void => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBadgeClose = useCallback(
|
||||
(e: React.MouseEvent, itemId: string, index: number): void => {
|
||||
e.preventDefault();
|
||||
handleRemove(itemId);
|
||||
|
||||
// Activating a button via Enter/Space reports detail 0;
|
||||
// a real click reports 1 or more
|
||||
// Only trigger focus when using keyboard
|
||||
const isKeyboardActivation = e.detail === 0;
|
||||
|
||||
if (!isKeyboardActivation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = index > 0 ? index - 1 : 0;
|
||||
requestAnimationFrame(() => {
|
||||
const buttons = footerRef.current?.querySelectorAll('button');
|
||||
const targetButton = buttons?.[targetIndex] as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
targetButton?.focus();
|
||||
const buttons = badgesRef.current?.querySelectorAll('button');
|
||||
buttons?.[targetIndex]?.focus();
|
||||
});
|
||||
},
|
||||
[handleRemove],
|
||||
@@ -120,7 +123,7 @@ function ItemInputSelector({
|
||||
styles.itemInputSelector,
|
||||
showError ? styles.itemInputSelectorError : '',
|
||||
)}
|
||||
data-testid="item-input-selector"
|
||||
data-testid={`item-input-selector-${testId}`}
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
@@ -128,14 +131,15 @@ function ItemInputSelector({
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
data-testid="item-input-selector-input"
|
||||
data-testid={`item-input-selector-input-${testId}`}
|
||||
prefix={prefixElement}
|
||||
suffix={
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleAddClick}
|
||||
disabled={!inputValue.trim()}
|
||||
data-testid="item-input-selector-add-btn"
|
||||
data-testid={`item-input-selector-add-btn-${testId}`}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
@@ -144,28 +148,22 @@ function ItemInputSelector({
|
||||
/>
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<div ref={footerRef} className={styles.itemInputSelectorFooter}>
|
||||
<div className={styles.itemInputSelectorBadges}>
|
||||
<div className={styles.itemInputSelectorFooter}>
|
||||
<div ref={badgesRef} className={styles.itemInputSelectorBadges}>
|
||||
{selectedIds.map((id, index) => (
|
||||
<span key={id} className={styles.itemInputSelectorBadge} title={id}>
|
||||
<Typography
|
||||
as="span"
|
||||
size="small"
|
||||
truncate={1}
|
||||
className={styles.itemInputSelectorBadgeLabel}
|
||||
>
|
||||
<Badge
|
||||
key={id}
|
||||
color="secondary"
|
||||
className={styles.itemInputSelectorBadge}
|
||||
testId={`item-badge-${testId}-${index}`}
|
||||
closable
|
||||
closeAriaLabel={`Remove ${id}`}
|
||||
onClose={(e): void => handleBadgeClose(e, id, index)}
|
||||
>
|
||||
<Typography as="span" size="small" truncate={1} title={id}>
|
||||
{id}
|
||||
</Typography>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.itemInputSelectorBadgeRemove}
|
||||
onClick={(): void => handleRemove(id)}
|
||||
onKeyDown={(e): void => handleBadgeKeyDown(e, id, index)}
|
||||
aria-label={`Remove ${id}`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<TooltipSimple
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// intentionally omitting few query types
|
||||
// from pkg/types/querybuildertypes/querybuildertypesv5/query_type.go
|
||||
export type QueryTypeId = 'builder_query' | 'promql' | 'clickhouse_sql';
|
||||
|
||||
export interface QueryTypeOption {
|
||||
id: QueryTypeId;
|
||||
label: string;
|
||||
supportsKeyScoping: boolean;
|
||||
metricsOnly?: boolean;
|
||||
}
|
||||
|
||||
export const QUERY_TYPES: readonly QueryTypeOption[] = [
|
||||
{
|
||||
id: 'builder_query',
|
||||
label: 'Builder Query',
|
||||
supportsKeyScoping: true,
|
||||
},
|
||||
{
|
||||
id: 'promql',
|
||||
label: 'PromQL',
|
||||
supportsKeyScoping: false,
|
||||
metricsOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'clickhouse_sql',
|
||||
label: 'ClickHouse SQL',
|
||||
supportsKeyScoping: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
|
||||
|
||||
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
|
||||
|
||||
export const ANY_RESOURCE_VALUE = '*';
|
||||
|
||||
export interface SelectorDraft {
|
||||
queryType: QueryTypeId;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ParsedSelector {
|
||||
queryType?: QueryTypeId;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SelectorValidation {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.wizardDialog {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
--select-trigger-background-color: var(--l3-background);
|
||||
--select-trigger-border-color: var(--l3-background);
|
||||
--select-content-background: var(--l3-background);
|
||||
--select-item-highlight-background: var(--l3-background-hover);
|
||||
--select-trigger-outline-width: 1px;
|
||||
--select-trigger-outline-offset: 0px;
|
||||
|
||||
--input-background: var(--l3-background);
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-focus-background: var(--l3-background);
|
||||
--input-disabled-background: var(--l3-background);
|
||||
--input-border-color: var(--l3-border);
|
||||
--input-hover-border-color: var(--l3-border);
|
||||
--input-focus-border-color: var(--l3-border);
|
||||
|
||||
outline: none;
|
||||
|
||||
& > div {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.wizardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.wizardField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.wizardValueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.wizardValueInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectContent {
|
||||
z-index: 10;
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Wand } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
QUERY_TYPES,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
|
||||
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
|
||||
|
||||
import styles from './TelemetrySelectorWizard.module.scss';
|
||||
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
interface TelemetrySelectorWizardProps {
|
||||
onAdd: (selector: string) => void;
|
||||
resource: AuthZResource;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function TelemetrySelectorWizard({
|
||||
onAdd,
|
||||
resource,
|
||||
testId,
|
||||
}: TelemetrySelectorWizardProps): JSX.Element {
|
||||
const {
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource,
|
||||
supportsKeyScoping,
|
||||
validation,
|
||||
canAdd,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
handleAdd,
|
||||
handleInputKeyDown,
|
||||
} = useTelemetrySelectorWizard({ onAdd });
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
data-testid={`telemetry-wizard-trigger-${testId}`}
|
||||
>
|
||||
<Wand size={14} />
|
||||
Wizard
|
||||
</Button>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={handleAdd}
|
||||
disabled={!canAdd}
|
||||
data-testid={`wizard-add-btn-${testId}`}
|
||||
>
|
||||
Add Selector
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Selector Wizard"
|
||||
width="wide"
|
||||
testId={`telemetry-wizard-dialog-${testId}`}
|
||||
trigger={trigger}
|
||||
footer={footer}
|
||||
className={styles.wizardDialog}
|
||||
>
|
||||
<div className={styles.wizardBody}>
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Query Type
|
||||
</Typography>
|
||||
<Select value={queryType} onChange={handleQueryTypeChange}>
|
||||
<SelectTrigger data-testid={`wizard-query-type-select-${testId}`}>
|
||||
<SelectValue>{selectedQueryType?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent withPortal={false} className={styles.selectContent}>
|
||||
{QUERY_TYPES.filter((queryTypeOption) =>
|
||||
isQueryTypeAvailable(queryTypeOption, resource),
|
||||
).map((queryTypeOption) => (
|
||||
<SelectItem
|
||||
key={queryTypeOption.id}
|
||||
value={queryTypeOption.id}
|
||||
testId={`wizard-query-type-option-${queryTypeOption.id}-${testId}`}
|
||||
>
|
||||
{queryTypeOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{supportsKeyScoping && (
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Key
|
||||
</Typography>
|
||||
<Input
|
||||
value={SUPPORTED_GRANT_KEY}
|
||||
readOnly
|
||||
disabled
|
||||
testId={`wizard-key-input-${testId}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Value
|
||||
</Typography>
|
||||
<div className={styles.wizardValueRow}>
|
||||
<Input
|
||||
className={styles.wizardValueInput}
|
||||
placeholder={
|
||||
supportsKeyScoping
|
||||
? 'Value or leave empty to allow every query'
|
||||
: ANY_RESOURCE_VALUE
|
||||
}
|
||||
value={value}
|
||||
disabled={!supportsKeyScoping}
|
||||
onChange={handleValueChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
testId={`wizard-value-input-${testId}`}
|
||||
/>
|
||||
<Checkbox
|
||||
id={`wizard-any-resource-${testId}`}
|
||||
value={isAnyResource}
|
||||
disabled={!supportsKeyScoping}
|
||||
onChange={(checked): void => handleAnyResourceChange(checked === true)}
|
||||
testId={`wizard-any-resource-checkbox-${testId}`}
|
||||
>
|
||||
Any value
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Selector
|
||||
</Typography>
|
||||
<Input
|
||||
value={selector}
|
||||
onChange={handleSelectorChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
testId={`wizard-selector-input-${testId}`}
|
||||
/>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={validation.isError ? 'danger' : 'muted'}
|
||||
testId={`wizard-selector-hint-${testId}`}
|
||||
>
|
||||
{validation.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default TelemetrySelectorWizard;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
ParsedSelector,
|
||||
QUERY_TYPES,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
SelectorDraft,
|
||||
SelectorValidation,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
|
||||
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
|
||||
export function getQueryTypeOption(
|
||||
queryType: string,
|
||||
): QueryTypeOption | undefined {
|
||||
return QUERY_TYPES.find((option) => option.id === queryType);
|
||||
}
|
||||
|
||||
export function isQueryTypeAvailable(
|
||||
option: QueryTypeOption,
|
||||
resource: AuthZResource,
|
||||
): boolean {
|
||||
return !option.metricsOnly || METRIC_RESOURCES.has(resource);
|
||||
}
|
||||
|
||||
export function supportsKeyScoping(queryType: string): boolean {
|
||||
return getQueryTypeOption(queryType)?.supportsKeyScoping ?? false;
|
||||
}
|
||||
|
||||
export function isAnyResourceValue(value: string): boolean {
|
||||
return value.trim() === ANY_RESOURCE_VALUE;
|
||||
}
|
||||
|
||||
function splitSelector(selector: string): string[] {
|
||||
const parts = selector.split('/');
|
||||
|
||||
if (parts.length <= 3) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
return [parts[0], parts[1], parts.slice(2).join('/')];
|
||||
}
|
||||
|
||||
export function buildSelector({ queryType, value }: SelectorDraft): string {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (!supportsKeyScoping(queryType) || !trimmedValue) {
|
||||
return `${queryType}/${ANY_RESOURCE_VALUE}`;
|
||||
}
|
||||
|
||||
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
|
||||
}
|
||||
|
||||
export function parseSelector(selector: string): ParsedSelector {
|
||||
const parts = splitSelector(selector.trim());
|
||||
|
||||
return {
|
||||
queryType: getQueryTypeOption(parts[0])?.id,
|
||||
value: parts.length >= 3 ? parts[2] : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This does a basic validation, intentionally omitting deep validations since this is to be made at backend,
|
||||
* so this will allow to produce invalid selectors, and the validation will be done after the user try to save
|
||||
*/
|
||||
export function validateSelector(selector: string): SelectorValidation {
|
||||
const trimmed = selector.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return { message: 'Enter a selector.', isError: true };
|
||||
}
|
||||
|
||||
if (trimmed === ANY_RESOURCE_VALUE) {
|
||||
return { message: 'Allow every query of every type.', isError: false };
|
||||
}
|
||||
|
||||
const parts = splitSelector(trimmed);
|
||||
const option = getQueryTypeOption(parts[0]);
|
||||
|
||||
if (!option) {
|
||||
return {
|
||||
message: `"${parts[0]}" is not a supported query type.`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (parts.length < 3) {
|
||||
if (parts.length === 2 && parts[1] !== ANY_RESOURCE_VALUE) {
|
||||
if (!option.supportsKeyScoping) {
|
||||
return {
|
||||
message: `This query type does not support key scoping. Use ${option.id}/*`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Allow every "${option.label}" query.`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [, key, value] = parts;
|
||||
|
||||
if (value === ANY_RESOURCE_VALUE) {
|
||||
return {
|
||||
message: `Allow every ${key} for ${option.label} queries.`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!option.supportsKeyScoping) {
|
||||
return {
|
||||
message: `This query type does not support key scoping. Use ${option.id}/*`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Allow ${key}=${value} for ${option.label} queries.`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultSelector(queryType: QueryTypeId): string {
|
||||
return buildSelector({ queryType, value: '' });
|
||||
}
|
||||
@@ -76,9 +76,10 @@ function createGrantPermissionAsReadonly(
|
||||
return {
|
||||
label: 'readonly',
|
||||
insertText: resources
|
||||
.filter(
|
||||
(r) => r.allowedVerbs.includes('read') || r.allowedVerbs.includes('list'),
|
||||
)
|
||||
.filter((r) => {
|
||||
const verbs: readonly string[] = r.allowedVerbs;
|
||||
return verbs.includes('read') || verbs.includes('list');
|
||||
})
|
||||
.flatMap((r) => {
|
||||
const verbs = r.allowedVerbs.filter((v) => v === 'read' || v === 'list');
|
||||
return verbs.map(
|
||||
@@ -103,7 +104,12 @@ function buildResourceSnippets(): SnippetDef[] {
|
||||
for (const resource of resources) {
|
||||
const { kind, type, allowedVerbs } = resource;
|
||||
|
||||
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
|
||||
// If only one verb is supported, no point on add grant all
|
||||
// that will only add one permission at time, just leave the verb snippet
|
||||
// and it will look cleaner
|
||||
if (allowedVerbs.length > 1) {
|
||||
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
|
||||
}
|
||||
|
||||
for (const verb of allowedVerbs) {
|
||||
snippets.push(createGrantPermissionToVerbAndKind(kind, verb, type));
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
DEFAULT_QUERY_TYPE,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
SelectorValidation,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
import {
|
||||
buildSelector,
|
||||
getDefaultSelector,
|
||||
getQueryTypeOption,
|
||||
isAnyResourceValue,
|
||||
parseSelector,
|
||||
validateSelector,
|
||||
} from './TelemetrySelectorWizard.utils';
|
||||
|
||||
interface UseTelemetrySelectorWizardParams {
|
||||
onAdd: (selector: string) => void;
|
||||
}
|
||||
|
||||
interface UseTelemetrySelectorWizardResult {
|
||||
open: boolean;
|
||||
queryType: QueryTypeId;
|
||||
selectedQueryType: QueryTypeOption | undefined;
|
||||
value: string;
|
||||
selector: string;
|
||||
isAnyResource: boolean;
|
||||
supportsKeyScoping: boolean;
|
||||
validation: SelectorValidation;
|
||||
canAdd: boolean;
|
||||
handleOpenChange: (nextOpen: boolean) => void;
|
||||
handleQueryTypeChange: (value: string | string[]) => void;
|
||||
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleAnyResourceChange: (checked: boolean) => void;
|
||||
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleAdd: () => void;
|
||||
handleInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function useTelemetrySelectorWizard({
|
||||
onAdd,
|
||||
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
|
||||
const [value, setValue] = useState('');
|
||||
const [selector, setSelector] = useState(() =>
|
||||
getDefaultSelector(DEFAULT_QUERY_TYPE),
|
||||
);
|
||||
|
||||
const selectedQueryType = useMemo(
|
||||
() => getQueryTypeOption(queryType),
|
||||
[queryType],
|
||||
);
|
||||
const supportsKeyScoping = selectedQueryType?.supportsKeyScoping ?? false;
|
||||
|
||||
const validation = useMemo(() => validateSelector(selector), [selector]);
|
||||
|
||||
const applyDraft = useCallback(
|
||||
(nextQueryType: QueryTypeId, nextValue: string): void => {
|
||||
setQueryType(nextQueryType);
|
||||
setValue(nextValue);
|
||||
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleQueryTypeChange = useCallback(
|
||||
(next: string | string[]): void => {
|
||||
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
|
||||
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
|
||||
|
||||
applyDraft(selected, keepsValue ? value : '');
|
||||
},
|
||||
[applyDraft, value],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
applyDraft(queryType, event.target.value);
|
||||
},
|
||||
[applyDraft, queryType],
|
||||
);
|
||||
|
||||
const handleAnyResourceChange = useCallback(
|
||||
(checked: boolean): void => {
|
||||
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
|
||||
},
|
||||
[applyDraft, queryType],
|
||||
);
|
||||
|
||||
const handleSelectorChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const nextSelector = event.target.value;
|
||||
setSelector(nextSelector);
|
||||
|
||||
const parsed = parseSelector(nextSelector);
|
||||
if (parsed.queryType) {
|
||||
setQueryType(parsed.queryType);
|
||||
}
|
||||
setValue(parsed.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean): void => {
|
||||
setOpen(nextOpen);
|
||||
|
||||
if (!nextOpen) {
|
||||
setQueryType(DEFAULT_QUERY_TYPE);
|
||||
setValue('');
|
||||
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAdd = useCallback((): void => {
|
||||
const trimmed = selector.trim();
|
||||
|
||||
if (validateSelector(trimmed).isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd(trimmed);
|
||||
handleOpenChange(false);
|
||||
}, [selector, onAdd, handleOpenChange]);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
handleAdd();
|
||||
}
|
||||
},
|
||||
[handleAdd],
|
||||
);
|
||||
|
||||
return {
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource: isAnyResourceValue(value),
|
||||
supportsKeyScoping,
|
||||
validation,
|
||||
canAdd: !validation.isError,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
handleAdd,
|
||||
handleInputKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
export default useTelemetrySelectorWizard;
|
||||
@@ -329,11 +329,15 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -414,11 +418,15 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Bot, Key, Shield } from '@signozhq/icons';
|
||||
import {
|
||||
Bot,
|
||||
ChartLine,
|
||||
DraftingCompass,
|
||||
Gauge,
|
||||
Key,
|
||||
Logs,
|
||||
Shield,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
@@ -14,12 +22,15 @@ type IconComponent = typeof Shield;
|
||||
|
||||
const OBJECT_SCOPED_VERB_SET = new Set<string>(OBJECT_SCOPED_VERBS);
|
||||
|
||||
export type SelectorType = 'input' | 'telemetryBuilder';
|
||||
|
||||
export interface ResourcePanelConfig {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: IconComponent;
|
||||
selectorPlaceholder: string;
|
||||
docsAnchor: string;
|
||||
selectorType?: SelectorType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +61,42 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type service account ID, separate multiple with comma or space',
|
||||
docsAnchor: 'service-account',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
icon: Logs,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'logs',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
traces: {
|
||||
label: 'Traces',
|
||||
description: 'Distributed tracing data collected across the workspace.',
|
||||
icon: DraftingCompass,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'traces',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
metrics: {
|
||||
label: 'Metrics',
|
||||
description: 'Metric data collected across the workspace.',
|
||||
icon: ChartLine,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
'meter-metrics': {
|
||||
label: 'Meter Metrics',
|
||||
description: 'Usage metering data for the workspace.',
|
||||
icon: Gauge,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'meter-metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
};
|
||||
|
||||
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
|
||||
|
||||
@@ -108,6 +108,7 @@ export function getAppContextMockState(
|
||||
isFetchingHosts: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
userFetchError: undefined,
|
||||
activeLicenseFetchError: null,
|
||||
hostsFetchError: undefined,
|
||||
|
||||
@@ -144,6 +144,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
userPreferences,
|
||||
isFetchingUserPreferences,
|
||||
changelog,
|
||||
toggleChangelogModal,
|
||||
updateUserPreferenceInContext,
|
||||
@@ -261,6 +262,11 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
|
||||
// Compute initial pinned items and secondary menu items synchronously to avoid flash
|
||||
const computedPinnedMenuItems = useMemo(() => {
|
||||
// While loading, return empty to avoid flash
|
||||
if (isFetchingUserPreferences) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const navShortcutsPreference = userPreferences?.find(
|
||||
(preference) => preference.name === USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
);
|
||||
@@ -268,11 +274,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
| string[]
|
||||
| undefined;
|
||||
|
||||
// If userPreferences not loaded yet, return empty to avoid showing defaults before preferences load
|
||||
if (userPreferences === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If preference exists with non-empty array, use stored shortcuts
|
||||
if (isArray(navShortcuts) && navShortcuts.length > 0) {
|
||||
return navShortcuts
|
||||
@@ -282,9 +283,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
.filter((item): item is SidebarItem => item !== undefined);
|
||||
}
|
||||
|
||||
// No preference, or empty array → use defaults
|
||||
// No preference, or empty array, or error loading → use defaults
|
||||
return defaultMoreMenuItems.filter((item) => item.isPinned);
|
||||
}, [userPreferences]);
|
||||
}, [isFetchingUserPreferences, userPreferences]);
|
||||
|
||||
const computedSecondaryMenuItems = useMemo(() => {
|
||||
const shouldShowIntegrationsValue =
|
||||
@@ -322,12 +323,16 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
// Sync state only on initial load when userPreferences first becomes available
|
||||
useEffect(() => {
|
||||
// Only sync once: when userPreferences loads for the first time
|
||||
if (!hasInitializedRef.current && userPreferences !== null) {
|
||||
if (!hasInitializedRef.current && isFetchingUserPreferences === false) {
|
||||
setPinnedMenuItems(computedPinnedMenuItems);
|
||||
setSecondaryMenuItems(computedSecondaryMenuItems);
|
||||
hasInitializedRef.current = true;
|
||||
}
|
||||
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
|
||||
}, [
|
||||
computedPinnedMenuItems,
|
||||
computedSecondaryMenuItems,
|
||||
isFetchingUserPreferences,
|
||||
]);
|
||||
|
||||
const isChatSupportEnabled = featureFlags?.find(
|
||||
(flag) => flag.name === FeatureKeys.CHAT_SUPPORT,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_COPIED_RESET_MS = 2000;
|
||||
|
||||
export interface UseCopyToClipboardOptions {
|
||||
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
|
||||
copiedResetMs?: number;
|
||||
}
|
||||
|
||||
export type ID = number | string | null;
|
||||
|
||||
export interface UseCopyToClipboardReturn {
|
||||
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
|
||||
copyToClipboard: (text: string, id?: ID) => void;
|
||||
/** True when something was just copied and still within the reset threshold. */
|
||||
isCopied: boolean;
|
||||
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
|
||||
id: ID;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(
|
||||
options: UseCopyToClipboardOptions = {},
|
||||
): UseCopyToClipboardReturn {
|
||||
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
|
||||
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
|
||||
isCopied: false,
|
||||
id: null,
|
||||
});
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, id?: ID): void => {
|
||||
// oxlint-disable-next-line signoz/no-navigator-clipboard
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setState({ isCopied: true, id: id ?? null });
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setState({ isCopied: false, id: null });
|
||||
timeoutRef.current = null;
|
||||
}, copiedResetMs);
|
||||
});
|
||||
},
|
||||
[copiedResetMs],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied: state.isCopied,
|
||||
id: state.id,
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ describe('PermissionDeniedCallout', () => {
|
||||
it('renders multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('PermissionDeniedFullPage', () => {
|
||||
it('renders with multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();
|
||||
|
||||
@@ -35,6 +35,26 @@ export default {
|
||||
'update',
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'logs',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
{
|
||||
kind: 'meter-metrics',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
{
|
||||
kind: 'metrics',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
{
|
||||
kind: 'traces',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
],
|
||||
relations: {
|
||||
assignee: ['role'],
|
||||
@@ -43,7 +63,7 @@ export default {
|
||||
delete: ['metaresource', 'role', 'serviceaccount'],
|
||||
detach: ['metaresource', 'role', 'serviceaccount'],
|
||||
list: ['metaresource', 'role', 'serviceaccount'],
|
||||
read: ['metaresource', 'role', 'serviceaccount'],
|
||||
read: ['metaresource', 'role', 'serviceaccount', 'telemetryresource'],
|
||||
update: ['metaresource', 'role', 'serviceaccount'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,6 +23,40 @@ export function buildPermission<R extends AuthZRelation>(
|
||||
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an object string for use with `buildPermission`.
|
||||
*
|
||||
* ## Type Inference Behavior
|
||||
*
|
||||
* TypeScript infers `R` from `resource`. If a resource belongs to multiple relations,
|
||||
* the return type becomes a union of all matching `AuthZObject` types.
|
||||
*
|
||||
* Example: 'role' is valid for 'read', 'update', 'delete', 'assignee'.
|
||||
* Without explicit generic, return type = `AuthZObject<'read' | 'update' | 'delete' | 'assignee'>`.
|
||||
*
|
||||
* ## When to specify explicit generic
|
||||
*
|
||||
* **Needed** when resource belongs to multiple relations AND you pass result to `buildPermission`
|
||||
* with a relation that has FEWER valid resources than others:
|
||||
*
|
||||
* ```ts
|
||||
* // ERROR: 'read' includes telemetry resources, 'update' does not
|
||||
* buildPermission('update', buildObjectString('role', 'admin'))
|
||||
*
|
||||
* // OK: explicit generic narrows return type
|
||||
* buildPermission('update', buildObjectString<'update'>('role', 'admin'))
|
||||
* ```
|
||||
*
|
||||
* **Not needed** when:
|
||||
* - Resource only belongs to one relation in the constraint
|
||||
* - Using with 'read' relation (widest type, accepts all)
|
||||
* - Storing in a variable typed as `BrandedPermission` (already opaque)
|
||||
*
|
||||
* ```ts
|
||||
* // OK: 'read' is widest, accepts union return type
|
||||
* buildPermission('read', buildObjectString('role', 'admin'))
|
||||
* ```
|
||||
*/
|
||||
export function buildObjectString<
|
||||
R extends 'delete' | 'read' | 'update' | 'assignee',
|
||||
>(resource: ResourcesForRelation<R>, objectId: string): AuthZObject<R> {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
|
||||
@@ -33,7 +31,6 @@ export default function Legend({
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
@@ -57,17 +54,8 @@ export default function Legend({
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const handleCopyLegendItem = useCallback(
|
||||
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(label, seriesIndex);
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
const isCopied = copiedId === item.seriesIndex;
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
@@ -91,36 +79,18 @@ export default function Legend({
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<TooltipSimple
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
arrow
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="legend-copy-button"
|
||||
onClick={(e): void =>
|
||||
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
|
||||
}
|
||||
aria-label={`Copy ${item.label}`}
|
||||
// data-testid (not testId): TooltipSimple's trigger injects
|
||||
// data-testid:undefined via Radix Slot, and Button spreads
|
||||
// incoming props after its own testId — so set it as a prop
|
||||
// that wins the Slot merge and survives the spread.
|
||||
data-testid="legend-copy"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
@@ -15,9 +9,6 @@ import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
@@ -49,15 +40,6 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
>;
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
beforeAll(() => {
|
||||
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
@@ -89,11 +71,6 @@ describe('UPlotLegend', () => {
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
mockWriteText.mockClear();
|
||||
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
@@ -110,7 +87,6 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -237,47 +213,4 @@ describe('UPlotLegend', () => {
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy action', () => {
|
||||
it('copies the legend label to clipboard when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('A');
|
||||
});
|
||||
|
||||
it('copies the correct label when copy is clicked on a different legend item', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const thirdLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="2"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('does not call onLegendClick when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(onLegendClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { CSSProperties, type MouseEvent, useCallback } from 'react';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
|
||||
import styles from './CopyButton.module.scss';
|
||||
import { useCopyButton } from './useCopyButton';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text written to the clipboard on click. */
|
||||
@@ -30,11 +30,15 @@ function CopyButton({
|
||||
className,
|
||||
testId,
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard();
|
||||
const { copyToClipboard, isCopied } = useCopyButton();
|
||||
|
||||
const handleClick = useCallback((): void => {
|
||||
copyToClipboard(value);
|
||||
}, [copyToClipboard, value]);
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(value);
|
||||
},
|
||||
[copyToClipboard, value],
|
||||
);
|
||||
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CopyButton from '../CopyButton';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// Exercise the real useCopyButton — stub only react-use's underlying copy so the
|
||||
// click doesn't hit copy-to-clipboard's jsdom fallback (window.prompt).
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
describe('CopyButton', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
mockCopy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('copies its value on click', async () => {
|
||||
render(<CopyButton value="hello" testId="copy" />);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('does not trigger parent click handlers (stops propagation)', async () => {
|
||||
const onParentClick = jest.fn();
|
||||
render(
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div onClick={onParentClick}>
|
||||
<CopyButton value="x" testId="copy" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the copied state after clicking and reverts after the reset window', async () => {
|
||||
const { container } = render(<CopyButton value="hello" testId="copy" />);
|
||||
const iconStack = container.querySelector('[data-copied]') as HTMLElement;
|
||||
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'true');
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** How long (ms) CopyButton stays in the "copied" state before reverting. */
|
||||
export const COPIED_RESET_MS = 1000;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { COPIED_RESET_MS } from './constants';
|
||||
|
||||
export interface UseCopyButtonReturn {
|
||||
copyToClipboard: (text: string) => void;
|
||||
isCopied: boolean;
|
||||
}
|
||||
|
||||
export function useCopyButton(): UseCopyButtonReturn {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string): void => {
|
||||
copy(text);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsCopied(true);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, COPIED_RESET_MS);
|
||||
},
|
||||
[copy],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied,
|
||||
};
|
||||
}
|
||||
@@ -434,6 +434,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
isFetchingHosts,
|
||||
isFetchingFeatureFlags,
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUserPreferences,
|
||||
userFetchError,
|
||||
activeLicenseFetchError,
|
||||
hostsFetchError,
|
||||
@@ -464,6 +465,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
isFetchingHosts,
|
||||
isFetchingFeatureFlags,
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUserPreferences,
|
||||
isFetchingUser,
|
||||
isLoggedIn,
|
||||
hostsData,
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface IAppContext {
|
||||
isFetchingHosts: boolean;
|
||||
isFetchingFeatureFlags: boolean;
|
||||
isFetchingOrgPreferences: boolean;
|
||||
isFetchingUserPreferences: boolean;
|
||||
userFetchError: unknown;
|
||||
activeLicenseFetchError: APIError | null;
|
||||
hostsFetchError: unknown;
|
||||
|
||||
@@ -242,6 +242,7 @@ export function getAppContextMock(
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: jest.fn(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
|
||||
@@ -23,6 +23,11 @@ export type ComponentTypes =
|
||||
| 'add_panel_locked_dashboard'
|
||||
| 'manage_llm_pricing';
|
||||
|
||||
/**
|
||||
* @deprecated Before adding a new value here, check if what you want to add permission is supported by authz.
|
||||
* If so, read AuthZ Guidelines on how to add permission check via AuthZ.
|
||||
* If not, you can keep adding to this record.
|
||||
*/
|
||||
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
current_org_settings: ['ADMIN'],
|
||||
invite_members: ['ADMIN'],
|
||||
@@ -46,11 +51,16 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
manage_llm_pricing: ['ADMIN'],
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated You can still add new permissions/routes here but be aware if this page/module supports authz.
|
||||
* If so, also implement the correct authz checks in the page itself, and here you can add ADMIN/EDITOR/VIEWER,
|
||||
* and also update/include the route at {@link routeWithInitialAuthZSupport}
|
||||
*/
|
||||
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERTS_NEW: ['ADMIN', 'EDITOR'],
|
||||
ORG_SETTINGS: ['ADMIN'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -75,13 +85,15 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
|
||||
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE_DETAIL_OLD: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
// Every role must be able to land here - a role missing from this list is
|
||||
// redirected to /un-authorized and then redirected off it again, looping.
|
||||
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS', 'AUTHOR'],
|
||||
USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -95,12 +107,12 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
GET_STARTED_WITH_CLOUD: ['ADMIN', 'EDITOR'],
|
||||
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -141,3 +153,34 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Any route that will start be supported under AuthZ should be added here.
|
||||
* This will help correctly identify when fallback to show the page
|
||||
* or just return unauthorized.
|
||||
*
|
||||
* This prevents us from adding `ANONYMOUS` on the `routePermission`
|
||||
*/
|
||||
export const routeWithInitialAuthZSupport = {
|
||||
MY_SETTINGS: true,
|
||||
SETTINGS: true,
|
||||
TRACES_EXPLORER: true,
|
||||
TRACE: true,
|
||||
TRACE_DETAIL: true,
|
||||
TRACE_DETAIL_OLD: true,
|
||||
LOGS: true,
|
||||
LOGS_EXPLORER: true,
|
||||
LIVE_LOGS: true,
|
||||
ROLES_SETTINGS: true,
|
||||
ROLE_CREATE: true,
|
||||
ROLE_DETAILS: true,
|
||||
ROLE_EDIT: true,
|
||||
SERVICE_ACCOUNTS_SETTINGS: true,
|
||||
SUPPORT: true,
|
||||
OLD_LOGS_EXPLORER: true,
|
||||
METRICS_EXPLORER: true,
|
||||
METRICS_EXPLORER_EXPLORER: true,
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: true,
|
||||
METER_EXPLORER: true,
|
||||
METER: true,
|
||||
} as const satisfies Partial<Record<keyof typeof ROUTES, true>>;
|
||||
|
||||
@@ -143,6 +143,12 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
plugins,
|
||||
resolve: {
|
||||
alias: {
|
||||
// @grafana/data imports bare CJS `lodash`, whose UMD footer checks for an
|
||||
// AMD loader before assigning module.exports. Any third-party script that
|
||||
// defines window.define.amd first leaves the bundled namespace empty, so
|
||||
// every lodash method reached through it is undefined at runtime. lodash-es
|
||||
// is the same version, real ESM, and tree-shakes.
|
||||
lodash: 'lodash-es',
|
||||
'@': resolve(__dirname, './src'),
|
||||
utils: resolve(__dirname, './src/utils'),
|
||||
types: resolve(__dirname, './src/types'),
|
||||
|
||||
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
|
||||
4
go.sum
@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2 h1:KCxdmvuVawVF4yl0ZS33q7olgmLZwvZG5BjWHp6o414=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3 h1:6iap8XGjuSjD3w7r1UNrg66ljBugcv2P39s4eo/ZLRw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
|
||||
298
pkg/alertmanager/alertmanagernotify/googlechat/googlechat.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
func New(conf *alertmanagertypes.GoogleChatReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
|
||||
if conf.HTTPConfig == nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat http_config is nil")
|
||||
}
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
|
||||
if n.conf.WebhookURL == nil {
|
||||
return false, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url is empty")
|
||||
}
|
||||
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n.logger.DebugContext(ctx, "sending google chat notification", slog.Any("group_key", key))
|
||||
|
||||
c, err := n.prepareContent(ctx, alerts)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Empty title and every body empty means a misconfigured template, so fail
|
||||
// loudly and non-retryably instead of sending a card with no content.
|
||||
if c.title == "" && !isAnyNonEmpty(c.bodies) {
|
||||
return false, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat message rendered empty; check the channel title/text templates")
|
||||
}
|
||||
|
||||
status := statusLine(alerts)
|
||||
|
||||
// Cap per-alert sections well under Google Chat's 100-widget limit and keep
|
||||
// the card readable; the overflow count drives a "+N more" note. Capping
|
||||
// before the size guard keeps the trim loop working only on rendered bodies.
|
||||
capAlerts, capBodies, remaining := capAlertSections(alerts, c.bodies)
|
||||
|
||||
// Serialized-size guard: keep the payload within Google Chat's byte limit by
|
||||
// trimming the longest body first, then the title (which appears in both the
|
||||
// summary and the card header). Banners/buttons are small and bounded, so
|
||||
// trimming the text fields always brings the payload under the limit.
|
||||
title := c.title
|
||||
bodies := append([]string(nil), capBodies...)
|
||||
buf, err := encodeMessage(buildMessage(title, status, capAlerts, bodies, remaining))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for buf.Len() > maxMessageBytes {
|
||||
over := buf.Len() - maxMessageBytes
|
||||
if i := longestBodyIndex(bodies); i >= 0 {
|
||||
bodies[i] = truncateToByteLimit(bodies[i], max(len(bodies[i])-over, 0))
|
||||
} else if title != "" {
|
||||
title = truncateToByteLimit(title, max(len(title)-over, 0))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
if buf, err = encodeMessage(buildMessage(title, status, capAlerts, bodies, remaining)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Thread same-rule alerts together: threadKey is a stable hash of the
|
||||
// alert group key. Changing a rule's grouping starts a new thread.
|
||||
u, err := url.Parse(n.conf.WebhookURL.String())
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse google chat webhook url")
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("threadKey", key.Hash())
|
||||
q.Set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, u.String(), buf) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return shouldRetry, err
|
||||
}
|
||||
|
||||
// prepareContent expands the title and body templates. Custom templates (from
|
||||
// alert annotations) override the channel defaults. The title is used as a plain
|
||||
// summary + card header; the body is converted to HTML for the card text widget.
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (content, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Title,
|
||||
DefaultBodyTemplate: n.conf.Text,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return content{}, err
|
||||
}
|
||||
|
||||
// Each body goes into a per-alert card textParagraph, which renders HTML.
|
||||
// Default and custom templates are both standard markdown, so convert each
|
||||
// one. Bold/links/lists/code render well; tables flatten (rare in alerts).
|
||||
bodies := make([]string, len(result.Body))
|
||||
for i, body := range result.Body {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
html, err := markdownrenderer.RenderHTML(body)
|
||||
if err != nil {
|
||||
return content{}, err
|
||||
}
|
||||
bodies[i] = html
|
||||
}
|
||||
|
||||
return content{title: result.Title, bodies: bodies}, nil
|
||||
}
|
||||
|
||||
// statusLine returns a colored firing/resolved banner for the card body.
|
||||
func statusLine(alerts []*types.Alert) string {
|
||||
if types.Alerts(alerts...).Status() == model.AlertResolved {
|
||||
return `<font color="#33a853"><b>🟢 RESOLVED</b></font>`
|
||||
}
|
||||
return `<font color="#d32f2f"><b>🔴 FIRING</b></font>`
|
||||
}
|
||||
|
||||
// relatedButtons builds the per-alert "View Related Logs/Traces" buttons from
|
||||
// the annotations the ruler attaches to each alert. Empty links are skipped.
|
||||
func relatedButtons(alert *types.Alert) []button {
|
||||
var buttons []button
|
||||
add := func(text, u string) {
|
||||
if u != "" {
|
||||
buttons = append(buttons, button{Text: text, OnClick: onClick{OpenLink: openLink{URL: u}}})
|
||||
}
|
||||
}
|
||||
add("View Related Logs", string(alert.Annotations[ruletypes.AnnotationRelatedLogs]))
|
||||
add("View Related Traces", string(alert.Annotations[ruletypes.AnnotationRelatedTraces]))
|
||||
return buttons
|
||||
}
|
||||
|
||||
// sigNozButton builds the shared "Open in SigNoz" button from the ruleSource
|
||||
// label, which is per-rule (identical for every alert in the group).
|
||||
func sigNozButton(alert *types.Alert) *button {
|
||||
if u := string(alert.Labels[ruletypes.LabelRuleSource]); u != "" {
|
||||
return &button{Text: "Open in SigNoz", OnClick: onClick{OpenLink: openLink{URL: u}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// capAlertSections keeps the first maxAlertSections non-empty bodies (with their
|
||||
// aligned alerts) and returns the count of non-empty bodies dropped beyond the
|
||||
// cap. Capping the render set up front keeps the card within Google Chat's
|
||||
// widget limit and bounds the size-guard trim loop.
|
||||
func capAlertSections(alerts []*types.Alert, bodies []string) ([]*types.Alert, []string, int) {
|
||||
capAlerts := make([]*types.Alert, 0, maxAlertSections)
|
||||
capBodies := make([]string, 0, maxAlertSections)
|
||||
remaining := 0
|
||||
for i, body := range bodies {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if len(capBodies) >= maxAlertSections {
|
||||
remaining++
|
||||
continue
|
||||
}
|
||||
var alert *types.Alert
|
||||
if i < len(alerts) {
|
||||
alert = alerts[i]
|
||||
}
|
||||
capAlerts = append(capAlerts, alert)
|
||||
capBodies = append(capBodies, body)
|
||||
}
|
||||
return capAlerts, capBodies, remaining
|
||||
}
|
||||
|
||||
// buildMessage assembles the text+card payload: a plain text summary plus a card
|
||||
// with a status banner, one section per alert (its body + related-link buttons),
|
||||
// an optional "+N more" note, and a shared "Open in SigNoz" footer button. The
|
||||
// alerts and bodies slices are the already-capped, aligned render set.
|
||||
func buildMessage(title, statusHTML string, alerts []*types.Alert, bodies []string, remaining int) Message {
|
||||
sections := []cardSection{{Widgets: []widget{{TextParagraph: &textParagraph{Text: statusHTML}}}}}
|
||||
|
||||
for i, body := range bodies {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
widgets := []widget{{TextParagraph: &textParagraph{Text: body}}}
|
||||
if i < len(alerts) && alerts[i] != nil {
|
||||
if btns := relatedButtons(alerts[i]); len(btns) > 0 {
|
||||
widgets = append(widgets, widget{ButtonList: &buttonList{Buttons: btns}})
|
||||
}
|
||||
}
|
||||
sections = append(sections, cardSection{Widgets: widgets})
|
||||
}
|
||||
|
||||
if remaining > 0 {
|
||||
note := fmt.Sprintf("<i>…and %d more alerts. Open in SigNoz for the full list.</i>", remaining)
|
||||
sections = append(sections, cardSection{Widgets: []widget{{TextParagraph: &textParagraph{Text: note}}}})
|
||||
}
|
||||
|
||||
if len(alerts) > 0 && alerts[0] != nil {
|
||||
if btn := sigNozButton(alerts[0]); btn != nil {
|
||||
sections = append(sections, cardSection{Widgets: []widget{{ButtonList: &buttonList{Buttons: []button{*btn}}}}})
|
||||
}
|
||||
}
|
||||
|
||||
return Message{
|
||||
Text: title,
|
||||
CardsV2: []cardWithID{{
|
||||
CardID: "signoz-alert",
|
||||
Card: card{Header: &cardHeader{Title: title}, Sections: sections},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// isAnyNonEmpty reports whether any string in ss is non-empty.
|
||||
func isAnyNonEmpty(ss []string) bool {
|
||||
for _, s := range ss {
|
||||
if s != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// longestBodyIndex returns the index of the longest non-empty body, or -1 when
|
||||
// all bodies are empty.
|
||||
func longestBodyIndex(bodies []string) int {
|
||||
idx, best := -1, 0
|
||||
for i, b := range bodies {
|
||||
if len(b) > best {
|
||||
idx, best = i, len(b)
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func encodeMessage(msg Message) (*bytes.Buffer, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
// truncateToByteLimit trims s to at most maxBytes bytes on a rune boundary,
|
||||
// appending an ellipsis when it truncates.
|
||||
func truncateToByteLimit(s string, maxBytes int) string {
|
||||
if len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
const ellipsis = "..."
|
||||
target := maxBytes - len(ellipsis)
|
||||
if target <= 0 {
|
||||
return ellipsis[:maxBytes]
|
||||
}
|
||||
truncated := s
|
||||
for len(truncated) > target {
|
||||
_, size := utf8.DecodeLastRuneInString(truncated)
|
||||
if size == 0 {
|
||||
break
|
||||
}
|
||||
truncated = truncated[:len(truncated)-size]
|
||||
}
|
||||
return truncated + ellipsis
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func secretURLFromString(t *testing.T, rawURL string) *config.SecretURL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
return &config.SecretURL{URL: parsed}
|
||||
}
|
||||
|
||||
func newTestNotifier(t *testing.T, webhookURL, title, text string) *Notifier {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
WebhookURL: secretURLFromString(t, webhookURL),
|
||||
Title: title,
|
||||
Text: text,
|
||||
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
func newTestAlerts(alertname string) []*types.Alert {
|
||||
return []*types.Alert{{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": model.LabelValue(alertname)},
|
||||
Annotations: model.LabelSet{"summary": model.LabelValue("summary for " + alertname)},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func newTestContext() context.Context {
|
||||
return notify.WithGroupKey(context.Background(), "test-receiver")
|
||||
}
|
||||
|
||||
// captureServer starts an httptest server that decodes the posted Google Chat
|
||||
// Message into got and replies 200.
|
||||
func captureServer(t *testing.T, got *Message) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(got)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestGoogleChatSend(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
n := newTestNotifier(t, server.URL, `[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}`, "")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("TestAlert")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, retry)
|
||||
require.Contains(t, got.Text, "FIRING")
|
||||
require.Contains(t, got.Text, "TestAlert")
|
||||
}
|
||||
|
||||
func TestGoogleChatTitleAndBody(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
n := newTestNotifier(t, server.URL, "TITLE", "BODY")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("TestAlert")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, retry)
|
||||
// Title is the plain summary + card header; body lives in a card widget.
|
||||
require.Equal(t, "TITLE", got.Text)
|
||||
require.Equal(t, "TITLE", got.CardsV2[0].Card.Header.Title)
|
||||
require.Contains(t, cardBody(t, got), "BODY")
|
||||
}
|
||||
|
||||
// cardBody concatenates the text of all textParagraph widgets in the message.
|
||||
func cardBody(t *testing.T, m Message) string {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, m.CardsV2)
|
||||
var b strings.Builder
|
||||
for _, s := range m.CardsV2[0].Card.Sections {
|
||||
for _, w := range s.Widgets {
|
||||
if w.TextParagraph != nil {
|
||||
b.WriteString(w.TextParagraph.Text)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cardButtons returns all buttons across every buttonList widget in the card.
|
||||
func cardButtons(t *testing.T, m Message) []button {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, m.CardsV2)
|
||||
var buttons []button
|
||||
for _, s := range m.CardsV2[0].Card.Sections {
|
||||
for _, w := range s.Widgets {
|
||||
if w.ButtonList != nil {
|
||||
buttons = append(buttons, w.ButtonList.Buttons...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
// cardSectionCount returns the number of sections in the card.
|
||||
func cardSectionCount(t *testing.T, m Message) int {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, m.CardsV2)
|
||||
return len(m.CardsV2[0].Card.Sections)
|
||||
}
|
||||
|
||||
func TestGoogleChatNilHTTPConfig(t *testing.T) {
|
||||
// A nil HTTPConfig must fail cleanly, not panic on the *conf.HTTPConfig deref.
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
|
||||
HTTPConfig: nil,
|
||||
WebhookURL: secretURLFromString(t, "https://chat.googleapis.com/v1/spaces/test/messages"),
|
||||
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
|
||||
require.Error(t, err)
|
||||
require.Nil(t, n)
|
||||
}
|
||||
|
||||
func TestGoogleChatRetryCodes(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
WebhookURL: secretURLFromString(t, "https://chat.googleapis.com/v1/spaces/test/messages"),
|
||||
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
code int
|
||||
retry bool
|
||||
}{
|
||||
{http.StatusOK, false},
|
||||
{http.StatusBadRequest, false}, // 400: malformed payload, permanent
|
||||
{http.StatusTooManyRequests, true}, // 429: rate limited, retry (our RetryCodes)
|
||||
{http.StatusInternalServerError, true}, // 5xx: retry
|
||||
{http.StatusServiceUnavailable, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(http.StatusText(c.code), func(t *testing.T) {
|
||||
actual, _ := n.retrier.Check(c.code, nil)
|
||||
require.Equal(t, c.retry, actual, "retry mismatch on status %d", c.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleChatRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
ctx = notify.WithGroupKey(ctx, "test-receiver")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
WebhookURL: &config.SecretURL{URL: u},
|
||||
Title: "alert", // non-empty so it reaches the POST (not the empty-text guard)
|
||||
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, n, u.String())
|
||||
}
|
||||
|
||||
func TestTruncateToByteLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
max int
|
||||
wantLen int // upper bound on byte length of result
|
||||
wantHas string // substring the result must contain (or "")
|
||||
}{
|
||||
{"under limit passthrough", "hello", 100, 5, "hello"},
|
||||
{"over limit trims with ellipsis", strings.Repeat("a", 50), 10, 10, "..."},
|
||||
{"exact limit passthrough", "hello", 5, 5, "hello"},
|
||||
{"tiny limit", "hello", 2, 2, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := truncateToByteLimit(c.in, c.max)
|
||||
require.LessOrEqual(t, len(got), c.wantLen)
|
||||
if c.wantHas != "" {
|
||||
require.Contains(t, got, c.wantHas)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleChatMessageSizeLimit(t *testing.T) {
|
||||
var bodyLen int
|
||||
var valid bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
bodyLen = len(raw)
|
||||
valid = json.Valid(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Huge plain-ASCII title so one-time truncation lands deterministically under the limit.
|
||||
n := newTestNotifier(t, server.URL, strings.Repeat("A", 40000), "")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("Big")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, retry)
|
||||
require.True(t, valid, "posted body must be valid JSON")
|
||||
require.LessOrEqual(t, bodyLen, maxMessageBytes, "posted body must be within the size limit")
|
||||
}
|
||||
|
||||
func TestGoogleChatThreading(t *testing.T) {
|
||||
var query url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query = r.URL.Query()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cases := []struct{ name, groupKey string }{
|
||||
{"rule a", "{ruleId=\"aaa\"}"},
|
||||
{"rule b", "{ruleId=\"bbb\"}"},
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
n := newTestNotifier(t, server.URL, "T", "")
|
||||
ctx := notify.WithGroupKey(context.Background(), c.groupKey)
|
||||
_, err := n.Notify(ctx, newTestAlerts("X")...)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", query.Get("messageReplyOption"))
|
||||
threadKey := query.Get("threadKey")
|
||||
require.Equal(t, notify.Key(c.groupKey).Hash(), threadKey, "threadKey must be the group key hash")
|
||||
seen[c.name] = threadKey
|
||||
})
|
||||
}
|
||||
require.NotEqual(t, seen["rule a"], seen["rule b"], "distinct group keys must yield distinct threadKeys")
|
||||
}
|
||||
|
||||
func TestGoogleChatCustomTemplateMarkdown(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
// Custom body template (standard markdown) supplied via annotation → the
|
||||
// !IsDefaultBody path must convert it to Google Chat dialect.
|
||||
alerts := []*types.Alert{{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "X"},
|
||||
Annotations: model.LabelSet{ruletypes.AnnotationBodyTemplate: "**bold** and [link](https://x)"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
},
|
||||
}}
|
||||
n := newTestNotifier(t, server.URL, "Alert", "default body")
|
||||
_, err := n.Notify(newTestContext(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Custom body is standard markdown → converted to card HTML.
|
||||
body := cardBody(t, got)
|
||||
require.Contains(t, body, "<strong>bold</strong>", "** should convert to HTML bold")
|
||||
require.Contains(t, body, `<a href="https://x">link</a>`, "[t](u) should convert to an HTML link")
|
||||
}
|
||||
|
||||
func TestGoogleChatSerializedSizeUnderLimit(t *testing.T) {
|
||||
var bodyLen int
|
||||
var valid bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
bodyLen = len(raw)
|
||||
valid = json.Valid(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Escaping-dense payload: <, >, & and newlines each expand under JSON
|
||||
// encoding, so this is the shape that would push the serialized body over
|
||||
// the limit if we measured the text bytes instead of the marshaled buffer.
|
||||
dense := strings.Repeat("<https://example.com/a?x=1&y=2|link>\n", 2000)
|
||||
n := newTestNotifier(t, server.URL, dense, "")
|
||||
_, err := n.Notify(newTestContext(), newTestAlerts("Dense")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, valid, "posted body must be valid JSON")
|
||||
require.LessOrEqual(t, bodyLen, maxMessageBytes, "serialized body must be within the limit")
|
||||
}
|
||||
|
||||
func TestGoogleChatEmptyText(t *testing.T) {
|
||||
server := captureServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
// Empty title and body templates → must not POST; fail non-retryably.
|
||||
n := newTestNotifier(t, server.URL, "", "")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("X")...)
|
||||
|
||||
require.Error(t, err)
|
||||
require.False(t, retry)
|
||||
}
|
||||
|
||||
func TestGoogleChatLinkButtons(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
labels model.LabelSet
|
||||
annotations model.LabelSet
|
||||
wantButtons map[string]string
|
||||
}{
|
||||
{
|
||||
name: "all links present",
|
||||
labels: model.LabelSet{
|
||||
"alertname": "X",
|
||||
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
|
||||
},
|
||||
annotations: model.LabelSet{
|
||||
ruletypes.AnnotationRelatedLogs: "https://signoz.example/logs",
|
||||
ruletypes.AnnotationRelatedTraces: "https://signoz.example/traces",
|
||||
},
|
||||
wantButtons: map[string]string{
|
||||
"Open in SigNoz": "https://signoz.example/alerts/1",
|
||||
"View Related Logs": "https://signoz.example/logs",
|
||||
"View Related Traces": "https://signoz.example/traces",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no links → no buttons",
|
||||
labels: model.LabelSet{"alertname": "X"},
|
||||
annotations: model.LabelSet{"summary": "s"},
|
||||
wantButtons: map[string]string{},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
alerts := []*types.Alert{{Alert: model.Alert{
|
||||
Labels: c.labels,
|
||||
Annotations: c.annotations,
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
}}}
|
||||
// Non-empty body so the alert section (which carries related buttons) exists.
|
||||
n := newTestNotifier(t, server.URL, "T", "an alert")
|
||||
_, err := n.Notify(newTestContext(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
buttons := cardButtons(t, got)
|
||||
require.Len(t, buttons, len(c.wantButtons))
|
||||
for _, b := range buttons {
|
||||
require.Equal(t, c.wantButtons[b.Text], b.OnClick.OpenLink.URL, "button %q", b.Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleChatMultiAlertSections(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
// A per-alert custom body template yields one card section per alert, each
|
||||
// with that alert's own related-link buttons, plus one shared SigNoz button.
|
||||
mkAlert := func(pod, logs string) *types.Alert {
|
||||
return &types.Alert{Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"alertname": "X",
|
||||
"pod": model.LabelValue(pod),
|
||||
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "an alert fired",
|
||||
ruletypes.AnnotationRelatedLogs: model.LabelValue(logs),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
}}
|
||||
}
|
||||
alerts := []*types.Alert{
|
||||
mkAlert("pod-1", "https://signoz.example/logs?pod=pod-1"),
|
||||
mkAlert("pod-2", "https://signoz.example/logs?pod=pod-2"),
|
||||
}
|
||||
n := newTestNotifier(t, server.URL, "T", "default body")
|
||||
_, err := n.Notify(newTestContext(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
// banner + one section per alert + shared SigNoz footer.
|
||||
require.Equal(t, 4, cardSectionCount(t, got))
|
||||
|
||||
sigNoz := 0
|
||||
logsURLs := map[string]bool{}
|
||||
for _, b := range cardButtons(t, got) {
|
||||
switch b.Text {
|
||||
case "Open in SigNoz":
|
||||
sigNoz++
|
||||
case "View Related Logs":
|
||||
logsURLs[b.OnClick.OpenLink.URL] = true
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, sigNoz, "SigNoz button must appear once (shared, per-rule)")
|
||||
require.True(t, logsURLs["https://signoz.example/logs?pod=pod-1"], "pod-1's logs button")
|
||||
require.True(t, logsURLs["https://signoz.example/logs?pod=pod-2"], "pod-2's logs button")
|
||||
}
|
||||
|
||||
func TestGoogleChatDefaultBodyGrouped(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
// Default body template (no per-alert annotation): the templater combines all
|
||||
// grouped alerts into ONE section, carrying only the first alert's buttons.
|
||||
mkAlert := func(pod string) *types.Alert {
|
||||
return &types.Alert{Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"alertname": "X",
|
||||
"pod": model.LabelValue(pod),
|
||||
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationRelatedLogs: model.LabelValue("https://signoz.example/logs?pod=" + pod),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
}}
|
||||
}
|
||||
alerts := []*types.Alert{mkAlert("pod-1"), mkAlert("pod-2")}
|
||||
n := newTestNotifier(t, server.URL, "T", "{{ range .Alerts }}pod {{ .Labels.pod }} {{ end }}")
|
||||
_, err := n.Notify(newTestContext(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
// banner + one combined alert section + shared SigNoz footer = 3 sections.
|
||||
require.Equal(t, 3, cardSectionCount(t, got))
|
||||
|
||||
body := cardBody(t, got)
|
||||
require.Contains(t, body, "pod-1")
|
||||
require.Contains(t, body, "pod-2", "default template combines all alerts into one section")
|
||||
|
||||
logs := 0
|
||||
for _, b := range cardButtons(t, got) {
|
||||
if b.Text == "View Related Logs" {
|
||||
logs++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, logs, "default path surfaces only the first alert's related buttons")
|
||||
}
|
||||
|
||||
func TestGoogleChatSectionCap(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
// 35 grouped alerts (custom body → per-alert bodies) exceed the 30 cap.
|
||||
const total = 35
|
||||
alerts := make([]*types.Alert, 0, total)
|
||||
for range total {
|
||||
alerts = append(alerts, &types.Alert{Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"alertname": "X",
|
||||
ruletypes.LabelRuleSource: "https://signoz.example/alerts/1",
|
||||
},
|
||||
Annotations: model.LabelSet{ruletypes.AnnotationBodyTemplate: "an alert fired"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
}})
|
||||
}
|
||||
n := newTestNotifier(t, server.URL, "T", "default body")
|
||||
_, err := n.Notify(newTestContext(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
// banner + 30 alert sections + "+N more" note + shared SigNoz footer.
|
||||
require.Equal(t, 1+maxAlertSections+1+1, cardSectionCount(t, got))
|
||||
|
||||
body := cardBody(t, got)
|
||||
require.Contains(t, body, "5 more alerts", "overflow note must state the dropped count")
|
||||
|
||||
// The SigNoz footer must survive after the note (last section).
|
||||
sigNoz := 0
|
||||
for _, b := range cardButtons(t, got) {
|
||||
if b.Text == "Open in SigNoz" {
|
||||
sigNoz++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, sigNoz, "SigNoz footer must be present despite the cap")
|
||||
}
|
||||
|
||||
func TestGoogleChatStatusLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resolved bool
|
||||
want string
|
||||
}{
|
||||
{"firing", false, "🔴 FIRING"},
|
||||
{"resolved", true, "🟢 RESOLVED"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
endsAt := time.Now().Add(time.Minute)
|
||||
if c.resolved {
|
||||
endsAt = time.Now().Add(-time.Minute) // EndsAt in the past → resolved
|
||||
}
|
||||
alerts := []*types.Alert{{Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "X"},
|
||||
StartsAt: time.Now().Add(-2 * time.Minute),
|
||||
EndsAt: endsAt,
|
||||
}}}
|
||||
n := newTestNotifier(t, server.URL, "T", "")
|
||||
_, err := n.Notify(newTestContext(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, cardBody(t, got), c.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
95
pkg/alertmanager/alertmanagernotify/googlechat/types.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "googlechat"
|
||||
// maxMessageBytes is the Google Chat message payload limit.
|
||||
// https://developers.google.com/chat/api/guides/message-formats/basic#maximum_size
|
||||
maxMessageBytes = 32000
|
||||
// maxAlertSections caps per-alert sections in a grouped card. Google Chat
|
||||
// allows 100 widgets per card and silently drops the section that crosses
|
||||
// that count and every section after it, so we cap well under the limit
|
||||
// (~63 widgets worst case) and add a "+N more" note for the overflow.
|
||||
// https://developers.google.com/workspace/chat/api/reference/rpc/google.apps.card.v1#card
|
||||
maxAlertSections = 30
|
||||
)
|
||||
|
||||
// Notifier implements notify.Notifier for Google Chat.
|
||||
type Notifier struct {
|
||||
conf *alertmanagertypes.GoogleChatReceiverConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// Message is the Google Chat webhook payload. A message carries a short text
|
||||
// summary (space list / push preview) and a rich card.
|
||||
type Message struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
CardsV2 []cardWithID `json:"cardsV2,omitempty"`
|
||||
}
|
||||
|
||||
type cardWithID struct {
|
||||
CardID string `json:"cardId"`
|
||||
Card card `json:"card"`
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Header *cardHeader `json:"header,omitempty"`
|
||||
Sections []cardSection `json:"sections"`
|
||||
}
|
||||
|
||||
type cardHeader struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
}
|
||||
|
||||
type cardSection struct {
|
||||
Header string `json:"header,omitempty"`
|
||||
Widgets []widget `json:"widgets"`
|
||||
}
|
||||
|
||||
// widget is a one-of: exactly one field is set per instance.
|
||||
type widget struct {
|
||||
TextParagraph *textParagraph `json:"textParagraph,omitempty"`
|
||||
ButtonList *buttonList `json:"buttonList,omitempty"`
|
||||
}
|
||||
|
||||
type textParagraph struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type buttonList struct {
|
||||
Buttons []button `json:"buttons"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
Text string `json:"text"`
|
||||
OnClick onClick `json:"onClick"`
|
||||
}
|
||||
|
||||
type onClick struct {
|
||||
OpenLink openLink `json:"openLink"`
|
||||
}
|
||||
|
||||
type openLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// content holds the rendered title and the HTML bodies for a Google Chat message.
|
||||
// bodies is per-alert (positionally aligned with the alerts slice) for a custom
|
||||
// body template, but a single combined entry for the default template.
|
||||
type content struct {
|
||||
title string
|
||||
bodies []string
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
@@ -24,6 +25,7 @@ var customNotifierIntegrations = []string{
|
||||
opsgenie.Integration,
|
||||
slack.Integration,
|
||||
msteamsv2.Integration,
|
||||
googlechat.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
@@ -74,6 +76,11 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.GoogleChatConfigs {
|
||||
add(googlechat.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return googlechat.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
|
||||
if errs.Len() > 0 {
|
||||
return nil, &errs
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
{
|
||||
"schemaVersion": "v6",
|
||||
"image": "/assets/Logos/gcp-cloud-storage",
|
||||
"name": "",
|
||||
"generateName": true,
|
||||
"tags": [
|
||||
{
|
||||
"key": "tag",
|
||||
"value": "observability"
|
||||
}
|
||||
],
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "GCP Cloud Storage Overview",
|
||||
"description": "Dashboard for GCP Cloud Storage overview"
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "project_id",
|
||||
"description": "GCP Project ID"
|
||||
},
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"customAllValue": "",
|
||||
"capturingRegexp": "",
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {
|
||||
"name": "project_id",
|
||||
"signal": "metrics"
|
||||
}
|
||||
},
|
||||
"name": "project_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "bucket_name",
|
||||
"description": "GCS bucket name"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"customAllValue": "",
|
||||
"capturingRegexp": "",
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {
|
||||
"name": "bucket_name",
|
||||
"signal": "metrics"
|
||||
}
|
||||
},
|
||||
"name": "bucket_name"
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
"1f0a4d5b-6c2e-4a71-8b3d-9e5c07a41d20": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Bucket size",
|
||||
"description": "Total size of all objects in the bucket, grouped by storage class."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TablePanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time"
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A": "By"
|
||||
},
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/storage/v2/total_bytes",
|
||||
"temporality": "",
|
||||
"timeAggregation": "latest",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "last"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "storage_class",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "Size"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"29ed9824-21db-44c3-9537-12be458f5c20": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Total object bytes",
|
||||
"description": "Total size of all objects in the bucket, grouped by storage class and type."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/storage/v2/total_bytes",
|
||||
"temporality": "",
|
||||
"timeAggregation": "latest",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "storage_class",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{bucket_name}}/{{storage_class}}/{{type}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"cbc53174-5527-4bce-b32d-317403849e48": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Total object count",
|
||||
"description": "Total number of objects and multipart-uploads per bucket, grouped by storage class and type, where type can be live-object, noncurrent-object, soft-deleted-object or multipart-upload."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "{count}",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/storage/v2/total_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "latest",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "avg"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "storage_class",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{bucket_name}}/{{storage_class}}/{{type}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"76e70ce5-47e4-4b5b-9715-49fa21c58b0c": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Deleted bytes",
|
||||
"description": "Rate of deleted bytes per bucket, grouped by storage class."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By/s",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/storage/v2/deleted_bytes",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "storage_class",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{bucket_name}}/{{storage_class}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"c5af2813-34ec-41a9-8d53-c9acd67f569f": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Request count",
|
||||
"description": "Rate of API calls, grouped by the API method name and response code."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "{count}/s",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/api/request_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "method",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "response_code",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"70333210-68c3-4387-a6b7-afa2459d27dd": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Authentication count",
|
||||
"description": "Rate of HMAC/RSA signed requests, grouped by authentication method, API method name and response code."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "{count}/s",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/authn/authentication_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "authentication_method",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "method",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "response_code",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{bucket_name}}/{{authentication_method}}/{{method}} - {{response_code}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"7a98084c-2cd3-4146-a831-f5382162ffe5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Received bytes",
|
||||
"description": "Rate of bytes received over the network, grouped by the API method name and response code."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By/s",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/network/received_bytes_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "method",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "response_code",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
"7138bf98-b41a-46d8-90ef-8e7470819cdf": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "Sent bytes",
|
||||
"description": "Rate of bytes sent over the network, grouped by the API method name and response code."
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time",
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By/s",
|
||||
"decimalPrecision": "2"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": {
|
||||
"fillOnlyBelow": false,
|
||||
"fillLessThan": ""
|
||||
}
|
||||
},
|
||||
"axes": {
|
||||
"softMin": 0,
|
||||
"softMax": 0,
|
||||
"isLogScale": false
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": null
|
||||
},
|
||||
"thresholds": null
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"source": "",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "storage.googleapis.com/network/sent_bytes_count",
|
||||
"temporality": "",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
"reduceTo": "sum"
|
||||
}
|
||||
],
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "bucket_name",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "method",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
},
|
||||
{
|
||||
"name": "response_code",
|
||||
"signal": "",
|
||||
"fieldContext": "attribute",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"order": [],
|
||||
"having": {
|
||||
"expression": ""
|
||||
},
|
||||
"functions": [],
|
||||
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/1f0a4d5b-6c2e-4a71-8b3d-9e5c07a41d20"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Storage",
|
||||
"collapse": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/29ed9824-21db-44c3-9537-12be458f5c20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/cbc53174-5527-4bce-b32d-317403849e48"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/76e70ce5-47e4-4b5b-9715-49fa21c58b0c"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "API & Authentication",
|
||||
"collapse": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/c5af2813-34ec-41a9-8d53-c9acd67f569f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/70333210-68c3-4387-a6b7-afa2459d27dd"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Network",
|
||||
"collapse": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/7a98084c-2cd3-4146-a831-f5382162ffe5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/7138bf98-b41a-46d8-90ef-8e7470819cdf"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"duration": "",
|
||||
"refreshInterval": "",
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}.cls-4{fill:#fff;}</style></defs><title>Icon_24px_CloudStorage_Color</title><g data-name="Product Icons"><rect class="cls-1" x="2" y="4" width="20" height="7"/><rect class="cls-2" x="20" y="4" width="2" height="7"/><polygon class="cls-3" points="22 4 20 4 20 11 22 4"/><rect class="cls-2" x="2" y="4" width="2" height="7"/><rect class="cls-4" x="6" y="7" width="6" height="1"/><rect class="cls-4" x="15" y="6" width="3" height="3" rx="1.5"/><rect class="cls-1" x="2" y="13" width="20" height="7"/><rect class="cls-2" x="20" y="13" width="2" height="7"/><polygon class="cls-3" points="22 13 20 13 20 20 22 13"/><rect class="cls-2" x="2" y="13" width="2" height="7"/><rect class="cls-4" x="6" y="16" width="6" height="1"/><rect class="cls-4" x="15" y="15" width="3" height="3" rx="1.5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 958 B |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"id": "cloudstorage",
|
||||
"title": "GCP Cloud Storage",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "storage.googleapis.com/storage/v2/total_bytes",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "storage.googleapis.com/storage/v2/total_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "storage.googleapis.com/storage/v2/deleted_bytes",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "storage.googleapis.com/api/request_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "storage.googleapis.com/network/received_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "storage.googleapis.com/network/sent_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "storage.googleapis.com/authn/authentication_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Cloud Storage Overview",
|
||||
"description": "Overview of GCP Cloud Storage metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Cloud Storage with SigNoz
|
||||
|
||||
Collect key GCP Cloud Storage metrics and view them with an out of the box dashboard.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_ComputeEngine_Color</title><g data-name="Product Icons"><rect class="cls-1" x="9" y="9" width="6" height="6"/><rect class="cls-2" x="11" y="2" width="2" height="4"/><rect class="cls-2" x="7" y="2" width="2" height="4"/><rect class="cls-2" x="15" y="2" width="2" height="4"/><rect class="cls-3" x="11" y="18" width="2" height="4"/><rect class="cls-3" x="7" y="18" width="2" height="4"/><rect class="cls-3" x="15" y="18" width="2" height="4"/><rect class="cls-3" x="19" y="10" width="2" height="4" transform="translate(8 32) rotate(-90)"/><rect class="cls-3" x="19" y="14" width="2" height="4" transform="translate(4 36) rotate(-90)"/><rect class="cls-3" x="19" y="6" width="2" height="4" transform="translate(12 28) rotate(-90)"/><rect class="cls-2" x="3" y="10" width="2" height="4" transform="translate(-8 16) rotate(-90)"/><rect class="cls-2" x="3" y="14" width="2" height="4" transform="translate(-12 20) rotate(-90)"/><rect class="cls-2" x="3" y="6" width="2" height="4" transform="translate(-4 12) rotate(-90)"/><path class="cls-1" d="M5,5V19H19V5ZM17,17H7V7H17Z"/><polygon class="cls-2" points="9 15 15 15 12 12 9 15"/><polygon class="cls-3" points="12 12 15 15 15 9 12 12"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"id": "computeengine",
|
||||
"title": "GCP Compute Engine",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/uptime_total",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/cpu/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/guest/memory/bytes_used",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/guest/memory/percent_used",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "agent.googleapis.com/memory/percent_used",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/average_io_latency",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/read_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/write_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/read_ops_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/write_ops_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/performance_status",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/received_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/sent_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/received_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/sent_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/firewall/dropped_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Compute Engine Overview",
|
||||
"description": "Overview of GCP Compute Engine metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Compute Engine with SigNoz
|
||||
|
||||
Collect key GCP Compute Engine metrics and view them with an out of the box dashboard.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#4285f4;}.cls-1,.cls-2,.cls-4{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3,.cls-4{fill:#aecbfa;}</style></defs><title>Icon_24px_K8Engine_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="14.68 13.06 19.23 15.69 19.23 16.68 14.29 13.83 14.68 13.06"/><polygon class="cls-2" points="9.98 13.65 4.77 16.66 4.45 15.86 9.53 12.92 9.98 13.65"/><rect class="cls-3" x="11.55" y="3.29" width="0.86" height="5.78"/><path class="cls-4" d="M3.25,7V17L12,22l8.74-5V7L12,2Zm15.63,8.89L12,19.78,5.12,15.89V8.11L12,4.22l6.87,3.89v7.78Z"/><polygon class="cls-4" points="11.98 11.5 15.96 9.21 11.98 6.91 8.01 9.21 11.98 11.5"/><polygon class="cls-2" points="11.52 12.3 7.66 10.01 7.66 14.6 11.52 16.89 11.52 12.3"/><polygon class="cls-1" points="12.48 12.3 12.48 16.89 16.34 14.6 16.34 10.01 12.48 12.3"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 941 B |
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"id": "gke",
|
||||
"title": "GCP Kubernetes Engine",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "kubernetes.io/container/cpu/limit_utilization",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/container/cpu/request_utilization",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/container/memory/limit_utilization",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/container/memory/request_utilization",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/container/restart_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/pod/latencies/pod_first_ready",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/pod/network/received_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/pod/network/sent_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/pod/volume/utilization",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/node/cpu/allocatable_utilization",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/node/memory/allocatable_utilization",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/node/ephemeral_storage/used_bytes",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/node/ephemeral_storage/allocatable_bytes",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/node/network/received_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/node/network/sent_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "kubernetes.io/node/status_condition",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Kubernetes Engine Overview",
|
||||
"description": "Overview of GCP Kubernetes Engine metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Kubernetes Engine with SigNoz
|
||||
|
||||
Collect key GCP Kubernetes Engine metrics and view them with an out of the box dashboard.
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (m *module) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
metadataTable := fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName)
|
||||
metadataTable := fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName)
|
||||
var (
|
||||
systemMetricCount uint64
|
||||
k8sMetricCount uint64
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -281,7 +281,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// Built once; identical across the two fps CTEs (buildFilterClause hits the
|
||||
// metadata store + parses the expression). AddWhereClause only reads it.
|
||||
@@ -310,7 +310,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
)
|
||||
}
|
||||
stateFps.Select(stateFpsCols...)
|
||||
stateFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
stateFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
stateFps.Where(
|
||||
stateFps.E("metric_name", containerStatusStateMetricName),
|
||||
stateFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -342,7 +342,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
containerState.Select(containerStateCols...)
|
||||
containerState.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN state_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerState.Where(
|
||||
containerState.E("samples.metric_name", containerStatusStateMetricName),
|
||||
@@ -361,7 +361,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", reasonFps.Var(containerNameAttrKey)),
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS reason", reasonFps.Var(containerStatusReasonAttrKey)),
|
||||
)
|
||||
reasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
reasonFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
reasonFps.Where(
|
||||
reasonFps.E("metric_name", containerStatusReasonMetricName),
|
||||
reasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -391,7 +391,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
)
|
||||
reasonInner.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
reasonInner.Where(
|
||||
reasonInner.E("samples.metric_name", containerStatusReasonMetricName),
|
||||
@@ -546,7 +546,7 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
@@ -572,7 +572,7 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
)
|
||||
}
|
||||
restartFps.Select(restartFpsCols...)
|
||||
restartFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
restartFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
restartFps.Where(
|
||||
restartFps.E("metric_name", containerRestartsMetricName),
|
||||
restartFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -602,7 +602,7 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
containerRestarts.Select(containerRestartsCols...)
|
||||
containerRestarts.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN restart_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerRestarts.Where(
|
||||
containerRestarts.E("samples.metric_name", containerRestartsMetricName),
|
||||
@@ -690,7 +690,7 @@ func (m *module) getPerGroupContainerReadyCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
@@ -716,7 +716,7 @@ func (m *module) getPerGroupContainerReadyCounts(
|
||||
)
|
||||
}
|
||||
readyFps.Select(readyFpsCols...)
|
||||
readyFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
readyFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
readyFps.Where(
|
||||
readyFps.E("metric_name", containerReadyMetricName),
|
||||
readyFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -746,7 +746,7 @@ func (m *module) getPerGroupContainerReadyCounts(
|
||||
containerReady.Select(containerReadyCols...)
|
||||
containerReady.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN ready_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerReady.Where(
|
||||
containerReady.E("samples.metric_name", containerReadyMetricName),
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
@@ -344,11 +344,11 @@ func alignedMetricWindow(startMs, endMs int64) (
|
||||
flooredEndMs = flooredEndMs - (flooredEndMs % (adjustStep * 1000))
|
||||
}
|
||||
|
||||
tsAdjustedStartMs, _, distributedTSTable, localTSTable := telemetrymetrics.WhichTSTableToUse(
|
||||
tsAdjustedStartMs, _, distributedTSTable, localTSTable := metricstelemetryschema.WhichTSTableToUse(
|
||||
samplesAdjustedStartMs, flooredEndMs, false, nil,
|
||||
)
|
||||
|
||||
distributedSamplesTable, localSamplesTable := telemetrymetrics.WhichSamplesTableToUse(
|
||||
distributedSamplesTable, localSamplesTable := metricstelemetryschema.WhichSamplesTableToUse(
|
||||
samplesAdjustedStartMs, flooredEndMs,
|
||||
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil,
|
||||
)
|
||||
@@ -362,7 +362,7 @@ func alignedMetricWindow(startMs, endMs int64) (
|
||||
func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, samplesTable string, flooredStart, flooredEnd uint64) *sqlbuilder.SelectBuilder {
|
||||
fpSB := sqlbuilder.NewSelectBuilder()
|
||||
fpSB.Select("DISTINCT fingerprint")
|
||||
fpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
|
||||
fpSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, samplesTable))
|
||||
fpSB.Where(
|
||||
fpSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
fpSB.GE("unix_milli", flooredStart),
|
||||
@@ -376,7 +376,7 @@ func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, sample
|
||||
func (m *module) buildReducedSamplesTblFingerprintSubQuery(metricNames []string, flooredStart, flooredEnd uint64) *sqlbuilder.SelectBuilder {
|
||||
lastSB := sqlbuilder.NewSelectBuilder()
|
||||
lastSB.Select("reduced_fingerprint")
|
||||
lastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
lastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
lastSB.Where(
|
||||
lastSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
lastSB.GE("unix_milli", flooredStart),
|
||||
@@ -385,7 +385,7 @@ func (m *module) buildReducedSamplesTblFingerprintSubQuery(metricNames []string,
|
||||
|
||||
sumSB := sqlbuilder.NewSelectBuilder()
|
||||
sumSB.Select("reduced_fingerprint")
|
||||
sumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
sumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
sumSB.Where(
|
||||
sumSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sumSB.GE("unix_milli", flooredStart),
|
||||
@@ -478,7 +478,7 @@ func (m *module) getEarliestMetricTime(ctx context.Context, metricNames []string
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("min(first_reported_unix_milli) AS min_first_reported")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -504,7 +504,7 @@ func (m *module) getMetricsExistence(ctx context.Context, metricNames []string)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "count(*) AS cnt")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
@@ -549,7 +549,7 @@ func (m *module) getAttributesExistence(ctx context.Context, metricNames, attrNa
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("attr_name", "count(*) AS cnt")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.In("attr_name", sqlbuilder.List(attrNames)),
|
||||
@@ -656,7 +656,7 @@ func (m *module) getMetadata(
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels", "unix_milli")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTableName))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -669,7 +669,7 @@ func (m *module) getMetadata(
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels", "unix_milli")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -683,7 +683,7 @@ func (m *module) getMetadata(
|
||||
// Inner query reads over the union of raw + reduced series.
|
||||
innerSB.From(innerSB.BuilderAs(sqlbuilder.UnionAll(rawSrc, reducedSrc), "series"))
|
||||
} else {
|
||||
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
innerSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTableName))
|
||||
innerSB.Where(
|
||||
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
innerSB.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -870,7 +870,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTbl))
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTbl))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -883,7 +883,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -896,7 +896,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
|
||||
sb.From(sb.BuilderAs(sqlbuilder.UnionAll(rawSrc, reducedSrc), "series"))
|
||||
} else {
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTbl))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTbl))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", tsAdjustedStartMs),
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -72,7 +72,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTableName))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -85,7 +85,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -101,7 +101,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, localSamplesTable, samplesStartMs, flooredEndMs)
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -357,7 +357,7 @@ func (m *module) getActiveHostsQuery(metricNames []string, hostNameAttr string,
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Distinct()
|
||||
sb.Select("attr_string_value")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.E("attr_name", hostNameAttr),
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
@@ -40,8 +40,8 @@ func NewModule(
|
||||
providerSettings factory.ProviderSettings,
|
||||
cfg inframonitoring.Config,
|
||||
) inframonitoring.Module {
|
||||
fieldMapper := telemetrymetrics.NewFieldMapper()
|
||||
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper()
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -186,7 +186,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
|
||||
// Step-floor bounds + resolve tables in one shot to match QB v5 querier.
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStartMs, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// ----- timeSeriesFPs -----
|
||||
timeSeriesFPs := sqlbuilder.NewSelectBuilder()
|
||||
@@ -200,7 +200,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
)
|
||||
}
|
||||
timeSeriesFPs.Select(timeSeriesFPsSelectCols...)
|
||||
timeSeriesFPs.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
timeSeriesFPs.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
timeSeriesFPs.Where(
|
||||
timeSeriesFPs.E("metric_name", nodeConditionMetricName),
|
||||
timeSeriesFPs.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -237,7 +237,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
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, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
latestConditionPerNode.Where(
|
||||
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -284,7 +284,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// Build the merged filter clause once; it's identical across the three fps
|
||||
// CTEs, and buildFilterClause hits the metadata store + parses the
|
||||
@@ -313,7 +313,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
)
|
||||
}
|
||||
phaseFps.Select(phaseFpsCols...)
|
||||
phaseFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
phaseFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
phaseFps.Where(
|
||||
phaseFps.E("metric_name", podPhaseMetricName),
|
||||
phaseFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -340,7 +340,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
phasePerPod.Select(phasePerPodCols...)
|
||||
phasePerPod.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN phase_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
phasePerPod.Where(
|
||||
phasePerPod.E("samples.metric_name", podPhaseMetricName),
|
||||
@@ -357,7 +357,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", podReasonFps.Var(podUIDAttrKey)),
|
||||
)
|
||||
podReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
podReasonFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
podReasonFps.Where(
|
||||
podReasonFps.E("metric_name", podStatusReasonMetricName),
|
||||
podReasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -377,7 +377,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
)
|
||||
podReasonPerPod.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN pod_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
podReasonPerPod.Where(
|
||||
podReasonPerPod.E("samples.metric_name", podStatusReasonMetricName),
|
||||
@@ -396,7 +396,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", containerReasonFps.Var(containerNameAttrKey)),
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS reason", containerReasonFps.Var(containerStatusReasonAttrKey)),
|
||||
)
|
||||
containerReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
containerReasonFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
containerReasonFps.Where(
|
||||
containerReasonFps.E("metric_name", containerStatusReasonMetricName),
|
||||
containerReasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -432,7 +432,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
)
|
||||
containerInner.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN container_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerInner.Where(
|
||||
containerInner.E("samples.metric_name", containerStatusReasonMetricName),
|
||||
@@ -611,7 +611,7 @@ func (m *module) getPerGroupPodRestartCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
@@ -637,7 +637,7 @@ func (m *module) getPerGroupPodRestartCounts(
|
||||
)
|
||||
}
|
||||
restartFps.Select(restartFpsCols...)
|
||||
restartFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
restartFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
restartFps.Where(
|
||||
restartFps.E("metric_name", containerRestartsMetricName),
|
||||
restartFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -667,7 +667,7 @@ func (m *module) getPerGroupPodRestartCounts(
|
||||
containerRestarts.Select(containerRestartsCols...)
|
||||
containerRestarts.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN restart_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerRestarts.Where(
|
||||
containerRestarts.E("samples.metric_name", containerRestartsMetricName),
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
@@ -49,8 +49,8 @@ type module struct {
|
||||
|
||||
// NewModule constructs the metrics module with the provided dependencies.
|
||||
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, ruleStore ruletypes.RuleStore, dashboardModule dashboard.Module, fl flagger.Flagger, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
|
||||
fieldMapper := telemetrymetrics.NewFieldMapper()
|
||||
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper()
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: ts,
|
||||
fieldMapper: fieldMapper,
|
||||
@@ -89,7 +89,7 @@ func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorerty
|
||||
"argMax(temporality, unix_milli) AS temporality",
|
||||
"argMax(is_monotonic, unix_milli) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metertelemetryschema.DBName, metertelemetryschema.SamplesTableName))
|
||||
|
||||
if params.Start != nil && params.End != nil {
|
||||
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
|
||||
@@ -160,11 +160,11 @@ func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *met
|
||||
hasRange := params.Start != nil && params.End != nil
|
||||
if hasRange {
|
||||
var distributedTsTable string
|
||||
rangeStart, rangeEnd, distributedTsTable, _ = telemetrymetrics.WhichTSTableToUse(uint64(*params.Start), uint64(*params.End), false, nil)
|
||||
rawSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
rangeStart, rangeEnd, distributedTsTable, _ = metricstelemetryschema.WhichTSTableToUse(uint64(*params.Start), uint64(*params.End), false, nil)
|
||||
rawSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
rawSB.Where(rawSB.Between("unix_milli", rangeStart, rangeEnd))
|
||||
} else {
|
||||
rawSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
|
||||
rawSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV41weekTableName))
|
||||
}
|
||||
|
||||
applyListFilters(rawSB)
|
||||
@@ -174,7 +174,7 @@ func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *met
|
||||
if reductionEnabled {
|
||||
reducedSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedSB.Select("DISTINCT metric_name")
|
||||
reducedSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
if hasRange {
|
||||
reducedSB.Where(reducedSB.Between("unix_milli", rangeStart, rangeEnd))
|
||||
}
|
||||
@@ -512,7 +512,7 @@ func (m *module) CheckMetricExists(ctx context.Context, orgID valuer.UUID, metri
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0 as metricExists")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -534,7 +534,7 @@ func (m *module) HasNonSigNozMetrics(ctx context.Context) (bool, error) {
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV41weekTableName))
|
||||
sb.Where("metric_name NOT LIKE 'signoz_%'")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -570,10 +570,10 @@ func (m *module) InspectMetrics(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tsStart, _, tsTable, _ := telemetrymetrics.WhichTSTableToUse(start, end, false, nil)
|
||||
tsStart, _, tsTable, _ := metricstelemetryschema.WhichTSTableToUse(start, end, false, nil)
|
||||
tsSb := sqlbuilder.NewSelectBuilder()
|
||||
tsSb.Select("fingerprint", "labels")
|
||||
tsSb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, tsTable))
|
||||
tsSb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, tsTable))
|
||||
tsSb.Where(
|
||||
tsSb.E("metric_name", req.MetricName),
|
||||
tsSb.GE("unix_milli", tsStart),
|
||||
@@ -625,7 +625,7 @@ func (m *module) InspectMetrics(
|
||||
|
||||
samplesSb := sqlbuilder.NewSelectBuilder()
|
||||
samplesSb.Select("fingerprint", "unix_milli", "value")
|
||||
samplesSb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4TableName))
|
||||
samplesSb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4TableName))
|
||||
samplesSb.Where(
|
||||
samplesSb.In("fingerprint", fingerprints...),
|
||||
samplesSb.E("metric_name", req.MetricName),
|
||||
@@ -717,7 +717,7 @@ func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, me
|
||||
"argMax(temporality, created_at) AS temporality",
|
||||
"argMax(is_monotonic, created_at) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.UpdatedMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.UpdatedMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", args...))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
@@ -777,7 +777,7 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
"anyLast(temporality) AS temporality",
|
||||
"argMax(is_monotonic, unix_milli) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4TableName))
|
||||
sb.Where(sb.In("metric_name", args...))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
@@ -891,7 +891,7 @@ func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, l
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0 AS has_label")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
sb.Where(sb.E("attr_name", label))
|
||||
sb.Limit(1)
|
||||
@@ -914,7 +914,7 @@ func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, r
|
||||
createdAt := time.Now().UnixMilli()
|
||||
|
||||
ib := sqlbuilder.NewInsertBuilder()
|
||||
ib.InsertInto(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.UpdatedMetadataTableName))
|
||||
ib.InsertInto(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.UpdatedMetadataTableName))
|
||||
ib.Cols("metric_name", "temporality", "is_monotonic", "type", "description", "unit", "created_at")
|
||||
ib.Values(
|
||||
req.MetricName,
|
||||
@@ -1016,9 +1016,9 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
}
|
||||
}
|
||||
|
||||
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
start, end, distributedTsTable, localTsTable := metricstelemetryschema.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := metricstelemetryschema.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := metricstelemetryschema.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// Timeseries counts per metric
|
||||
tsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1026,7 +1026,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
"metric_name",
|
||||
"uniq(fingerprint) AS timeseries",
|
||||
)
|
||||
tsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
tsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
tsSB.Where(tsSB.Between("unix_milli", start, end))
|
||||
tsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
if filterWhereClause != nil {
|
||||
@@ -1040,7 +1040,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
"metric_name",
|
||||
fmt.Sprintf("%s AS samples", countExp),
|
||||
)
|
||||
samplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
|
||||
samplesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedSamplesTable))
|
||||
samplesSB.Where(samplesSB.Between("unix_milli", req.Start, req.End))
|
||||
samplesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
@@ -1053,7 +1053,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
if filterWhereClause != nil {
|
||||
fingerprintSB := sqlbuilder.NewSelectBuilder()
|
||||
fingerprintSB.Select("fingerprint")
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTsTable))
|
||||
fingerprintSB.Where(fingerprintSB.Between("unix_milli", start, end))
|
||||
fingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
fingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1064,7 +1064,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
} else {
|
||||
metricNamesSB := sqlbuilder.NewSelectBuilder()
|
||||
metricNamesSB.Select("DISTINCT metric_name")
|
||||
metricNamesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
|
||||
metricNamesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTsTable))
|
||||
metricNamesSB.Where(metricNamesSB.Between("unix_milli", start, end))
|
||||
metricNamesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
@@ -1090,18 +1090,18 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
// the data lands either of the _reduced_last/_reduced_sum tables
|
||||
reducedLastSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedLastSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.Where(reducedLastSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedLastSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
reducedSumSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedSumSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.Where(reducedSumSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedSumSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
// separate query for reduced series counts
|
||||
reducedTsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedTsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedTsSB.Where(reducedTsSB.Between("unix_milli", start, end))
|
||||
reducedTsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedTsSB.GroupBy("metric_name")
|
||||
@@ -1112,7 +1112,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
// samples uses a separate cte with local table
|
||||
reducedFpSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedFpSB.Select("fingerprint")
|
||||
reducedFpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFpSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFpSB.Where(reducedFpSB.Between("unix_milli", start, end))
|
||||
reducedFpSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedFpSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1220,11 +1220,11 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
}
|
||||
}
|
||||
|
||||
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
start, end, distributedTsTable, _ := metricstelemetryschema.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
|
||||
totalTSBuilder := sqlbuilder.NewSelectBuilder()
|
||||
totalTSBuilder.Select("uniq(fingerprint) AS total_time_series")
|
||||
totalTSBuilder.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
totalTSBuilder.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
totalTSBuilder.Where(totalTSBuilder.Between("unix_milli", start, end))
|
||||
|
||||
metricsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1232,7 +1232,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
"metric_name",
|
||||
"uniq(fingerprint) AS total_value",
|
||||
)
|
||||
metricsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
metricsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
metricsSB.Where(metricsSB.Between("unix_milli", start, end))
|
||||
metricsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
if filterWhereClause != nil {
|
||||
@@ -1244,7 +1244,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
if reductionEnabled {
|
||||
reducedTotalTSBuilder := sqlbuilder.NewSelectBuilder()
|
||||
reducedTotalTSBuilder.Select("uniq(fingerprint) AS total_time_series")
|
||||
reducedTotalTSBuilder.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedTotalTSBuilder.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedTotalTSBuilder.Where(reducedTotalTSBuilder.Between("unix_milli", start, end))
|
||||
|
||||
reducedMetricsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1252,7 +1252,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
"metric_name",
|
||||
"uniq(fingerprint) AS total_value",
|
||||
)
|
||||
reducedMetricsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedMetricsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedMetricsSB.Where(reducedMetricsSB.Between("unix_milli", start, end))
|
||||
reducedMetricsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
if filterWhereClause != nil {
|
||||
@@ -1338,15 +1338,15 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
}
|
||||
}
|
||||
|
||||
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
start, end, distributedTsTable, localTsTable := metricstelemetryschema.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := metricstelemetryschema.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := metricstelemetryschema.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
|
||||
candidateLimit := req.Limit + 50
|
||||
|
||||
metricCandidatesSB := sqlbuilder.NewSelectBuilder()
|
||||
metricCandidatesSB.Select("metric_name")
|
||||
metricCandidatesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
metricCandidatesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
metricCandidatesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
metricCandidatesSB.Where(metricCandidatesSB.Between("unix_milli", start, end))
|
||||
if filterWhereClause != nil {
|
||||
@@ -1364,7 +1364,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if reductionEnabled {
|
||||
reducedCandidatesSB = sqlbuilder.NewSelectBuilder()
|
||||
reducedCandidatesSB.Select("metric_name")
|
||||
reducedCandidatesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedCandidatesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedCandidatesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedCandidatesSB.Where(reducedCandidatesSB.Between("unix_milli", start, end))
|
||||
if filterWhereClause != nil {
|
||||
@@ -1379,7 +1379,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
|
||||
totalSamplesSB := sqlbuilder.NewSelectBuilder()
|
||||
totalSamplesSB.Select(fmt.Sprintf("%s AS total_samples", countExp))
|
||||
totalSamplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
|
||||
totalSamplesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedSamplesTable))
|
||||
totalSamplesSB.Where(totalSamplesSB.Between("unix_milli", req.Start, req.End))
|
||||
|
||||
sampleCountsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1387,7 +1387,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
"metric_name",
|
||||
fmt.Sprintf("%s AS samples", countExp),
|
||||
)
|
||||
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
|
||||
sampleCountsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedSamplesTable))
|
||||
sampleCountsSB.Where(sampleCountsSB.Between("unix_milli", req.Start, req.End))
|
||||
sampleCountsSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
|
||||
|
||||
@@ -1397,13 +1397,13 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if reductionEnabled {
|
||||
reducedLastSB = sqlbuilder.NewSelectBuilder()
|
||||
reducedLastSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.Where(reducedLastSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedLastSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates)")
|
||||
|
||||
reducedSumSB = sqlbuilder.NewSelectBuilder()
|
||||
reducedSumSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.Where(reducedSumSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedSumSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates)")
|
||||
}
|
||||
@@ -1411,7 +1411,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if filterWhereClause != nil {
|
||||
fingerprintSB := sqlbuilder.NewSelectBuilder()
|
||||
fingerprintSB.Select("fingerprint")
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTsTable))
|
||||
fingerprintSB.Where(fingerprintSB.Between("unix_milli", start, end))
|
||||
fingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
fingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1423,7 +1423,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if reductionEnabled {
|
||||
reducedFingerprintSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedFingerprintSB.Select("fingerprint")
|
||||
reducedFingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFingerprintSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFingerprintSB.Where(reducedFingerprintSB.Between("unix_milli", start, end))
|
||||
reducedFingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedFingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1457,12 +1457,12 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
// Grand total includes reduced sample volume so percentages stay consistent.
|
||||
reducedTotalLastSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedTotalLastSB.Select("uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedTotalLastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
reducedTotalLastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
reducedTotalLastSB.Where(reducedTotalLastSB.Between("unix_milli", req.Start, req.End))
|
||||
|
||||
reducedTotalSumSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedTotalSumSB.Select("uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedTotalSumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
reducedTotalSumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
reducedTotalSumSB.Where(reducedTotalSumSB.Between("unix_milli", req.Start, req.End))
|
||||
|
||||
reducedTotalSamplesSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1552,7 +1552,7 @@ func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (ui
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("sum(count) AS data_points")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4Agg30mTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4Agg30mTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -1574,7 +1574,7 @@ func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("MAX(last_reported_unix_milli) AS last_received_time")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
@@ -1599,7 +1599,7 @@ func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint) AS time_series_count")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV41weekTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -1623,7 +1623,7 @@ func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint) AS active_time_series")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4TableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
sb.Where(sb.GTE("unix_milli", milli))
|
||||
|
||||
@@ -1649,7 +1649,7 @@ func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, s
|
||||
"groupUniqArray(1000)(attr_string_value) AS values",
|
||||
"uniq(attr_string_value) AS valueCount",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
sb.Where("NOT startsWith(attr_name, '__')")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
@@ -52,7 +52,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
|
||||
|
||||
response := []promotetypes.PromotePath{}
|
||||
for _, path := range promotedPaths {
|
||||
fullPath := telemetrylogs.BodyPromotedColumnPrefix + path
|
||||
fullPath := logstelemetryschema.BodyPromotedColumnPrefix + path
|
||||
path = telemetrytypes.BodyJSONStringSearchPrefix + path
|
||||
item := promotetypes.PromotePath{
|
||||
Path: path,
|
||||
@@ -68,7 +68,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
|
||||
|
||||
// add the paths that are not promoted but have indexes
|
||||
for path, indexes := range aggr {
|
||||
path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
|
||||
path := strings.TrimPrefix(path, logstelemetryschema.BodyV2ColumnPrefix)
|
||||
path = telemetrytypes.BodyJSONStringSearchPrefix + path
|
||||
response = append(response, promotetypes.PromotePath{
|
||||
Path: path,
|
||||
@@ -108,8 +108,8 @@ func (m *module) createIndexes(ctx context.Context, indexes []schemamigrator.Ind
|
||||
|
||||
for _, index := range indexes {
|
||||
alterStmt := schemamigrator.AlterTableAddIndex{
|
||||
Database: telemetrylogs.DBName,
|
||||
Table: telemetrylogs.LogsV2LocalTableName,
|
||||
Database: logstelemetryschema.DBName,
|
||||
Table: logstelemetryschema.LogsV2LocalTableName,
|
||||
Index: index,
|
||||
}
|
||||
op := alterStmt.OnCluster(m.telemetryStore.Cluster())
|
||||
@@ -153,10 +153,10 @@ func (m *module) PromoteAndIndexPaths(
|
||||
}
|
||||
}
|
||||
if len(it.Indexes) > 0 {
|
||||
parentColumn := telemetrylogs.LogsV2BodyV2Column
|
||||
parentColumn := logstelemetryschema.LogsV2BodyV2Column
|
||||
// if the path is already promoted or is being promoted, add it to the promoted column
|
||||
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
|
||||
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn
|
||||
parentColumn = logstelemetryschema.LogsV2BodyPromotedColumn
|
||||
}
|
||||
|
||||
for _, index := range it.Indexes {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/services"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -39,7 +39,7 @@ func (m *module) FetchTopLevelOperations(ctx context.Context, start time.Time, s
|
||||
ctx = m.withServicesContext(ctx, "FetchTopLevelOperations")
|
||||
|
||||
db := m.TelemetryStore.ClickhouseDB()
|
||||
query := fmt.Sprintf("SELECT name, serviceName, max(time) as ts FROM %s.%s WHERE time >= @start", telemetrytraces.DBName, telemetrytraces.TopLevelOperationsTableName)
|
||||
query := fmt.Sprintf("SELECT name, serviceName, max(time) as ts FROM %s.%s WHERE time >= @start", tracestelemetryschema.DBName, tracestelemetryschema.TopLevelOperationsTableName)
|
||||
args := []any{clickhouse.Named("start", start)}
|
||||
if len(services) > 0 {
|
||||
query += " AND serviceName IN @services"
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// statementRecorder collects the statements a PromQL evaluation would run.
|
||||
// Safe for concurrent use: the engine may Select selectors concurrently.
|
||||
type statementRecorder struct {
|
||||
mu sync.Mutex
|
||||
statements []prometheus.CapturedStatement
|
||||
}
|
||||
|
||||
func (r *statementRecorder) record(query string, args []any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
|
||||
}
|
||||
|
||||
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]prometheus.CapturedStatement, len(r.statements))
|
||||
copy(out, r.statements)
|
||||
return out
|
||||
}
|
||||
|
||||
type captureQueryable struct {
|
||||
client *client
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &captureQuerier{
|
||||
querier: querier{mint: mint, maxt: maxt, client: c.client},
|
||||
recorder: c.recorder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// captureQuerier builds the same SQL as the live querier but records it and
|
||||
// returns no data. The fingerprint filter always takes the subquery form:
|
||||
// without executing the series lookup, the inline literal set is unknown.
|
||||
type captureQuerier struct {
|
||||
querier
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureQuerier) Select(ctx context.Context, _ bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
|
||||
start, end := c.window(hints)
|
||||
|
||||
samplesQuery, args, err := buildSamplesQuery(start, end, metricNamesFromMatchers(matchers), matchers, c.lastSamplePerStepFor(ctx, hints))
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
c.recorder.record(samplesQuery, args)
|
||||
|
||||
return storage.EmptySeriesSet()
|
||||
}
|
||||
|
||||
func (c *captureQuerier) LabelValues(context.Context, string, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (c *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// metricNamesFromMatchers extracts the statically known metric name, if any.
|
||||
// The live path derives names from the matched series; the capture path has
|
||||
// no execution results, so only a __name__ equality contributes.
|
||||
func metricNamesFromMatchers(matchers []*labels.Matcher) []string {
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && m.Value != "" {
|
||||
return []string{m.Value}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
)
|
||||
|
||||
// seriesLookup holds a series lookup's result: matched fingerprints with
|
||||
// their labels, and the distinct metric names seen on them.
|
||||
type seriesLookup struct {
|
||||
fingerprints map[uint64]labels.Labels
|
||||
metricNames []string
|
||||
}
|
||||
|
||||
// client executes the series, samples and raw queries against ClickHouse.
|
||||
type client struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
lookbackMs int64
|
||||
}
|
||||
|
||||
func newClient(settings factory.ScopedProviderSettings, telemetryStore telemetrystore.TelemetryStore, cfg prometheus.Config) *client {
|
||||
lookback := cfg.LookbackDelta
|
||||
if lookback <= 0 {
|
||||
// Mirror the engine: promql defaults an unset lookback to 5m.
|
||||
lookback = defaultLookbackDelta
|
||||
}
|
||||
return &client{
|
||||
settings: settings,
|
||||
telemetryStore: telemetryStore,
|
||||
lookbackMs: lookback.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) withContext(ctx context.Context, functionName string) context.Context {
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-prometheus-v2",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) selectSeries(ctx context.Context, query string, args []any) (*seriesLookup, error) {
|
||||
ctx = c.withContext(ctx, "selectSeries")
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
lookup := &seriesLookup{fingerprints: make(map[uint64]labels.Labels)}
|
||||
names := make(map[string]struct{})
|
||||
|
||||
var fingerprint uint64
|
||||
var labelsJSON string
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&fingerprint, &labelsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lset, err := unmarshalLabels(labelsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup.fingerprints[fingerprint] = lset
|
||||
if name := lset.Get(metricNameLabel); name != "" {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for name := range names {
|
||||
lookup.metricNames = append(lookup.metricNames, name)
|
||||
}
|
||||
slices.Sort(lookup.metricNames)
|
||||
|
||||
return lookup, nil
|
||||
}
|
||||
|
||||
// unmarshalLabels parses the labels JSON column, dropping empty-valued
|
||||
// labels: empty means "absent" in Prometheus, but stored attribute JSON can
|
||||
// carry them.
|
||||
func unmarshalLabels(s string) (labels.Labels, error) {
|
||||
m := make(map[string]string)
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return labels.EmptyLabels(), err
|
||||
}
|
||||
builder := labels.NewScratchBuilder(len(m))
|
||||
for k, v := range m {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
builder.Add(k, v)
|
||||
}
|
||||
builder.Sort()
|
||||
return builder.Labels(), nil
|
||||
}
|
||||
|
||||
// selectSamples assembles per-series sample slices from a samples query (raw
|
||||
// or last-sample-per-step; same column shape), whose rows arrive ordered by
|
||||
// (fingerprint, unix_milli). Fingerprints missing from the lookup are skipped:
|
||||
// the samples query's semi-join re-runs the series predicates and can match
|
||||
// series registered after the lookup ran — the lookup is the read snapshot.
|
||||
// Stale flags map to the engine's StaleNaN. Duplicate
|
||||
// timestamps pass through: uniqueness is ingest's job, and v1 feeds them to
|
||||
// the engine as-is over the same dirty data.
|
||||
func (c *client) selectSamples(ctx context.Context, query string, args []any, lookup *seriesLookup) ([]*series, error) {
|
||||
ctx = c.withContext(ctx, "selectSamples")
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var (
|
||||
result []*series
|
||||
current *series
|
||||
fingerprint uint64
|
||||
prevFp uint64
|
||||
timestampMs int64
|
||||
val float64
|
||||
flags uint32
|
||||
first = true
|
||||
haveCurrent bool
|
||||
staleMarker = math.Float64frombits(promValue.StaleNaN)
|
||||
unknownCount int
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&fingerprint, ×tampMs, &val, &flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if first || fingerprint != prevFp {
|
||||
first = false
|
||||
prevFp = fingerprint
|
||||
lset, ok := lookup.fingerprints[fingerprint]
|
||||
if !ok {
|
||||
unknownCount++
|
||||
haveCurrent = false
|
||||
continue
|
||||
}
|
||||
current = &series{lset: lset}
|
||||
result = append(result, current)
|
||||
haveCurrent = true
|
||||
}
|
||||
if !haveCurrent {
|
||||
continue
|
||||
}
|
||||
|
||||
if flags&1 == 1 {
|
||||
val = staleMarker
|
||||
}
|
||||
current.ts = append(current.ts, timestampMs)
|
||||
current.vs = append(current.vs, val)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if unknownCount > 0 {
|
||||
c.settings.Logger().DebugContext(ctx, "skipped samples of fingerprints missing from series lookup",
|
||||
slog.Int("unknown_fingerprints", unknownCount))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Package clickhouseprometheusv2 is the second-generation ClickHouse-backed
|
||||
// Prometheus provider. It exists because the v1 provider fetches every raw
|
||||
// sample of a query's union window through the remote-read protobuf layer
|
||||
// and hands it to the engine — the cost is a function of ingested data, not
|
||||
// of the question asked. Here the stock promql engine evaluates over a
|
||||
// native storage.Querier: no translation layer, per-selector fetch windows,
|
||||
// and fetch reductions that are provably invisible to the engine. Every
|
||||
// reduction either preserves engine semantics exactly or is not performed.
|
||||
//
|
||||
// # Series lookup
|
||||
//
|
||||
// Matchers resolve to series once per selector (selectSeries) against the
|
||||
// time-series tables, which hold one row per (fingerprint, bucket) at
|
||||
// 1h/6h/1d/1w granularities; timeSeriesTableFor picks the table whose bucket
|
||||
// fits the window and rounds the window start down to the bucket boundary.
|
||||
// How matchers become SQL, and why regexes are anchored, is documented at
|
||||
// applySeriesConditions. Empty-valued labels come off at this boundary: an
|
||||
// empty value means "label absent" in Prometheus, but stored attribute JSON
|
||||
// can carry them.
|
||||
//
|
||||
// # Sample fetch
|
||||
//
|
||||
// Samples are fetched per selector using the engine's per-selector hints,
|
||||
// not the query-wide union window, so foo / foo offset 1d reads two narrow
|
||||
// windows instead of the widest one twice. Instant selectors of
|
||||
// subquery-free queries fetch only the last sample per step bucket — see
|
||||
// lastSamplePerStep for the correctness argument — while range selectors
|
||||
// always fetch raw: every sample feeds the range function. Row assembly maps
|
||||
// stale flags to the engine's StaleNaN and merges series with identical
|
||||
// label sets (sortAndMerge), because the engine assumes storages never emit
|
||||
// duplicates.
|
||||
//
|
||||
// # Sharding
|
||||
//
|
||||
// samples_v4 and time_series_v4 (and all their rollups) shard on the same
|
||||
// key — cityHash64(env, temporality, metric_name, fingerprint) — so a
|
||||
// series' samples and catalog rows live on the same shard. The samples
|
||||
// fetch exploits that: it restricts by a shard-local series subquery, not a
|
||||
// GLOBAL broadcast of the matched set. Delta-temporality
|
||||
// series stay invisible to PromQL exactly as they are in v1: the rollout
|
||||
// gate is parity with v1, and a Delta stream fed to rate() as-if-cumulative
|
||||
// would be wrong, not just new.
|
||||
//
|
||||
// # Observability
|
||||
//
|
||||
// Every statement carries a log_comment with
|
||||
// code.namespace=clickhouse-prometheus-v2 and code.function.name naming the
|
||||
// call site, so this provider's work is attributable in system.query_log.
|
||||
package clickhouseprometheusv2
|
||||
@@ -1,72 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
)
|
||||
|
||||
// Provider ties the package together: its own engine and parser, and the
|
||||
// ClickHouse client behind the native storage.Querier. See the package
|
||||
// documentation for how the read path differs from v1. It is exported as a
|
||||
// concrete type — callers hold it directly, and an interface with a single
|
||||
// implementation would only hide that dependency.
|
||||
type Provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
engine *prometheus.Engine
|
||||
parser prometheus.Parser
|
||||
client *client
|
||||
}
|
||||
|
||||
var (
|
||||
_ prometheus.Prometheus = (*Provider)(nil)
|
||||
_ prometheus.StatementCapturer = (*Provider)(nil)
|
||||
)
|
||||
|
||||
func NewFactory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[prometheus.Prometheus, prometheus.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("clickhousev2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config) (prometheus.Prometheus, error) {
|
||||
return New(ctx, providerSettings, config, telemetryStore)
|
||||
})
|
||||
}
|
||||
|
||||
func New(_ context.Context, providerSettings factory.ProviderSettings, config prometheus.Config, telemetryStore telemetrystore.TelemetryStore) (*Provider, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2")
|
||||
|
||||
engine := prometheus.NewEngine(settings.Logger(), config)
|
||||
parser := prometheus.NewParser()
|
||||
client := newClient(settings, telemetryStore, config)
|
||||
|
||||
return &Provider{
|
||||
settings: settings,
|
||||
engine: engine,
|
||||
parser: parser,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Engine() *prometheus.Engine {
|
||||
return p.engine
|
||||
}
|
||||
|
||||
func (p *Provider) Parser() prometheus.Parser {
|
||||
return p.parser
|
||||
}
|
||||
|
||||
func (p *Provider) Storage() storage.Queryable {
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &querier{mint: mint, maxt: maxt, client: p.client}, nil
|
||||
}
|
||||
|
||||
// CapturingStorage implements prometheus.StatementCapturer: a storage that
|
||||
// records each selector's SQL without executing it, for the preview path.
|
||||
// A fresh recorder per call keeps concurrent dry-runs isolated.
|
||||
func (p *Provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
|
||||
recorder := &statementRecorder{}
|
||||
return &captureQueryable{client: p.client, recorder: recorder}, recorder
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// defaultLookbackDelta mirrors promql's default when the config leaves the
|
||||
// lookback unset; the engine and the storage must agree on it for
|
||||
// last-sample-per-step bucket anchoring.
|
||||
const defaultLookbackDelta = 5 * time.Minute
|
||||
|
||||
// querier is a native storage.Querier over ClickHouse: Select builds SQL
|
||||
// directly from the matchers and hints, with no remote-read protobuf layer.
|
||||
type querier struct {
|
||||
mint, maxt int64
|
||||
client *client
|
||||
}
|
||||
|
||||
var _ storage.Querier = (*querier)(nil)
|
||||
|
||||
func (q *querier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
start, end := q.window(hints)
|
||||
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(start, end, matchers)
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
lookup, err := q.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return storage.EmptySeriesSet()
|
||||
}
|
||||
|
||||
list, err := q.fetchSamples(ctx, start, end, matchers, lookup, q.lastSamplePerStepFor(ctx, hints))
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
|
||||
// The engine assumes storages never emit duplicate label sets.
|
||||
list = sortAndMerge(list)
|
||||
return newSeriesSet(list)
|
||||
}
|
||||
|
||||
func (q *querier) LabelValues(ctx context.Context, name string, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
if name == metricNameLabel {
|
||||
sb.Select("DISTINCT metric_name AS value")
|
||||
} else {
|
||||
sb.Select(fmt.Sprintf("DISTINCT JSONExtractString(labels, %s) AS value", sb.Var(name)))
|
||||
}
|
||||
adjustedStart, table := timeSeriesTableFor(q.mint, q.maxt)
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, q.maxt, matchers); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sb.Where("value != ''")
|
||||
if hints != nil && hints.Limit > 0 {
|
||||
sb.Limit(hints.Limit)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
values, err := q.selectStrings(ctx, "LabelValues", query, args)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slices.Sort(values)
|
||||
return values, nil, nil
|
||||
}
|
||||
|
||||
func (q *querier) LabelNames(ctx context.Context, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT arrayJoin(JSONExtractKeys(labels)) AS name")
|
||||
adjustedStart, table := timeSeriesTableFor(q.mint, q.maxt)
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, q.maxt, matchers); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if hints != nil && hints.Limit > 0 {
|
||||
sb.Limit(hints.Limit)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
names, err := q.selectStrings(ctx, "LabelNames", query, args)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names, nil, nil
|
||||
}
|
||||
|
||||
func (q *querier) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// window returns the per-selector fetch window from the hints (already
|
||||
// adjusted for offset, @, range and lookback); mint/maxt span the union of
|
||||
// all selectors and are the fallback.
|
||||
func (q *querier) window(hints *storage.SelectHints) (int64, int64) {
|
||||
if hints != nil && hints.Start != 0 && hints.End != 0 && hints.Start <= hints.End {
|
||||
return hints.Start, hints.End
|
||||
}
|
||||
return q.mint, q.maxt
|
||||
}
|
||||
|
||||
// lastSamplePerStepFor decides whether the fetch can keep only the last
|
||||
// sample per step bucket (see lastSamplePerStep). Only instant selectors
|
||||
// (hints.Range == 0) of subquery-free queries qualify: range selectors need
|
||||
// every raw sample, and subquery selectors evaluate at the subquery's own
|
||||
// step while hints carry the top-level step — the subquery-free proof
|
||||
// arrives as QueryTraits in the context. The first evaluation timestamp is
|
||||
// recovered as hints.Start + lookback - 1ms, inverting how the engine
|
||||
// derives hints.Start.
|
||||
func (q *querier) lastSamplePerStepFor(ctx context.Context, hints *storage.SelectHints) *lastSamplePerStep {
|
||||
if hints == nil || hints.Range != 0 || hints.Start <= 0 {
|
||||
return nil
|
||||
}
|
||||
traits, ok := prometheus.QueryTraitsFromContext(ctx)
|
||||
if !ok || !traits.SubqueryFree {
|
||||
return nil
|
||||
}
|
||||
firstEval := hints.Start + q.client.lookbackMs - 1
|
||||
if firstEval > hints.End {
|
||||
// Defensive: never anchor a bucket past the window.
|
||||
firstEval = hints.End
|
||||
}
|
||||
return &lastSamplePerStep{firstEvalMs: firstEval, stepMs: hints.Step}
|
||||
}
|
||||
|
||||
// fetchSamples runs the samples query for the matched series (see
|
||||
// buildSamplesQuery).
|
||||
func (q *querier) fetchSamples(ctx context.Context, start, end int64, matchers []*labels.Matcher, lookup *seriesLookup, lastPerStep *lastSamplePerStep) ([]*series, error) {
|
||||
query, args, err := buildSamplesQuery(start, end, lookup.metricNames, matchers, lastPerStep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.client.selectSamples(ctx, query, args, lookup)
|
||||
}
|
||||
|
||||
func (q *querier) selectStrings(ctx context.Context, fn, query string, args []any) ([]string, error) {
|
||||
ctx = q.client.withContext(ctx, fn)
|
||||
rows, err := q.client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
var v string
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/prometheus/prometheus/model/histogram"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/tsdb/chunkenc"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// series is one time series with samples as parallel slices ordered by
|
||||
// timestamp. Deliberately not storage.NewListSeries: that boxes every sample
|
||||
// as an interface value, a per-sample allocation this fetch path exists to
|
||||
// avoid.
|
||||
type series struct {
|
||||
lset labels.Labels
|
||||
ts []int64
|
||||
vs []float64
|
||||
}
|
||||
|
||||
var _ storage.Series = (*series)(nil)
|
||||
|
||||
func (s *series) Labels() labels.Labels {
|
||||
return s.lset
|
||||
}
|
||||
|
||||
func (s *series) Iterator(it chunkenc.Iterator) chunkenc.Iterator {
|
||||
if fit, ok := it.(*floatIterator); ok {
|
||||
fit.reset(s)
|
||||
return fit
|
||||
}
|
||||
fit := &floatIterator{}
|
||||
fit.reset(s)
|
||||
return fit
|
||||
}
|
||||
|
||||
// floatIterator implements chunkenc.Iterator over a series' sample slices.
|
||||
type floatIterator struct {
|
||||
s *series
|
||||
i int
|
||||
}
|
||||
|
||||
var _ chunkenc.Iterator = (*floatIterator)(nil)
|
||||
|
||||
func (it *floatIterator) reset(s *series) {
|
||||
it.s = s
|
||||
it.i = -1
|
||||
}
|
||||
|
||||
func (it *floatIterator) Next() chunkenc.ValueType {
|
||||
it.i++
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
|
||||
func (it *floatIterator) Seek(t int64) chunkenc.ValueType { //nolint:govet // stdmethods flags io.Seeker; this is chunkenc.Iterator's Seek
|
||||
if it.i < 0 {
|
||||
it.i = 0
|
||||
}
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
// The current position, once valid, must not move backwards.
|
||||
if it.s.ts[it.i] >= t {
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
it.i += sort.Search(len(it.s.ts)-it.i, func(j int) bool {
|
||||
return it.s.ts[it.i+j] >= t
|
||||
})
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
|
||||
func (it *floatIterator) At() (int64, float64) {
|
||||
return it.s.ts[it.i], it.s.vs[it.i]
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtHistogram(*histogram.Histogram) (int64, *histogram.Histogram) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtFloatHistogram(*histogram.FloatHistogram) (int64, *histogram.FloatHistogram) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtT() int64 {
|
||||
return it.s.ts[it.i]
|
||||
}
|
||||
|
||||
// AtST returns the current start timestamp; not tracked by this storage.
|
||||
func (it *floatIterator) AtST() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (it *floatIterator) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seriesSet iterates a fully materialized, label-sorted list of series.
|
||||
type seriesSet struct {
|
||||
series []*series
|
||||
i int
|
||||
}
|
||||
|
||||
var _ storage.SeriesSet = (*seriesSet)(nil)
|
||||
|
||||
func newSeriesSet(list []*series) *seriesSet {
|
||||
return &seriesSet{series: list, i: -1}
|
||||
}
|
||||
|
||||
func (s *seriesSet) Next() bool {
|
||||
s.i++
|
||||
return s.i < len(s.series)
|
||||
}
|
||||
|
||||
func (s *seriesSet) At() storage.Series {
|
||||
return s.series[s.i]
|
||||
}
|
||||
|
||||
func (s *seriesSet) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *seriesSet) Warnings() annotations.Annotations {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sortAndMerge orders series by label set and merges identical label sets
|
||||
// by timestamp (first sample wins ties): distinct fingerprints can carry
|
||||
// identical label sets, and the engine assumes storages never emit
|
||||
// duplicates.
|
||||
func sortAndMerge(list []*series) []*series {
|
||||
if len(list) < 2 {
|
||||
return list
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return labels.Compare(list[i].lset, list[j].lset) < 0
|
||||
})
|
||||
out := list[:1]
|
||||
for _, s := range list[1:] {
|
||||
last := out[len(out)-1]
|
||||
if labels.Compare(last.lset, s.lset) != 0 {
|
||||
out = append(out, s)
|
||||
continue
|
||||
}
|
||||
merged := mergeSamples(last, s)
|
||||
out[len(out)-1] = merged
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeSamples(a, b *series) *series {
|
||||
ts := make([]int64, 0, len(a.ts)+len(b.ts))
|
||||
vs := make([]float64, 0, len(a.ts)+len(b.ts))
|
||||
i, j := 0, 0
|
||||
for i < len(a.ts) && j < len(b.ts) {
|
||||
switch {
|
||||
case a.ts[i] < b.ts[j]:
|
||||
ts = append(ts, a.ts[i])
|
||||
vs = append(vs, a.vs[i])
|
||||
i++
|
||||
case a.ts[i] > b.ts[j]:
|
||||
ts = append(ts, b.ts[j])
|
||||
vs = append(vs, b.vs[j])
|
||||
j++
|
||||
default:
|
||||
ts = append(ts, a.ts[i])
|
||||
vs = append(vs, a.vs[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
ts = append(ts, a.ts[i:]...)
|
||||
vs = append(vs, a.vs[i:]...)
|
||||
ts = append(ts, b.ts[j:]...)
|
||||
vs = append(vs, b.vs[j:]...)
|
||||
return &series{lset: a.lset, ts: ts, vs: vs}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
)
|
||||
|
||||
// buildSeriesQuery renders the series lookup: one row per matched fingerprint
|
||||
// with its labels.
|
||||
func buildSeriesQuery(start, end int64, matchers []*labels.Matcher) (string, []any, error) {
|
||||
adjustedStart, table := timeSeriesTableFor(start, end)
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("fingerprint", "any(labels)")
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, end, matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.GroupBy("fingerprint")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
// buildSamplesQuery renders the samples fetch for the matched series: a
|
||||
// semi-join re-runs the series predicates against the shard-local series
|
||||
// table (complete by fingerprint co-locality, see localTimeSeriesTable — a
|
||||
// GLOBAL broadcast would ship the matched set to every shard, and ClickHouse
|
||||
// materializes the subquery's set per shard before the scan, so it still
|
||||
// engages the fingerprint primary-key column). metricNames (observed on the
|
||||
// matched series when the selector had no __name__ equality) narrows the
|
||||
// primary-key scan. A non-nil lastPerStep groups to the last sample per
|
||||
// step bucket.
|
||||
func buildSamplesQuery(start, end int64, metricNames []string, matchers []*labels.Matcher, lastPerStep *lastSamplePerStep) (string, []any, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
if lastPerStep != nil {
|
||||
// Aliases must not shadow source columns: ClickHouse resolves aliases
|
||||
// in WHERE too, and "max(unix_milli) AS unix_milli" would put an
|
||||
// aggregate into the WHERE clause (error 184).
|
||||
sb.Select("fingerprint", "max(unix_milli) AS ts", "argMax(value, unix_milli) AS val", "argMax(flags, unix_milli) AS fl")
|
||||
} else {
|
||||
sb.Select("fingerprint", "unix_milli", "value", "flags")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, distributedSamplesV4))
|
||||
|
||||
switch len(metricNames) {
|
||||
case 0:
|
||||
// No name constraint derivable; the primary-key prefix goes unused.
|
||||
case 1:
|
||||
sb.Where(sb.EQ("metric_name", metricNames[0]))
|
||||
default:
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
}
|
||||
// Semantically redundant (the fingerprints already come from these
|
||||
// temporalities) but engages the leading primary-key column.
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
sub.Select("fingerprint")
|
||||
adjustedStart, table := timeSeriesTableFor(start, end)
|
||||
sub.From(fmt.Sprintf("%s.%s", databaseName, localTimeSeriesTable(table)))
|
||||
if err := applySeriesConditions(sub, adjustedStart, end, matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.Where(sb.In("fingerprint", sub))
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
|
||||
|
||||
if lastPerStep != nil {
|
||||
sb.GroupBy("fingerprint")
|
||||
if expr := lastPerStep.bucketExpr(); expr != "" {
|
||||
sb.GroupBy(expr)
|
||||
}
|
||||
sb.OrderBy("fingerprint", "ts")
|
||||
} else {
|
||||
sb.OrderBy("fingerprint", "unix_milli")
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
// applySeriesConditions adds the WHERE conditions of a series table scan:
|
||||
// __name__ matchers (all four types) translate to the metric_name column,
|
||||
// every other matcher to a JSONExtractString condition on the labels column.
|
||||
// An equality matcher against "" matches series without the label, mirroring
|
||||
// PromQL, because JSONExtractString returns "" for missing keys. Regexes are
|
||||
// anchored: PromQL matchers match the whole value, ClickHouse match()
|
||||
// searches for a substring.
|
||||
func applySeriesConditions(sb *sqlbuilder.SelectBuilder, start, end int64, matchers []*labels.Matcher) error {
|
||||
for _, m := range matchers {
|
||||
if m.Name != metricNameLabel {
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case labels.MatchEqual:
|
||||
sb.Where(sb.EQ("metric_name", m.Value))
|
||||
case labels.MatchNotEqual:
|
||||
sb.Where(sb.NE("metric_name", m.Value))
|
||||
case labels.MatchRegexp:
|
||||
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
case labels.MatchNotRegexp:
|
||||
sb.Where(fmt.Sprintf("NOT match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported matcher type %q for __name__", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
// Inclusive upper bound: registration rows are hour-floored (and 6h/1d/1w
|
||||
// for the rollup tables) by the exporter, so a series first registered in
|
||||
// the bucket starting exactly at `end` would otherwise be invisible while
|
||||
// its samples (<= end) are in range.
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
|
||||
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel {
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case labels.MatchEqual:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case labels.MatchNotEqual:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case labels.MatchRegexp:
|
||||
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
case labels.MatchNotRegexp:
|
||||
sb.Where(fmt.Sprintf("NOT match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported matcher type %q", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorRegex turns a PromQL regex into its fully-anchored form (see
|
||||
// applySeriesConditions).
|
||||
func anchorRegex(v string) string {
|
||||
return "^(?:" + v + ")$"
|
||||
}
|
||||
|
||||
// lastSamplePerStep reduces an instant-selector fetch to the last sample of
|
||||
// each step bucket: bucket 0 is (start, firstEval], bucket i is
|
||||
// (firstEval+(i-1)·step, firstEval+i·step]. Anchoring buckets at the first
|
||||
// evaluation timestamp makes every bucket boundary an evaluation timestamp,
|
||||
// so a non-final sample of a bucket can never be the latest sample in any
|
||||
// (t-lookback, t] the engine resolves — the reduction is lossless. Real
|
||||
// timestamps are preserved, so the engine's own lookback and staleness
|
||||
// handling remain exact.
|
||||
type lastSamplePerStep struct {
|
||||
firstEvalMs int64
|
||||
stepMs int64
|
||||
}
|
||||
|
||||
func (t *lastSamplePerStep) bucketExpr() string {
|
||||
if t.stepMs <= 0 {
|
||||
// Instant query: a single evaluation at firstEval; one bucket.
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"if(unix_milli <= %d, 0, intDiv(unix_milli - %d - 1, %d) + 1)",
|
||||
t.firstEvalMs, t.firstEvalMs, t.stepMs,
|
||||
)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mustMatcher(t *testing.T, mt labels.MatchType, name, value string) *labels.Matcher {
|
||||
t.Helper()
|
||||
m, err := labels.NewMatcher(mt, name, value)
|
||||
require.NoError(t, err)
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTimeSeriesTableFor(t *testing.T) {
|
||||
base := time.Date(2026, 7, 10, 3, 27, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
span time.Duration
|
||||
wantTable string
|
||||
roundTo time.Duration
|
||||
}{
|
||||
{"under 6h uses hourly table", 2 * time.Hour, distributedTimeSeriesV4, time.Hour},
|
||||
{"under 1d uses 6h table", 12 * time.Hour, distributedTimeSeriesV46hrs, 6 * time.Hour},
|
||||
{"under 1w uses 1d table", 3 * 24 * time.Hour, distributedTimeSeriesV41day, 24 * time.Hour},
|
||||
{"over 1w uses 1w table", 10 * 24 * time.Hour, distributedTimeSeriesV41week, 7 * 24 * time.Hour},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, table := timeSeriesTableFor(base, base+tt.span.Milliseconds())
|
||||
assert.Equal(t, tt.wantTable, table)
|
||||
assert.Zero(t, start%tt.roundTo.Milliseconds())
|
||||
assert.LessOrEqual(t, start, base)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSeriesQuery(t *testing.T) {
|
||||
start := int64(1_700_000_000_000)
|
||||
end := start + time.Hour.Milliseconds()
|
||||
// The series table window rounds down to the table's bucket boundary.
|
||||
adjustedStart := start - (start % time.Hour.Milliseconds())
|
||||
|
||||
t.Run("equality name and label matchers", func(t *testing.T) {
|
||||
query, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total"),
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"SELECT fingerprint, any(labels) FROM signoz_metrics.distributed_time_series_v4 WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified'] AND unix_milli >= ? AND unix_milli <= ? AND JSONExtractString(labels, ?) = ? GROUP BY fingerprint",
|
||||
query,
|
||||
)
|
||||
assert.Equal(t, []any{"http_requests_total", adjustedStart, end, "job", "api"}, args)
|
||||
})
|
||||
|
||||
t.Run("regex matchers are anchored", func(t *testing.T) {
|
||||
_, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "up"),
|
||||
mustMatcher(t, labels.MatchRegexp, "instance", "prod.*"),
|
||||
mustMatcher(t, labels.MatchNotRegexp, "env", "dev|test"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{"up", adjustedStart, end, "instance", "^(?:prod.*)$", "env", "^(?:dev|test)$"}, args)
|
||||
})
|
||||
|
||||
t.Run("regex name matcher uses metric_name column", func(t *testing.T) {
|
||||
query, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchRegexp, "__name__", "node_cpu.*|node_memory.*"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "match(metric_name, ?)")
|
||||
assert.NotContains(t, query, "JSONExtractString")
|
||||
assert.Equal(t, []any{"^(?:node_cpu.*|node_memory.*)$", adjustedStart, end}, args)
|
||||
})
|
||||
|
||||
t.Run("no name matcher omits metric_name condition", func(t *testing.T) {
|
||||
query, _, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, query, "metric_name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSamplesQuery(t *testing.T) {
|
||||
start := int64(1_700_000_000_000)
|
||||
end := start + time.Hour.Milliseconds()
|
||||
adjustedStart := start - (start % time.Hour.Milliseconds())
|
||||
matchers := []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "up"),
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
}
|
||||
|
||||
t.Run("raw fetch filters by a shard-local semi-join", func(t *testing.T) {
|
||||
query, args, err := buildSamplesQuery(start, end, []string{"up"}, matchers, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "fingerprint IN (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE ")
|
||||
assert.NotContains(t, query, "GLOBAL IN")
|
||||
assert.Contains(t, query, "ORDER BY fingerprint, unix_milli")
|
||||
// Args follow placeholder order: samples metric name, the semi-join's
|
||||
// series predicates, then the samples window bounds.
|
||||
assert.Equal(t, []any{"up", "up", adjustedStart, end, "job", "api", start, end}, args)
|
||||
})
|
||||
|
||||
t.Run("last-sample-per-step groups by step bucket anchored at first eval", func(t *testing.T) {
|
||||
lastPerStep := &lastSamplePerStep{firstEvalMs: start + 299_999, stepMs: 60_000}
|
||||
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "argMax(value, unix_milli) AS val")
|
||||
assert.Contains(t, query, "argMax(flags, unix_milli) AS fl")
|
||||
assert.Contains(t, query, "GROUP BY fingerprint, if(unix_milli <= 1700000299999, 0, intDiv(unix_milli - 1700000299999 - 1, 60000) + 1)")
|
||||
assert.Contains(t, query, "ORDER BY fingerprint, ts")
|
||||
// Aliases must not shadow the source columns referenced in WHERE.
|
||||
assert.NotContains(t, query, "AS unix_milli")
|
||||
assert.NotContains(t, query, "AS value")
|
||||
assert.NotContains(t, query, "AS flags")
|
||||
})
|
||||
|
||||
t.Run("instant query keeps one bucket", func(t *testing.T) {
|
||||
lastPerStep := &lastSamplePerStep{firstEvalMs: end, stepMs: 0}
|
||||
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "GROUP BY fingerprint ORDER BY fingerprint, ts")
|
||||
assert.NotContains(t, query, "intDiv")
|
||||
})
|
||||
|
||||
t.Run("multiple metric names from regex selector", func(t *testing.T) {
|
||||
query, args, err := buildSamplesQuery(start, end, []string{"node_cpu", "node_memory"}, matchers, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "metric_name IN (?, ?)")
|
||||
assert.Equal(t, []any{"node_cpu", "node_memory", "up", adjustedStart, end, "job", "api", start, end}, args)
|
||||
})
|
||||
}
|
||||