Compare commits

...

30 Commits

Author SHA1 Message Date
Nikhil Soni
4c9c99403d style(savedview): trim comments per comments.md 2026-08-10 19:27:33 +05:30
Nikhil Soni
aa7f0f7a02 refactor(savedview): make requestType an explicit validation input
SavedViewSpec.Validate() guessed the qbtypes.RequestType (and thus
which aggregation rules apply) from PanelType. That guess is a second,
independently-maintained copy of the frontend's own
mapPanelTypeToRequestType(), and any drift between them fails silently
-- which is exactly how the previous bug (list-panel queries wrongly
required an aggregation) went uncaught.

RequestType is fully derivable from PanelType and never read back for
anything after a view is saved (the frontend always re-derives it from
PanelType when it re-runs a query), so it doesn't belong inside the
persisted SavedViewSpec. Instead:

- PostableSavedView/UpdatableSavedView gain a required, non-persisted
  RequestType field -- the v2 API must send it explicitly rather than
  have the server guess.
- SavedViewSpec.Validate() takes requestType as a parameter instead of
  deriving it internally.
- The v1 legacy API has no requestType concept of its own, so its
  conversion functions keep computing it from panelType via the new
  LegacyRequestTypeForPanelType, scoped to that legacy boundary only.
2026-08-10 19:22:55 +05:30
Nikhil Soni
819bbeeb73 fix(savedview): allow list/trace panel queries without aggregations
SavedViewSpec.Validate() always required at least one aggregation on
every query, but list/trace panels are raw (non-aggregated) queries by
design -- this broke the v1 legacy update API for any saved list/trace
view converted from the explorer, which never carries aggregations.
Map panel type to the corresponding qbtypes.RequestType and reuse
GetValidationOptions, mirroring how dashboards already validate
per-query-kind.
2026-08-10 16:23:29 +05:30
Nikhil Soni
b69c696e6c fix(sqlmigration): repair remaining migration-111 gaps
- Remove dead code: the specFieldZeroValueJSON !known branch was
  unreachable, and the slice-model Scan's sql.ErrNoRows check never
  fired.
- Mirror telemetryFieldKey's full JSON-visible shape instead of a
  minimal Name-only copy, so extra/malformed fields are actually
  caught during repair.
- Treat a JSON null for queries/selectedFields as unclean so it gets
  blanked to [] and the row is repaired, not deleted.
- Normalize a nil Queries to [] in StorableSavedView.ToSavedView(),
  matching the existing SelectedFields normalization, so the API
  response never returns "queries": null against a nullable:false
  schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-10 15:08:55 +05:30
Nikhil Soni
08eff15a62 test(savedview): move store coverage into module test
Drop the real-sqlite store_test.go and cover the duplicate-name
conflict path (409) through the module test's existing sqlmock-based
store instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-10 14:35:01 +05:30
Nikhil Soni
b9e3ddca04 chore(sqlmigration): drop migration 111 test file from the PR
Verification-only: kept locally during development to prove the
repair/delete logic against a real sqlite db, but once this migration
ships there's no expected future change to it, so it isn't carried
into the merged history. Recoverable from earlier commits on this
branch if ever needed again.
2026-08-10 12:49:45 +05:30
Nikhil Soni
81abe54e1e fix(sqlmigration): delete unrepairable saved views instead of placeholder
An unrepairable row already 500s on every read today, so replacing it
with a fake "corrupted saved view" row preserves nothing usable while
showing up in ListSavedViews looking like something the user created.
Delete the row outright instead, logging the raw data first so it's
recoverable from logs if needed.

Also fix Up()'s pre-check: it used a loose json.Unmarshal(row.Data, new(fixData))
to decide whether a row needs repair, but Go's decoder accepts the exact
legacy {"query":"select 1"} shape into queryEnvelope as a zero-valued
{Type:"", Spec:nil} without erroring -- silently skipping repair for the
one case this migration exists to fix. Now uses repairSavedViewData's
strict, field-by-field check for that decision too.
2026-08-10 12:42:51 +05:30
Nikhil Soni
7fafcbfbd8 chore: remove unnecessary comments 2026-08-10 12:22:04 +05:30
Nikhil Soni
c3def283f3 refactor(sqlmigration): decouple migration 111 from live types packages
Per review: migration 111 imported savedviewtypes/qbtypes/telemetrytypes
to check whether a row's spec fields unmarshal cleanly and to build the
placeholder. That ties an already-shipped migration's behavior to
whatever those types look like today -- if any of them changes shape
later, this migration's repair logic silently changes behavior right
along with it, for rows that already went through it or will go
through it on a fresh install.

Duplicated the minimal shapes actually needed, local to this file:
queryEnvelope/knownQueryTypes (mirrors qbtypes.QueryEnvelope's type+spec
discriminator), telemetryFieldKey (mirrors telemetrytypes.TelemetryFieldKey
closely enough to reject the old bare-string shape), fixDisplay/fixSpec/
fixData (mirror savedviewtypes.Display/SavedViewSpec/SavedViewData's
on-disk shape for the "does this unmarshal cleanly" check and the
placeholder). None of these are exported or referenced outside this
file. Test file updated to match -- assertions now go through the local
fixData shape instead of importing savedviewtypes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-10 12:01:38 +05:30
Nikhil Soni
2246451b5e revert(sqlmigration): restore migration 109 to its original form
Per review: migrations that have already shipped shouldn't be edited
after the fact -- doing so changes what already ran in production
without anyone re-running it, and the fix for old rows belongs in a
follow-up migration instead (111 already exists for exactly this).
Restores legacySavedViewExtraData/savedViewSpec's SelectColumns/
SelectedFields to their original json.RawMessage passthrough and drops
the telemetrytypes import and the added warning log, matching main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-10 12:01:22 +05:30
Nikhil Soni
bfade5b070 test(savedview): prove the 409 conflict path against a real sqlite db
CreateSavedView declares 409 in its OpenAPI schema, and store.Create
wraps duplicate-name errors via WrapAlreadyExistsErrf, but nothing
actually exercised that path -- there was no test anywhere (unit or
integration) that created two saved views with the same name and
checked the result. sqlmock can't prove this either, since it just
returns whatever error you tell it to.

Spins up a real, temp-file sqlite db (mirroring impltag/store_test.go's
pattern) with the actual saved_view table and the same UNIQUE(org_id,
name) index migration 109 creates in production, then asserts a
genuine constraint violation gets classified as errors.TypeAlreadyExists
-- which is what actually makes the 409 true, not just documented.
Also guards that the uniqueness is scoped to (org_id, name), not name
alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 15:11:30 +05:30
Nikhil Soni
377eb89f85 test(sqlmigration): cover migration 111's malformed-data repair logic
Migration 111 (and every sqlmigration in this repo) had zero unit test
coverage. Reviewing the PR end to end, this was the one substantive gap:
the repair logic that's supposed to fix real corrupted saved_view rows
was never actually exercised against a realistic malformed payload.

repairSavedViewData/specFieldUnmarshalsCleanly/placeholderSavedViewData
are pure functions (no DB needed), so this tests them directly with the
actual real-world corruption shapes:
- selectedFields as bare strings (["service.name"]) -- the shape
  migration 109 forwarded verbatim before selectedFields was typed as
  []telemetrytypes.TelemetryFieldKey.
- queries missing the "type" discriminator -- predates the current
  QueryEnvelope discriminated union.
- multiple corrupted fields in the same row, blanked independently.
- data/spec that aren't JSON objects at all (unrepairable -- must fall
  through to the placeholder, never left broken).
- an unknown/future spec key is left untouched regardless of shape.

Also verifies placeholderSavedViewData's output survives the full
StorableSavedView.ToSavedView() read path without panicking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 15:11:15 +05:30
Nikhil Soni
228a944956 refactor(savedview): move the last storable-to-domain conversions into types
toSavedViews (a []*StorableSavedView -> []*SavedView mapper) and
NewStatsFromSavedViews (which required a fully domain-converted slice
just to read Source off it) were the last conversion-shaped helpers
still living in implsavedview/module.go instead of the types package.

Mirrored dashboardtypes' shape exactly:
- NewSavedViewsFromStorableSavedViews replaces toSavedViews.
- NewStatsFromStorableSavedViews replaces NewStatsFromSavedViews,
  reading Source directly off StorableSavedView (a top-level field on
  the row already) instead of requiring a full ToSavedView() round trip
  per row just to compute a count -- same optimization as
  dashboardtypes.NewStatsFromStorableDashboards.

module.go now has no conversion logic of its own left; every
domain<->Storable conversion is a call into savedviewtypes. The legacy
v1 bridge functions in handler.go (newPostableSavedViewFromLegacyView
and friends) are deliberately excluded per the earlier decision to keep
that conversion in the handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 14:39:55 +05:30
Nikhil Soni
ad15653789 refactor(savedview): move selectedFields normalization into ToSavedView
normalizeSelectedFields lived in implsavedview/module.go, called
separately after every storable.ToSavedView() -- an easy step to forget
at a new call site, and a type conversion detail living outside the
types package alongside every other domain<->Storable conversion.
Folded it directly into StorableSavedView.ToSavedView() so nil
SelectedFields is fixed up as part of the conversion itself; module.go
no longer needs to know this normalization exists.

Added TestStorableSavedView_ToSavedView covering the round trip and the
nil-normalization case directly in the types package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 14:22:55 +05:30
Nikhil Soni
450685ba3e refactor(savedview): store interface takes StorableSavedView, not the domain type
Checked how dashboard (pkg/types/dashboardtypes/store.go) and rules
(pkg/types/ruletypes/rule.go, RuleStore) do this: both have their Store
interface take/return the Storable type directly, with the domain<->
Storable conversion happening in the module/manager layer before/after
calling the store. savedview had it backwards -- Store took the domain
SavedView and implsavedview/store.go converted internally via
NewStorableSavedView right before the bun call.

Moved the conversion to module.go: CreateView/UpdateView now build the
domain SavedView (unchanged) and explicitly convert it before calling
store.Create/Update; GetView and the List-backed paths convert the
returned StorableSavedView(s) back via ToSavedView(), with
normalizeSelectedFields applied there instead of inside the store.
store.go now only deals in StorableSavedView, matching Create/Get/
Update/List against dashboard and rules' shape.

No test changes needed -- savedviewtypestest's mock helpers already
operate at the SQL/row level (via NewStorableSavedView(view).Data to
build mock row bytes), not through the Store interface's parameter
types directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 14:10:17 +05:30
Nikhil Soni
6372af75a6 revert(savedview): don't fail v1 requests on malformed extraData
newPostableSavedViewFromLegacyView/newUpdatableSavedViewFromLegacyView
were changed earlier this PR to reject the request outright when
extraData failed to unmarshal. That's a behavior change for the live v1
API that wasn't asked for -- extraData is frontend-owned, best-effort
data (color/selectColumns/format/maxLines/fontSize), not something a v1
caller should get a 400 for. Restored the original best-effort handling
(malformed/older extraData shapes are ignored, not an error), matching
how migration 109 already treats the same shape. The genuinely new
validation -- postable.Validate()/updatable.Validate() catching a
converted payload with zero real queries -- is unrelated and unchanged.

Reverted the corresponding test cases in handler_test.go to their
earlier assertions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 13:45:00 +05:30
Nikhil Soni
dc86f88bf7 ci: wire the savedview suite into the integration test matrix
tests/integration/tests/savedview/ has existed since #12342 but was never
added to the CI matrix, so it has never actually run in CI -- which is how
its request/response shapes were able to drift out of sync with the API
without anything failing (see the preceding fix(tests) commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 11:20:28 +05:30
Nikhil Soni
c4eff4edcd test(savedview): cover empty, null, and partial selectedFields/display
Adds integration coverage for the create/update edge cases discussed in
review: selectedFields and display are both optional, and each of
display's four fields is independently optional too.

- omitted/explicit-null/explicit-empty selectedFields and display on
  create all read back as the zero-value list/object, never null or a
  400 (test_display_omitted_on_create_reads_back_as_zero_value,
  test_selected_fields_and_display_explicit_null_on_create -- the
  selectedFields-only version of these already existed).
- a partial display (only "color" set) on create is accepted, with the
  unset fields defaulting to their own zero value rather than being
  rejected (test_create_with_partial_display_defaults_missing_fields).
- update is a whole-object replace, not a merge: sending only "color" on
  an update to a previously fully-populated display resets
  fontSize/format/maxLines to zero rather than preserving them
  (test_update_with_partial_display_replaces_whole_object) -- documents
  real, verified behavior a caller could otherwise assume is a merge.

All verified against a live build, including a clean (non---reuse) run
matching the CI invocation: 33 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 11:19:50 +05:30
Nikhil Soni
b9069afb4d fix(tests): update savedview integration tests to the unwrapped wire shape
The savedview request/response payloads dropped the "data" wrapper
(schemaVersion/spec promoted to the top level) in an earlier commit, but
the integration suite -- and the shared create_saved_view fixture -- were
never updated to match. Every create/update request still nested
schemaVersion/spec under "data", which the real API now rejects as an
unknown field, and every response assertion read spec back through a
now-nonexistent extra "data" level. The savedview suite isn't wired into
the integration CI matrix yet, so this had been silently broken.

Verified against a live build (make py-test-setup + pytest
integration/tests/savedview/): all 29 existing tests pass with the
request/response shapes corrected; they were failing before this fix
(20/29 failed with "unknown field \"data\"" or a masked validation
error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 11:19:18 +05:30
Nikhil Soni
40a036f2b0 chore(savedview): regenerate openapi spec and frontend api client
Reflects the SchemaVersion typed-enum change: schemaVersion is now a
$ref to its own SavedviewtypesSchemaVersion component instead of an
inline enum on each type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 10:50:13 +05:30
Nikhil Soni
d752452d40 fix(savedview): typed schemaVersion enum, close legacy zero-query gap
Three follow-ups from reviewing the earlier schemaVersion/optional-field
changes:

- Replace the jsonschema.Preparer hack + SavedViewMetadataBase wrapper for
  schemaVersion with a dedicated SchemaVersion type (mirrors PanelType /
  Source: valuer.String + Enum() + Validate()). Unlike rules, savedviews
  only ever has one valid value, so there's no accept/publish asymmetry
  that would justify hand-mutating the schema -- the plain enum type
  publishes and validates the same single value. SchemaVersion is now a
  direct field on SavedView/PostableSavedView/UpdatableSavedView instead
  of being embedded via SavedViewMetadataBase.
- Add test coverage proving selectedFields/display becoming non-required
  is safe: omitted, null, and empty ([]/{}) all decode and validate
  cleanly, and partially-populated display is unaffected.
- Fix a real validation gap in the v1 handlers: newPostableSavedViewFromLegacyView
  / newUpdatableSavedViewFromLegacyView convert a legacy v3.SavedView into
  the typed postable/updatable, but a legacy payload with a present-but-
  empty query map (e.g. "builderQueries": {}) passes v3.CompositeQuery.Validate()
  despite having zero actual queries. Create/Update now also call
  postable.Validate() / updatable.Validate() on the converted payload,
  which correctly rejects it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 10:48:44 +05:30
Nikhil Soni
4a8a1efb03 fix(sqlmigration): generalize migration 111 repair to every spec field
The old repair only special-cased selectedFields, so any other unmarshal
failure (e.g. an incompatible queries shape) still left the row broken --
every future Get/List of that saved view would 500. Now every spec key
that fails to unmarshal into its real type is individually blanked to its
zero value, and rows that still don't unmarshal cleanly afterward are
replaced with a guaranteed-valid placeholder view instead of being left
corrupted in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-09 10:48:06 +05:30
Nikhil Soni
7d2afa5a54 chore(savedview): regenerate openapi spec and frontend api client
Reflects the five savedview schema fixes: unwrapped data, schemaVersion
enum, optional selectedFields/display, 409 on create, minItems on queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-08 17:57:28 +05:30
Nikhil Soni
b579190140 fix(savedview): require at least one query
The server already rejects an empty queries list (CompositeQuery.Validate),
the schema just didn't say so. Mirrors #12112, which added the same minItems
constraint to the rules equivalent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-08 17:56:16 +05:30
Nikhil Soni
6222204af7 fix(savedview): declare 409 on CreateSavedView
Names are org-unique and the server returns 409 on a collision; the
OpenAPI operation just didn't say so. Eleven other create endpoints already
declare it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-08 17:55:49 +05:30
Nikhil Soni
0fcd6aeada fix(savedview): selectedFields and display are not required
Neither field is actually validated server-side (SavedViewSpec.Validate
never checks them), so marking them required in the schema over-constrains
callers for no enforced reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-08 17:55:29 +05:30
Nikhil Soni
32af113d0c fix(savedview): restrict published schemaVersion to enum [v2]
schemaVersion was published as a bare string, letting a client send anything.
Mirrors ruletypes.PostableRule's own schemaVersion override (see #12112) --
v2 here, not v2alpha1, since that's the rules value. Attached to
SavedViewMetadataBase so it applies to SavedView, PostableSavedView and
UpdatableSavedView through method promotion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-08 17:54:56 +05:30
Nikhil Soni
61588b9303 refactor(savedview): unwrap data, promote schemaVersion/spec to top level
Matches dashboardtypes.DashboardV2 and the v2alpha1 rule shape: schemaVersion
and spec are now top-level fields on SavedView/PostableSavedView/
UpdatableSavedView instead of nested under a data object. StorableSavedView
is introduced as the distinct bun-mapped row shape -- bun maps a single
opaque `data` column, which is incompatible with promoting its fields to the
top level for JSON, so the two diverge the same way Dashboard/StorableDashboard
do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnYR48DXJQGxqQSEW3237h
2026-08-08 17:54:36 +05:30
Nikhil Soni
dbc40efe24 chore: add migration to fix the already migrated data 2026-08-08 17:28:02 +05:30
Nikhil Soni
33a428496b fix: handle malformed selectField data and throw error in v1 apis 2026-08-08 17:05:53 +05:30
20 changed files with 1297 additions and 491 deletions

View File

@@ -61,6 +61,7 @@ jobs:
- querierauthz
- role
- rootuser
- savedview
- serviceaccount
- spanmapper
- querier_json_body

View File

@@ -7880,17 +7880,23 @@ components:
type: string
SavedviewtypesPostableSavedView:
properties:
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
generateName:
type: boolean
name:
type: string
requestType:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- source
- data
- schemaVersion
- requestType
- spec
type: object
SavedviewtypesSavedView:
properties:
@@ -7899,14 +7905,16 @@ components:
type: string
createdBy:
type: string
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
id:
type: string
name:
type: string
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
updatedAt:
format: date-time
type: string
@@ -7914,14 +7922,6 @@ components:
type: string
required:
- id
type: object
SavedviewtypesSavedViewData:
properties:
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- schemaVersion
- spec
type: object
@@ -7936,6 +7936,7 @@ components:
queries:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
minItems: 1
type: array
selectedFields:
items:
@@ -7945,9 +7946,11 @@ components:
- displayName
- panelType
- queries
- selectedFields
- display
type: object
SavedviewtypesSchemaVersion:
enum:
- v2
type: string
SavedviewtypesSource:
enum:
- traces
@@ -7957,13 +7960,19 @@ components:
type: string
SavedviewtypesUpdatableSavedView:
properties:
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
requestType:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- source
- data
- schemaVersion
- requestType
- spec
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
@@ -22972,6 +22981,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:

View File

@@ -8991,8 +8991,17 @@ export enum SavedviewtypesPanelTypeDTO {
list = 'list',
trace = 'trace',
}
export enum SavedviewtypesSchemaVersionDTO {
v2 = 'v2',
}
export enum SavedviewtypesSourceDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
}
export interface SavedviewtypesSavedViewSpecDTO {
display: SavedviewtypesDisplayDTO;
display?: SavedviewtypesDisplayDTO;
/**
* @type string
*/
@@ -9005,25 +9014,10 @@ export interface SavedviewtypesSavedViewSpecDTO {
/**
* @type array
*/
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
selectedFields?: TelemetrytypesTelemetryFieldKeyDTO[];
}
export interface SavedviewtypesSavedViewDataDTO {
/**
* @type string
*/
schemaVersion: string;
spec: SavedviewtypesSavedViewSpecDTO;
}
export enum SavedviewtypesSourceDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
}
export interface SavedviewtypesPostableSavedViewDTO {
data: SavedviewtypesSavedViewDataDTO;
/**
* @type boolean
*/
@@ -9032,7 +9026,10 @@ export interface SavedviewtypesPostableSavedViewDTO {
* @type string
*/
name?: string;
requestType: Querybuildertypesv5RequestTypeDTO;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface SavedviewtypesSavedViewDTO {
@@ -9045,7 +9042,6 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
createdBy?: string;
data?: SavedviewtypesSavedViewDataDTO;
/**
* @type string
*/
@@ -9054,7 +9050,9 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
name?: string;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source?: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
/**
* @type string
* @format date-time
@@ -9067,8 +9065,10 @@ export interface SavedviewtypesSavedViewDTO {
}
export interface SavedviewtypesUpdatableSavedViewDTO {
data: SavedviewtypesSavedViewDataDTO;
requestType: Querybuildertypesv5RequestTypeDTO;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {

View File

@@ -51,7 +51,7 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSavedView.Scope(coretypes.VerbCreate)}),
},

View File

@@ -39,26 +39,28 @@ type legacyExtraData struct {
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
var legacy legacyExtraData
if v.ExtraData != "" {
// Best-effort: malformed/older extraData shapes never fail the request
// Best-effort: malformed/older extraData shapes never fail the request.
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
}
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
return savedviewtypes.PostableSavedView{
GenerateName: true,
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
GenerateName: true,
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: panelType,
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
},
}
@@ -68,25 +70,27 @@ func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Postable
func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.UpdatableSavedView {
var legacy legacyExtraData
if v.ExtraData != "" {
// Best-effort: malformed/older extraData shapes never fail the request
// Best-effort: malformed/older extraData shapes never fail the request.
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
}
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
return savedviewtypes.UpdatableSavedView{
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: panelType,
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
},
}
@@ -95,11 +99,11 @@ func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Updatab
// newLegacyViewFromSavedView renders a v2 SavedView back into the v1 shape.
func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, error) {
extraData, err := json.Marshal(legacyExtraData{
Color: v.Data.Spec.Display.Color,
SelectColumns: v.Data.Spec.SelectedFields,
Format: v.Data.Spec.Display.Format,
MaxLines: v.Data.Spec.Display.MaxLines,
FontSize: v.Data.Spec.Display.FontSize,
Color: v.Spec.Display.Color,
SelectColumns: v.Spec.SelectedFields,
Format: v.Spec.Display.Format,
MaxLines: v.Spec.Display.MaxLines,
FontSize: v.Spec.Display.FontSize,
})
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
@@ -107,17 +111,17 @@ func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, err
return &v3.SavedView{
ID: v.ID,
Name: v.Data.Spec.DisplayName,
Name: v.Spec.DisplayName,
CreatedAt: v.CreatedAt,
CreatedBy: v.CreatedBy,
UpdatedAt: v.UpdatedAt,
UpdatedBy: v.UpdatedBy,
SourcePage: v.Source.StringValue(),
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelType(v.Data.Spec.PanelType.StringValue()),
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
// Saved views are only ever created from the explorer's builder mode.
QueryType: v3.QueryTypeBuilder,
Queries: v.Data.Spec.Queries,
Queries: v.Spec.Queries,
},
ExtraData: string(extraData),
}, nil
@@ -156,7 +160,14 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
postable := newPostableSavedViewFromLegacyView(&view)
if err := postable.Validate(); err != nil {
render.Error(w, err)
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, postable)
if err != nil {
render.Error(w, err)
return
@@ -224,8 +235,14 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
return
}
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newUpdatableSavedViewFromLegacyView(&view))
if err != nil {
updatable := newUpdatableSavedViewFromLegacyView(&view)
if err := updatable.Validate(); err != nil {
render.Error(w, err)
return
}
if err := handler.module.UpdateView(ctx, claims.OrgID, viewUUID, updatable); err != nil {
render.Error(w, err)
return
}

View File

@@ -42,13 +42,14 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
assert.Empty(t, postable.Name, "v1 has no slug concept -- name must always be generated")
assert.True(t, postable.GenerateName, "v1 has no slug concept -- name must always be generated")
assert.Equal(t, "my view", postable.Data.Spec.DisplayName)
assert.Equal(t, "my view", postable.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceLogs, postable.Source)
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.Data.SchemaVersion)
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Data.Spec.PanelType)
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Data.Spec.Queries)
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Data.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Data.Spec.Display)
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.SchemaVersion)
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Spec.PanelType)
assert.Equal(t, qbtypes.RequestTypeTimeSeries, postable.RequestType, "graph panel type must map to the time_series request type")
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Spec.Queries)
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Spec.Display)
})
t.Run("empty extra data leaves display and selected fields zero-valued", func(t *testing.T) {
@@ -64,8 +65,8 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
assert.Nil(t, postable.Data.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
assert.Nil(t, postable.Spec.SelectedFields)
})
t.Run("malformed extra data is ignored, not an error", func(t *testing.T) {
@@ -81,8 +82,47 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, "malformed extra data", postable.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
assert.Equal(t, "malformed extra data", postable.Spec.DisplayName)
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
})
t.Run("legacy validation gap: empty builderQueries map with no queries", func(t *testing.T) {
legacy := &v3.SavedView{
Name: "no real queries",
SourcePage: "logs",
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelTypeGraph,
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{},
},
}
require.NoError(t, legacy.Validate(), "the legacy CompositeQuery check is expected to miss this")
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Error(t, postable.Validate(), "the converted postable must catch what the legacy check missed")
})
t.Run("list panel query with no aggregation is valid", func(t *testing.T) {
legacy := &v3.SavedView{
Name: "raw list view",
SourcePage: "traces",
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelTypeList,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "db_name = 'two'"},
},
}},
},
}
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, qbtypes.RequestTypeRaw, postable.RequestType, "list panel type must map to the raw request type")
assert.NoError(t, postable.Validate(), "a raw list query must not be required to carry an aggregation")
})
}
@@ -99,24 +139,23 @@ func TestNewUpdatableSavedViewFromLegacyView(t *testing.T) {
updatable := newUpdatableSavedViewFromLegacyView(legacy)
assert.Equal(t, "renamed view", updatable.Data.Spec.DisplayName)
assert.Equal(t, "renamed view", updatable.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceTraces, updatable.Source)
assert.Equal(t, qbtypes.RequestTypeScalar, updatable.RequestType, "table panel type must map to the scalar request type")
}
func TestNewLegacyViewFromSavedView(t *testing.T) {
now := time.Now()
savedView := &savedviewtypes.SavedView{
Name: "my-view-abc123ef",
Source: savedviewtypes.SourceLogs,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "my view",
PanelType: savedviewtypes.PanelTypeGraph,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
},
Name: "my-view-abc123ef",
Source: savedviewtypes.SourceLogs,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "my view",
PanelType: savedviewtypes.PanelTypeGraph,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
},
}
savedView.ID = valuer.GenerateUUID()
@@ -129,7 +168,7 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, savedView.ID, legacy.ID)
assert.Equal(t, savedView.Data.Spec.DisplayName, legacy.Name)
assert.Equal(t, savedView.Spec.DisplayName, legacy.Name)
assert.Equal(t, savedView.CreatedAt, legacy.CreatedAt)
assert.Equal(t, savedView.CreatedBy, legacy.CreatedBy)
assert.Equal(t, savedView.UpdatedAt, legacy.UpdatedAt)
@@ -137,20 +176,20 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
assert.Equal(t, "logs", legacy.SourcePage)
assert.Equal(t, v3.PanelTypeGraph, legacy.CompositeQuery.PanelType)
assert.Equal(t, v3.QueryTypeBuilder, legacy.CompositeQuery.QueryType)
assert.Equal(t, savedView.Data.Spec.Queries, legacy.CompositeQuery.Queries)
assert.Equal(t, savedView.Spec.Queries, legacy.CompositeQuery.Queries)
var extra legacyExtraData
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
assert.Equal(t, "blue", extra.Color)
assert.Equal(t, savedView.Data.Spec.SelectedFields, extra.SelectColumns)
assert.Equal(t, savedView.Spec.SelectedFields, extra.SelectColumns)
assert.Equal(t, "table", extra.Format)
assert.Equal(t, 10, extra.MaxLines)
assert.Equal(t, "large", extra.FontSize)
}
func TestNewLegacyViewsFromSavedViews(t *testing.T) {
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}}
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}}
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}
legacyViews, err := newLegacyViewsFromSavedViews([]*savedviewtypes.SavedView{a, b})
require.NoError(t, err)
@@ -167,17 +206,15 @@ func TestNewLegacyViewsFromSavedViews(t *testing.T) {
// slug (Name) is deliberately NOT part of this contract -- v1 never sees it.
func TestLegacyViewRoundTrip(t *testing.T) {
original := &savedviewtypes.SavedView{
Name: "round-trip-abc123ef",
Source: savedviewtypes.SourceMetrics,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
},
Name: "round-trip-abc123ef",
Source: savedviewtypes.SourceMetrics,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
},
}
@@ -188,10 +225,37 @@ func TestLegacyViewRoundTrip(t *testing.T) {
assert.Empty(t, roundTripped.Name)
assert.True(t, roundTripped.GenerateName)
assert.Equal(t, original.Data.Spec.DisplayName, roundTripped.Data.Spec.DisplayName)
assert.Equal(t, original.Spec.DisplayName, roundTripped.Spec.DisplayName)
assert.Equal(t, original.Source, roundTripped.Source)
assert.Equal(t, original.Data.Spec.PanelType, roundTripped.Data.Spec.PanelType)
assert.Equal(t, original.Data.Spec.Queries, roundTripped.Data.Spec.Queries)
assert.Equal(t, original.Data.Spec.SelectedFields, roundTripped.Data.Spec.SelectedFields)
assert.Equal(t, original.Data.Spec.Display, roundTripped.Data.Spec.Display)
assert.Equal(t, original.Spec.PanelType, roundTripped.Spec.PanelType)
assert.Equal(t, original.Spec.Queries, roundTripped.Spec.Queries)
assert.Equal(t, original.Spec.SelectedFields, roundTripped.Spec.SelectedFields)
assert.Equal(t, original.Spec.Display, roundTripped.Spec.Display)
}
func TestLegacyViewRoundTrip_EmptySelectedFieldsAndDisplay(t *testing.T) {
original := &savedviewtypes.SavedView{
Name: "round-trip-empty-abc123ef",
Source: savedviewtypes.SourceMetrics,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip empty",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
Display: savedviewtypes.Display{},
},
}
legacy, err := newLegacyViewFromSavedView(original)
require.NoError(t, err)
var extra legacyExtraData
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
assert.Nil(t, extra.SelectColumns, "omitempty drops an empty selectColumns from extraData entirely")
roundTripped := newPostableSavedViewFromLegacyView(legacy)
assert.Empty(t, roundTripped.Spec.SelectedFields, "empty, not necessarily non-nil, on this leg of the round trip")
assert.Equal(t, savedviewtypes.Display{}, roundTripped.Spec.Display)
}

View File

@@ -19,7 +19,11 @@ func NewModule(store savedviewtypes.Store) savedview.Module {
}
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
return module.store.List(ctx, orgID, source, name)
storables, err := module.store.List(ctx, orgID, source, name)
if err != nil {
return nil, err
}
return savedviewtypes.NewSavedViewsFromStorableSavedViews(storables), nil
}
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
@@ -30,14 +34,19 @@ func (module *module) CreateView(ctx context.Context, orgID string, view savedvi
dbView := view.ToSavedView(orgID, claims.Email)
if err := module.store.Create(ctx, dbView); err != nil {
if err := module.store.Create(ctx, savedviewtypes.NewStorableSavedView(dbView)); err != nil {
return valuer.UUID{}, err
}
return dbView.ID, nil
}
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.SavedView, error) {
return module.store.Get(ctx, orgID, uuid)
storable, err := module.store.Get(ctx, orgID, uuid)
if err != nil {
return nil, err
}
return storable.ToSavedView(), nil
}
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
@@ -46,7 +55,8 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
}
return module.store.Update(ctx, view.ToSavedView(uuid, orgID, claims.Email))
dbView := view.ToSavedView(uuid, orgID, claims.Email)
return module.store.Update(ctx, savedviewtypes.NewStorableSavedView(dbView))
}
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
@@ -54,10 +64,10 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
savedViews, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
storables, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
if err != nil {
return nil, err
}
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
return savedviewtypes.NewStatsFromStorableSavedViews(storables), nil
}

View File

@@ -28,24 +28,22 @@ func newTestStore() (savedview.Module, *savedviewtypestest.StoreTest) {
func testPostableSavedView(name string, source savedviewtypes.Source) savedviewtypes.PostableSavedView {
return savedviewtypes.PostableSavedView{
Name: name,
Source: source,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: name,
PanelType: savedviewtypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
},
Name: name,
Source: source,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: name,
PanelType: savedviewtypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
}
}
@@ -53,8 +51,9 @@ func testPostableSavedView(name string, source savedviewtypes.Source) savedviewt
func testUpdatableSavedView(displayName string, source savedviewtypes.Source) savedviewtypes.UpdatableSavedView {
postable := testPostableSavedView(displayName, source)
return savedviewtypes.UpdatableSavedView{
Source: postable.Source,
Data: postable.Data,
Source: postable.Source,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
}
}
@@ -93,7 +92,22 @@ func TestModule_CreateAndGetView(t *testing.T) {
assert.Equal(t, savedviewtypes.SourceLogs, got.Source)
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Data.Spec.PanelType)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
require.NoError(t, st.AssertExpectations())
}
// A duplicate-name insert failure must surface as errors.TypeAlreadyExists, not a generic internal error.
func TestModule_CreateView_DuplicateNameIsConflict(t *testing.T) {
m, st := newTestStore()
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "creator@signoz.io")
st.ExpectCreateError(errors.Newf(errors.TypeInternal, errors.CodeInternal, "UNIQUE constraint failed: saved_view.org_id, saved_view.name"))
_, err := m.CreateView(ctx, orgID, testPostableSavedView("same-name", savedviewtypes.SourceLogs))
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeAlreadyExists), "expected an already-exists error, got %v", err)
require.NoError(t, st.AssertExpectations())
}
@@ -138,21 +152,21 @@ func TestModule_UpdateView(t *testing.T) {
existingName := existing.Name
updated := testUpdatableSavedView("renamed", savedviewtypes.SourceTraces)
updated.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
st.ExpectUpdate(orgID, id, 1)
require.NoError(t, m.UpdateView(contextWithClaims(orgID, "updater@signoz.io"), orgID, id, updated))
stored := testSavedView(orgID, id, "updater@signoz.io", testPostableSavedView("renamed", savedviewtypes.SourceTraces))
stored.Name = existingName
stored.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
stored.Spec.PanelType = savedviewtypes.PanelTypeTable
st.ExpectGet(orgID, id, stored)
got, err := m.GetView(contextWithClaims(orgID, "creator@signoz.io"), orgID, id)
require.NoError(t, err)
assert.Equal(t, existingName, got.Name, "name must not change on update")
assert.Equal(t, "renamed", got.Data.Spec.DisplayName)
assert.Equal(t, "renamed", got.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceTraces, got.Source)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Data.Spec.PanelType)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
require.NoError(t, st.AssertExpectations())

View File

@@ -6,7 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -18,32 +17,31 @@ func NewStore(sqlstore sqlstore.SQLStore) savedviewtypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(view).Exec(ctx)
func (store *store) Create(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(storable).Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", view.Name)
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", storable.Name)
}
return nil
}
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
var view savedviewtypes.SavedView
err := store.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.StorableSavedView, error) {
var storable savedviewtypes.StorableSavedView
err := store.sqlstore.BunDB().NewSelect().Model(&storable).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
}
normalizeSelectedFields(&view)
return &view, nil
return &storable, nil
}
func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
func (store *store) Update(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
res, err := store.sqlstore.BunDB().NewUpdate().
Model(&savedviewtypes.SavedView{}).
Model((*savedviewtypes.StorableSavedView)(nil)).
Set("updated_at = ?, updated_by = ?, source = ?, data = ?",
view.UpdatedAt, view.UpdatedBy, view.Source, view.Data).
Where("id = ?", view.ID.StringValue()).
Where("org_id = ?", view.OrgID).
storable.UpdatedAt, storable.UpdatedBy, storable.Source, storable.Data).
Where("id = ?", storable.ID.StringValue()).
Where("org_id = ?", storable.OrgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
@@ -54,7 +52,7 @@ func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView)
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
}
if rowsAffected == 0 {
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", view.ID.StringValue())
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", storable.ID.StringValue())
}
return nil
@@ -62,7 +60,7 @@ func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView)
func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
res, err := store.sqlstore.BunDB().NewDelete().
Model(&savedviewtypes.SavedView{}).
Model((*savedviewtypes.StorableSavedView)(nil)).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
@@ -81,9 +79,9 @@ func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) er
return nil
}
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
var views []*savedviewtypes.SavedView
q := store.sqlstore.BunDB().NewSelect().Model(&views).
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.StorableSavedView, error) {
var storables []*savedviewtypes.StorableSavedView
q := store.sqlstore.BunDB().NewSelect().Model(&storables).
Where("org_id = ?", orgID).
Where("name LIKE ?", "%"+name+"%")
if !source.IsZero() {
@@ -94,16 +92,5 @@ func (store *store) List(ctx context.Context, orgID string, source savedviewtype
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
}
for _, view := range views {
normalizeSelectedFields(view)
}
return views, nil
}
// normalizeSelectedFields fixes up a scanned row's nil SelectedFields.
func normalizeSelectedFields(view *savedviewtypes.SavedView) {
if view.Data.Spec.SelectedFields == nil {
view.Data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
}
return storables, nil
}

View File

@@ -237,6 +237,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore, sqlschema),
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
)
}

View File

@@ -0,0 +1,220 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
// knownQueryTypes mirrors the discriminator values qbtypes.QueryType currently defines.
var knownQueryTypes = map[string]bool{
"builder_query": true,
"builder_ai_query": true,
"builder_formula": true,
"builder_sub_query": true,
"builder_join": true,
"builder_trace_operator": true,
"clickhouse_sql": true,
"promql": true,
}
// specFieldZeroValueJSON is the JSON to substitute for a spec key that fails to unmarshal.
var specFieldZeroValueJSON = map[string]string{
"displayName": `""`,
"panelType": `""`,
"queries": `[]`,
"selectedFields": `[]`,
"display": `{}`,
}
// storableSavedViewData is the shape of the `saved_view` table this migration repairs.
type storableSavedViewData struct {
bun.BaseModel `bun:"table:saved_view"`
ID string `bun:"id,pk,type:text"`
Data string `bun:"data,type:text"`
}
// queryEnvelope mirrors minimal required qbtypes.QueryEnvelope.
type queryEnvelope struct {
Type string `json:"type"`
Spec json.RawMessage `json:"spec"`
}
// telemetryFieldKey mirrors telemetrytypes.TelemetryFieldKey's JSON-visible fields.
// Signal/FieldContext/FieldDataType are plain strings to test UnmarshalJSON.
type telemetryFieldKey struct {
Name string `json:"name"`
Description string `json:"description"`
Unit string `json:"unit"`
Signal string `json:"signal"`
FieldContext string `json:"fieldContext"`
FieldDataType string `json:"fieldDataType"`
}
// fixDisplay mirrors savedviewtypes.Display.
type fixDisplay struct {
MaxLines int `json:"maxLines"`
FontSize string `json:"fontSize"`
Format string `json:"format"`
Color string `json:"color"`
}
// fixSpec mirrors savedviewtypes.SavedViewSpec.
type fixSpec struct {
DisplayName string `json:"displayName"`
PanelType string `json:"panelType"`
Queries []queryEnvelope `json:"queries"`
SelectedFields []telemetryFieldKey `json:"selectedFields"`
Display fixDisplay `json:"display"`
}
// fixData mirrors savedviewtypes.SavedViewData.
type fixData struct {
SchemaVersion string `json:"schemaVersion"`
Spec fixSpec `json:"spec"`
}
type fixSavedViewSelectedFields struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewFixSavedViewSelectedFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("fix_saved_view_selected_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fixSavedViewSelectedFields{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *fixSavedViewSelectedFields) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *fixSavedViewSelectedFields) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableSavedViewData
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var repaired, deleted int
for _, row := range rows {
fixedData, blanked, ok := repairSavedViewData(row.Data)
if ok && len(blanked) == 0 {
// already scans cleanly field-by-field -- nothing to repair.
continue
}
if !ok {
migration.settings.Logger.WarnContext(ctx, "saved view data could not be repaired field-by-field, deleting the row", slog.String("saved_view_id", row.ID), slog.String("raw_data", row.Data))
if _, err := tx.NewDelete().Model((*storableSavedViewData)(nil)).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
deleted++
continue
}
repaired++
migration.settings.Logger.WarnContext(ctx, "repaired saved view data by blanking fields that failed to unmarshal", slog.String("saved_view_id", row.ID), slog.Any("fields_blanked", blanked))
if _, err := tx.NewUpdate().Model((*storableSavedViewData)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "checked saved views for unreadable data", slog.Int("total", len(rows)), slog.Int("repaired", repaired), slog.Int("deleted", deleted))
return tx.Commit()
}
func (migration *fixSavedViewSelectedFields) Down(context.Context, *bun.DB) error {
return nil
}
// specFieldUnmarshalsCleanly reports whether value can be unmarshalled into
// the expected shape of the given savedviewtypes.SavedViewSpec JSON key.
func specFieldUnmarshalsCleanly(key string, value json.RawMessage) bool {
switch key {
case "displayName", "panelType":
var s string
return json.Unmarshal(value, &s) == nil
case "queries":
var q []queryEnvelope
if err := json.Unmarshal(value, &q); err != nil {
return false
}
if q == nil {
// a JSON null unmarshals into a nil slice with no error; treat it as unclean so it
// gets blanked to [] rather than shipping "queries": null against a nullable:false schema.
return false
}
for _, e := range q {
if !knownQueryTypes[e.Type] || len(e.Spec) == 0 {
return false
}
}
return true
case "selectedFields":
var f []telemetryFieldKey
if err := json.Unmarshal(value, &f); err != nil {
return false
}
// same null-vs-[] gap as "queries" above: blank a JSON null to [] instead of leaving it.
return f != nil
case "display":
var d fixDisplay
return json.Unmarshal(value, &d) == nil
default:
return true
}
}
// repairSavedViewData tries to make data unmarshal cleanly by blanking, one key at a time,
// whichever top-level spec fields fail to unmarshal into their expected shape.
func repairSavedViewData(data string) (fixed string, blanked []string, ok bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "", nil, false
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return "", nil, false
}
for key, value := range spec {
if specFieldUnmarshalsCleanly(key, value) {
continue
}
spec[key] = json.RawMessage(specFieldZeroValueJSON[key])
blanked = append(blanked, key)
}
fixedSpec, err := json.Marshal(spec)
if err != nil {
return "", nil, false
}
raw["spec"] = fixedSpec
fixedData, err := json.Marshal(raw)
if err != nil {
return "", nil, false
}
// verify the fix actually round-trips before writing it.
if err := json.Unmarshal(fixedData, new(fixData)); err != nil {
return "", nil, false
}
return string(fixedData), blanked, true
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"k8s.io/apimachinery/pkg/util/validation"
@@ -28,27 +30,81 @@ var (
)
type SavedView struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"-"`
Name string `json:"name"`
Source Source `json:"source"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
type StorableSavedView struct {
bun.BaseModel `bun:"table:saved_view"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"-" bun:"org_id,notnull"`
Name string `json:"name" bun:"name,type:text,notnull"`
Source Source `json:"source" bun:"source,type:text,notnull"`
Data SavedViewData `json:"data" bun:"data,type:text,notnull"`
OrgID string `bun:"org_id,notnull"`
Name string `bun:"name,type:text,notnull"`
Source Source `bun:"source,type:text,notnull"`
Data SavedViewData `bun:"data,type:text,notnull"`
}
func (s *StorableSavedView) ToSavedView() *SavedView {
spec := s.Data.Spec
if spec.Queries == nil {
spec.Queries = []qbtypes.QueryEnvelope{}
}
if spec.SelectedFields == nil {
spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
}
return &SavedView{
Identifiable: s.Identifiable,
TimeAuditable: s.TimeAuditable,
UserAuditable: s.UserAuditable,
OrgID: s.OrgID,
Name: s.Name,
Source: s.Source,
SchemaVersion: SchemaVersion{valuer.NewString(s.Data.SchemaVersion)},
Spec: spec,
}
}
func NewStorableSavedView(view *SavedView) *StorableSavedView {
return &StorableSavedView{
Identifiable: view.Identifiable,
TimeAuditable: view.TimeAuditable,
UserAuditable: view.UserAuditable,
OrgID: view.OrgID,
Name: view.Name,
Source: view.Source,
Data: SavedViewData{
SchemaVersion: view.SchemaVersion.StringValue(),
Spec: view.Spec,
},
}
}
// RequestType selects which query-validation rules apply (see SavedViewSpec.Validate);
// it is not persisted and must not be added to SavedViewSpec.
type PostableSavedView struct {
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Source Source `json:"source" required:"true"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
RequestType qbtypes.RequestType `json:"requestType" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
// RequestType has the same meaning as PostableSavedView.RequestType.
type UpdatableSavedView struct {
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
Source Source `json:"source" required:"true"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
RequestType qbtypes.RequestType `json:"requestType" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
type ListSavedViewsParams struct {
@@ -83,7 +139,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
name := postable.Name
if postable.GenerateName {
name = generateSavedViewName(postable.Data.Spec.DisplayName)
name = generateSavedViewName(postable.Spec.DisplayName)
}
return &SavedView{
@@ -93,7 +149,8 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
OrgID: orgID,
Name: name,
Source: postable.Source,
Data: postable.Data,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
}
}
@@ -106,7 +163,8 @@ func (updatable UpdatableSavedView) ToSavedView(id valuer.UUID, orgID string, up
UserAuditable: types.UserAuditable{UpdatedBy: updatedBy},
OrgID: orgID,
Source: updatable.Source,
Data: updatable.Data,
SchemaVersion: updatable.SchemaVersion,
Spec: updatable.Spec,
}
}
@@ -117,8 +175,14 @@ func (p *PostableSavedView) Validate() error {
if err := p.Source.Validate(); err != nil {
return err
}
if err := p.SchemaVersion.Validate(); err != nil {
return err
}
if p.RequestType.IsZero() {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "requestType is required")
}
return p.Data.Validate()
return p.Spec.Validate(p.RequestType)
}
func (p *PostableSavedView) validateName() error {
@@ -135,8 +199,14 @@ func (u *UpdatableSavedView) Validate() error {
if err := u.Source.Validate(); err != nil {
return err
}
if err := u.SchemaVersion.Validate(); err != nil {
return err
}
if u.RequestType.IsZero() {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "requestType is required")
}
return u.Data.Validate()
return u.Spec.Validate(u.RequestType)
}
func (p *ListSavedViewsParams) Validate() error {
@@ -147,7 +217,17 @@ func (p *ListSavedViewsParams) Validate() error {
return p.Source.Validate()
}
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
// NewSavedViewsFromStorableSavedViews converts scanned rows to their domain shape.
func NewSavedViewsFromStorableSavedViews(storableSavedViews []*StorableSavedView) []*SavedView {
savedViews := make([]*SavedView, len(storableSavedViews))
for idx, storableSavedView := range storableSavedViews {
savedViews[idx] = storableSavedView.ToSavedView()
}
return savedViews
}
func NewStatsFromStorableSavedViews(savedViews []*StorableSavedView) map[string]any {
stats := make(map[string]any)
for _, savedView := range savedViews {
key := "savedview.source." + strings.ToLower(savedView.Source.StringValue()) + ".count"

View File

@@ -4,29 +4,30 @@ import (
"strings"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
)
func validPostableSavedView() PostableSavedView {
return PostableSavedView{
Name: "my-view",
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
Name: "my-view",
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
RequestType: qbtypes.RequestTypeTimeSeries,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
}
}
func validUpdatableSavedView() UpdatableSavedView {
return UpdatableSavedView{
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
RequestType: qbtypes.RequestTypeTimeSeries,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
}
}
@@ -69,7 +70,7 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("invalid saved view data is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Data.SchemaVersion = "v1"
view.SchemaVersion = SchemaVersion{valuer.NewString("v1")}
assert.Error(t, view.Validate())
})
@@ -100,9 +101,26 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Data.Spec.DisplayName = ""
view.Spec.DisplayName = ""
assert.ErrorContains(t, view.Validate(), "displayName is required")
})
t.Run("missing requestType is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.RequestType = qbtypes.RequestType{}
assert.ErrorContains(t, view.Validate(), "requestType is required")
})
t.Run("requestType is honored over the panelType-derived guess", func(t *testing.T) {
view := validPostableSavedView()
view.Spec.PanelType = PanelTypeList
view.Spec.Queries = []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{Signal: telemetrytypes.SignalTraces},
}}
view.RequestType = qbtypes.RequestTypeRaw
assert.NoError(t, view.Validate())
})
}
func TestUpdatableSavedViewValidate(t *testing.T) {
@@ -119,9 +137,15 @@ func TestUpdatableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validUpdatableSavedView()
view.Data.Spec.DisplayName = ""
view.Spec.DisplayName = ""
assert.ErrorContains(t, view.Validate(), "displayName is required")
})
t.Run("missing requestType is rejected", func(t *testing.T) {
view := validUpdatableSavedView()
view.RequestType = qbtypes.RequestType{}
assert.ErrorContains(t, view.Validate(), "requestType is required")
})
}
func TestListSavedViewsParamsValidate(t *testing.T) {
@@ -153,7 +177,8 @@ func TestNewSavedView(t *testing.T) {
assert.Equal(t, "creator@signoz.io", savedView.UpdatedBy)
assert.Equal(t, view.Name, savedView.Name)
assert.Equal(t, view.Source, savedView.Source)
assert.Equal(t, view.Data, savedView.Data)
assert.Equal(t, view.SchemaVersion, savedView.SchemaVersion)
assert.Equal(t, view.Spec, savedView.Spec)
assert.False(t, savedView.CreatedAt.IsZero())
assert.Equal(t, savedView.CreatedAt, savedView.UpdatedAt)
}
@@ -163,14 +188,14 @@ func TestNewSavedView_GeneratesNameWhenEmpty(t *testing.T) {
view := validPostableSavedView()
view.Name = ""
view.GenerateName = true
view.Data.Spec.DisplayName = "My View!"
view.Spec.DisplayName = "My View!"
savedView := view.ToSavedView(orgID, "creator@signoz.io")
assert.NotEmpty(t, savedView.Name)
assert.Empty(t, validation.IsDNS1123Label(savedView.Name), "generated name must be a valid DNS-1123 label")
assert.True(t, strings.HasPrefix(savedView.Name, "my-view-"))
assert.Equal(t, "My View!", savedView.Data.Spec.DisplayName)
assert.Equal(t, "My View!", savedView.Spec.DisplayName)
}
func TestGenerateSavedViewName(t *testing.T) {
@@ -212,17 +237,94 @@ func TestGenerateSavedViewName(t *testing.T) {
})
}
func TestNewStatsFromSavedViews(t *testing.T) {
views := []*SavedView{
func TestStorableSavedView_ToSavedView(t *testing.T) {
t.Run("round trip preserves populated fields", func(t *testing.T) {
view := &SavedView{
Name: "my-view",
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
},
}
view.OrgID = valuer.GenerateUUID().StringValue()
roundTripped := NewStorableSavedView(view).ToSavedView()
assert.Equal(t, view.OrgID, roundTripped.OrgID)
assert.Equal(t, view.Name, roundTripped.Name)
assert.Equal(t, view.Source, roundTripped.Source)
assert.Equal(t, view.SchemaVersion, roundTripped.SchemaVersion)
assert.Equal(t, view.Spec, roundTripped.Spec)
})
t.Run("nil selectedFields normalizes to an empty slice, not nil", func(t *testing.T) {
storable := &StorableSavedView{
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion.StringValue(),
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: validQueries(),
SelectedFields: nil,
},
},
}
view := storable.ToSavedView()
assert.NotNil(t, view.Spec.SelectedFields)
assert.Empty(t, view.Spec.SelectedFields)
})
t.Run("nil queries normalizes to an empty slice, not nil", func(t *testing.T) {
storable := &StorableSavedView{
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion.StringValue(),
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: nil,
},
},
}
view := storable.ToSavedView()
assert.NotNil(t, view.Spec.Queries)
assert.Empty(t, view.Spec.Queries)
})
}
func TestNewStatsFromStorableSavedViews(t *testing.T) {
storables := []*StorableSavedView{
{Source: SourceLogs},
{Source: SourceLogs},
{Source: SourceTraces},
}
stats := NewStatsFromSavedViews(views)
stats := NewStatsFromStorableSavedViews(storables)
assert.Equal(t, int64(3), stats["savedview.count"])
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
assert.NotContains(t, stats, "savedview.source.metrics.count")
}
func TestNewSavedViewsFromStorableSavedViews(t *testing.T) {
storables := []*StorableSavedView{
{Name: "a", Source: SourceLogs, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "a", PanelType: PanelTypeGraph, Queries: validQueries()}}},
{Name: "b", Source: SourceTraces, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "b", PanelType: PanelTypeTable, Queries: validQueries()}}},
}
views := NewSavedViewsFromStorableSavedViews(storables)
require.Len(t, views, 2)
assert.Equal(t, "a", views[0].Name)
assert.Equal(t, SourceLogs, views[0].Source)
assert.Equal(t, "b", views[1].Name)
assert.Equal(t, SourceTraces, views[1].Source)
}

View File

@@ -28,7 +28,7 @@ func (t *StoreTest) Store() savedviewtypes.Store { return t.store }
func (t *StoreTest) Mock() sqlmock.Sqlmock { return t.mock }
func savedViewRow(view *savedviewtypes.SavedView) []driver.Value {
data, _ := json.Marshal(view.Data)
data, _ := json.Marshal(savedviewtypes.NewStorableSavedView(view).Data)
return []driver.Value{
view.ID.StringValue(),
view.CreatedAt,
@@ -47,6 +47,12 @@ func (t *StoreTest) ExpectCreate() {
t.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnResult(sqlmock.NewResult(1, 1))
}
// ExpectCreateError sets up the SQL expectation for a Create call whose insert
// fails, e.g. on a UNIQUE(org_id, name) violation.
func (t *StoreTest) ExpectCreateError(err error) {
t.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnError(err)
}
// ExpectGet sets up the SQL expectation for a Get call. Pass view = nil to
// simulate a not-found row.
func (t *StoreTest) ExpectGet(orgID string, id valuer.UUID, view *savedviewtypes.SavedView) {

View File

@@ -8,7 +8,7 @@ import (
)
// SavedViewSchemaVersion is the only schemaVersion currently.
const SavedViewSchemaVersion = "v2"
var SavedViewSchemaVersion = SchemaVersion{valuer.NewString("v2")}
var (
PanelTypeValue = PanelType{valuer.NewString("value")}
@@ -30,9 +30,9 @@ type Display struct {
type SavedViewSpec struct {
DisplayName string `json:"displayName" required:"true"`
PanelType PanelType `json:"panelType" required:"true"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
Display Display `json:"display" required:"true"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false" minItems:"1"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" nullable:"false"`
Display Display `json:"display"`
}
// SavedViewData is what's persisted as saved view data.
@@ -41,6 +41,11 @@ type SavedViewData struct {
Spec SavedViewSpec `json:"spec" required:"true"`
}
// SchemaVersion has v2 as the only value currently.
type SchemaVersion struct {
valuer.String
}
// PanelType is the explore-page panel a saved view renders as.
type PanelType struct {
valuer.String
@@ -65,7 +70,21 @@ func (p PanelType) Validate() error {
}
}
func (s *SavedViewSpec) Validate() error {
func (SchemaVersion) Enum() []any {
return []any{SavedViewSchemaVersion}
}
func (s SchemaVersion) Validate() error {
if s != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion.StringValue(), s.StringValue())
}
return nil
}
// Validate validates Queries against the rules for requestType -- the shape
// of result they must produce (e.g. a raw/list request must not carry an
// aggregation, a scalar request must).
func (s *SavedViewSpec) Validate(requestType qbtypes.RequestType) error {
if s.DisplayName == "" {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "displayName is required")
}
@@ -73,13 +92,20 @@ func (s *SavedViewSpec) Validate() error {
return err
}
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate(qbtypes.GetValidationOptions(requestType)...)
}
func (d *SavedViewData) Validate() error {
if d.SchemaVersion != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
// LegacyRequestTypeForPanelType exists only for the v1 legacy API, which has
// no requestType concept of its own to send.
func LegacyRequestTypeForPanelType(p PanelType) qbtypes.RequestType {
switch p {
case PanelTypeList:
return qbtypes.RequestTypeRaw
case PanelTypeTrace:
return qbtypes.RequestTypeTrace
case PanelTypeGraph:
return qbtypes.RequestTypeTimeSeries
default:
return qbtypes.RequestTypeScalar
}
return d.Spec.Validate()
}

View File

@@ -1,12 +1,15 @@
package savedviewtypes
import (
"encoding/json"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validQueries() []qbtypes.QueryEnvelope {
@@ -75,7 +78,7 @@ func TestSavedViewSpecValidate(t *testing.T) {
expectError: true,
},
{
name: "selected fields and display are not required",
name: "selectedFields and display populated is still valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
@@ -85,51 +88,149 @@ func TestSavedViewSpecValidate(t *testing.T) {
},
expectError: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.spec.Validate()
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestSavedViewDataValidate(t *testing.T) {
cases := []struct {
name string
data SavedViewData
expectError bool
}{
{
name: "valid data",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
name: "nil selectedFields is valid -- neither field is actually required",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
Queries: validQueries(),
SelectedFields: nil,
},
expectError: false,
},
{
name: "wrong schema version is rejected",
data: SavedViewData{SchemaVersion: "v1", Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
name: "empty (non-nil) selectedFields is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
expectError: false,
},
{
name: "empty schema version is rejected",
data: SavedViewData{Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
name: "zero-value display is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
Queries: validQueries(),
Display: Display{},
},
expectError: false,
},
{
name: "invalid spec is rejected",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph}},
name: "list panel query with no aggregation is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeList,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: false,
},
{
name: "trace panel query with no aggregation is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTrace,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: false,
},
{
name: "graph panel query with no aggregation is still rejected",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.data.Validate()
err := c.spec.Validate(LegacyRequestTypeForPanelType(c.spec.PanelType))
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestSavedViewSpecValidate_HonorsExplicitRequestType(t *testing.T) {
// requestType is a caller-supplied validation input, not derived from PanelType --
// a raw query without aggregations must pass under RequestTypeRaw even though its
// PanelType is graph (which LegacyRequestTypeForPanelType would map to time_series).
spec := SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
}
assert.NoError(t, spec.Validate(qbtypes.RequestTypeRaw))
assert.Error(t, spec.Validate(qbtypes.RequestTypeTimeSeries))
}
func TestSavedViewSpecJSONUnmarshal_OptionalFields(t *testing.T) {
base := `"displayName":"My View","panelType":"table","queries":[{"type":"builder_query","spec":{"signal":"logs","aggregations":[{"expression":"count()"}]}}]`
cases := []struct {
name string
json string
}{
{name: "selectedFields and display omitted entirely", json: `{` + base + `}`},
{name: "selectedFields and display explicitly null", json: `{` + base + `,"selectedFields":null,"display":null}`},
{name: "selectedFields empty array, display empty object", json: `{` + base + `,"selectedFields":[],"display":{}}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var spec SavedViewSpec
err := json.Unmarshal([]byte(c.json), &spec)
require.NoError(t, err)
assert.NoError(t, spec.Validate(LegacyRequestTypeForPanelType(spec.PanelType)))
assert.Empty(t, spec.SelectedFields)
assert.Equal(t, Display{}, spec.Display)
})
}
}
func TestSchemaVersionValidate(t *testing.T) {
cases := []struct {
name string
schemaVersion SchemaVersion
expectError bool
}{
{name: "valid schema version", schemaVersion: SavedViewSchemaVersion, expectError: false},
{name: "wrong schema version is rejected", schemaVersion: SchemaVersion{valuer.NewString("v1")}, expectError: true},
{name: "empty schema version is rejected", schemaVersion: SchemaVersion{}, expectError: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.schemaVersion.Validate()
if c.expectError {
assert.Error(t, err)
} else {

View File

@@ -7,9 +7,9 @@ import (
)
type Store interface {
Create(ctx context.Context, view *SavedView) error
Get(ctx context.Context, orgID string, id valuer.UUID) (*SavedView, error)
Update(ctx context.Context, view *SavedView) error
Create(ctx context.Context, view *StorableSavedView) error
Get(ctx context.Context, orgID string, id valuer.UUID) (*StorableSavedView, error)
Update(ctx context.Context, view *StorableSavedView) error
Delete(ctx context.Context, orgID string, id valuer.UUID) error
List(ctx context.Context, orgID string, source Source, name string) ([]*SavedView, error)
List(ctx context.Context, orgID string, source Source, name string) ([]*StorableSavedView, error)
}

View File

@@ -13,15 +13,13 @@ def _body(name: str, source: str = "logs") -> dict:
return {
"name": name,
"source": source,
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": name,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": name,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
}

View File

@@ -26,15 +26,13 @@ def test_create_rejects_wrong_schema_version(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v9",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v9",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -59,15 +57,13 @@ def test_create_rejects_invalid_panel_type(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "bogus",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "bogus",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -91,15 +87,13 @@ def test_create_rejects_empty_queries(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -127,15 +121,13 @@ def test_create_rejects_empty_display_name(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -160,15 +152,13 @@ def test_create_rejects_invalid_source(
"name": "my-view",
"generateName": False,
"source": "bogus",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -192,15 +182,13 @@ def test_create_rejects_invalid_name(
"name": "Not A Valid Slug",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -226,15 +214,13 @@ def test_create_rejects_empty_name_without_generate_name(
"name": "",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -261,15 +247,13 @@ def test_create_rejects_name_when_generate_name_is_true(
"name": "explicit-name",
"generateName": True,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -298,15 +282,13 @@ def test_create_rejects_unknown_field(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"unknownfield": "boom",
},
@@ -366,15 +348,13 @@ def test_update_missing_view_returns_not_found(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -402,15 +382,13 @@ def test_update_rejects_name_field(
"name": "update-rejects-name-field",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -424,15 +402,13 @@ def test_update_rejects_name_field(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"name": "update-rejects-name-field",
},
@@ -485,15 +461,13 @@ def test_saved_view_lifecycle(
"name": "lc-logs-overview",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -508,15 +482,13 @@ def test_saved_view_lifecycle(
"name": "lc-traces-overview",
"generateName": False,
"source": "traces",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-traces-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "lc-traces-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -535,9 +507,9 @@ def test_saved_view_lifecycle(
got = response.json()["data"]
assert got["id"] == view_id
assert got["name"] == "lc-logs-overview"
assert got["data"]["spec"]["displayName"] == "lc-logs-overview"
assert got["spec"]["displayName"] == "lc-logs-overview"
assert got["source"] == "logs"
assert got["data"]["spec"]["panelType"] == "table"
assert got["spec"]["panelType"] == "table"
# ── list filters by source and name ──────────────────────────────
response = requests.get(
@@ -564,15 +536,13 @@ def test_saved_view_lifecycle(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "metrics",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview-renamed",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview-renamed",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -588,9 +558,9 @@ def test_saved_view_lifecycle(
assert response.status_code == HTTPStatus.OK, response.text
updated = response.json()["data"]
assert updated["name"] == "lc-logs-overview", "name is immutable"
assert updated["data"]["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["source"] == "metrics"
assert updated["data"]["spec"]["panelType"] == "graph"
assert updated["spec"]["panelType"] == "graph"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -621,15 +591,13 @@ def test_empty_name_derives_a_slug_from_display_name(
"name": "",
"generateName": True,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My Generated View!",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My Generated View!",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -646,7 +614,7 @@ def test_empty_name_derives_a_slug_from_display_name(
)
assert response.status_code == HTTPStatus.OK, response.text
got = response.json()["data"]
assert got["data"]["spec"]["displayName"] == "My Generated View!"
assert got["spec"]["displayName"] == "My Generated View!"
assert got["name"].startswith("my-generated-view-")
assert got["name"] != "my-generated-view-", "expected a random suffix, not just the slugified prefix"
finally:
@@ -681,15 +649,13 @@ def test_create_roundtrip_preserves_zero_values(
"name": "create-zero-values",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "create-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "create-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -705,7 +671,7 @@ def test_create_roundtrip_preserves_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["data"]["spec"]
spec = response.json()["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
@@ -741,14 +707,12 @@ def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
"name": "omitted-selected-fields",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "omitted-selected-fields",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "omitted-selected-fields",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -764,7 +728,151 @@ def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["data"]["spec"]["selectedFields"] == []
assert response.json()["data"]["spec"]["selectedFields"] == []
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_display_omitted_on_create_reads_back_as_zero_value(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""display's counterpart to test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null:
neither field is required, so omitting display entirely must not 400 or
leave it null -- it reads back as the zero-value Display object."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "omitted-display",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "omitted-display",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": ""}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_selected_fields_and_display_explicit_null_on_create(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""JSON null decodes as a no-op onto a non-pointer Go field (struct/slice), so
an explicit null is expected to behave identically to omitting the field."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "null-selected-fields-and-display",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "null-selected-fields-and-display",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": None,
"display": None,
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
assert spec["selectedFields"] == []
assert spec["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": ""}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_create_with_partial_display_defaults_missing_fields(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""display's fields are each independently optional -- sending only one
(color) must not 400, and the fields left unset must default to their own
zero value rather than being rejected or dropped."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "partial-display-color-only",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "partial-display-color-only",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"color": "test"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": "test"}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -793,15 +901,13 @@ def test_update_does_not_corrupt_zero_values(
"name": "update-zero-values",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": True, "legend": "Custom Legend"}}],
"selectedFields": [{"name": "service.name"}],
"display": {"maxLines": 25, "fontSize": "large", "format": "table", "color": "blue"},
},
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": True, "legend": "Custom Legend"}}],
"selectedFields": [{"name": "service.name"}],
"display": {"maxLines": 25, "fontSize": "large", "format": "table", "color": "blue"},
},
},
headers=headers,
@@ -817,7 +923,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["data"]["spec"]
spec = response.json()["data"]["spec"]
assert spec["display"]["maxLines"] == 25
# signal/fieldContext/fieldDataType always serialize on TelemetryFieldKey
# (no omitempty -- see pkg/types/telemetrytypes/field.go), so an entry sent
@@ -830,15 +936,13 @@ def test_update_does_not_corrupt_zero_values(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -853,7 +957,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["data"]["spec"]
spec = response.json()["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
@@ -873,3 +977,69 @@ def test_update_does_not_corrupt_zero_values(
headers=headers,
timeout=5,
)
def test_update_with_partial_display_replaces_whole_object(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Update is a whole-object replace, not a merge: sending only "color" on
update must not preserve the previous fontSize/format/maxLines -- those
reset to their zero value exactly as if display had been sent in full."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "update-partial-display",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-partial-display",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 10, "fontSize": "large", "format": "table", "color": "blue"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-partial-display",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"color": "green"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": "green"}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)

View File

@@ -112,15 +112,13 @@ def test_write_forbidden_without_grant(
signoz.self.host_configs["8080"].get(f"{SAVED_VIEW_BASE}/{target_id}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -133,15 +131,13 @@ def test_write_forbidden_without_grant(
json={
"name": "saved-view-fga-create-attempt",
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "saved-view-fga-create-attempt",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "saved-view-fga-create-attempt",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -212,15 +208,13 @@ def test_update_scoped_to_granted_view(
token = get_token(_SAVED_VIEW_FGA_CUSTOM_USER_EMAIL, _SAVED_VIEW_FGA_CUSTOM_USER_PASSWORD)
updated_body = {
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
}