Compare commits

...

15 Commits

Author SHA1 Message Date
Vinicius Lourenço
7fd6852fa6 refactor(authz): use new authz components for roles (#12011) 2026-07-10 12:19:09 +00:00
Vinicius Lourenço
b5567d1550 refactor(authz): use new authz components for service accounts (#12007) 2026-07-10 12:18:55 +00:00
Yunus M
2017660eb8 feat(quick-filters): add end-to-end tests and helper functions for qu… (#12063)
* feat(quick-filters): add end-to-end tests and helper functions for quick filters settings panel

* chore: fix formatting issues

* fix: add classname to skeleton and assert on the classname

* fix: address review comments

* chore: fix formatting issues
2026-07-10 11:54:14 +00:00
Aditya Singh
ca5d888970 feat(trace-details): restore Open in Logs Explorer in span details Logs tab (#11941)
* feat(trace-details): add Open in Logs Explorer to span details Logs tab

Restores the Open in Logs Explorer action (removed with V2 in #11805): a
toolbar button above the log list and an inline action in the no-trace-logs
empty state. Also tightens the empty-state RESOURCES card layout (stacked,
content-width, no extra height).

* feat: add logs explorer btn in footer

* test(trace-details): cover Open in Logs Explorer footer button in span details

* test(trace-details): assert log-row click opens Logs Explorer with trace/span query

* style(trace-details): fix oxfmt formatting in SpanLogs test

* fix(trace-details): pin Open in Logs Explorer footer to the panel bottom

The footer lived inside the scrolling tab content, so it was pushed below
the fold whenever the span summary above the tabs was in view. Render it as
a sibling of the scrolling panelBody instead (Logs tab only), making it a
true fixed footer that is always visible.
2026-07-10 07:36:05 +00:00
Yunus M
76b4d249e2 chore: update package dependencies in package.json and pnpm-lock.yaml (#12066)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-10 05:56:13 +00:00
Pandey
ef892f3361 fix(ruletypes): tag rule threshold targets as format: double (#12061)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(ruletypes): tag rule threshold targets as format: double

BasicRuleThreshold.{TargetValue,RecoveryTarget} and RuleCondition.Target are
*float64 but emitted a bare 'type: number' (swaggest sets format: double only for
non-pointer floats). Bare number makes oapi-codegen clients generate float32, so a
value like 0.8 loses precision on round-trip. Tag them format:double to match
non-pointer float64 fields (e.g. MetrictypesComparisonSpaceAggregationParam).

* chore(frontend): regenerate API client for rule threshold format: double

Reflects the format: double schema change on the rule threshold/condition
targets in the orval-generated client (oxfmt + oxlint applied).
2026-07-09 22:09:25 +00:00
Yunus M
9c4046d84a fix: revert the resizeable quick filter changes (#12058) 2026-07-09 19:00:19 +00:00
Vikrant Gupta
a91cee2b95 chore(sql): enable txlock immediate by default (#12048)
* chore(sql): enable txlock immediate by default

* chore(sql): remove the redundant flag from integration tests
2026-07-09 16:05:13 +00:00
Ashwin Bhatkal
51036d6cc4 feat(public-dashboard): integrate v2 (Perses-spec) public dashboards (#12032)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(public-dashboard): detect v1 vs v2 schema for the public viewer

Anonymous public viewers have no feature flags, so the schema can't be read from
use_dashboard_v2. Probe the v2 model endpoint first and fall back to v1 only on the
'dashboard_invalid_data' (HTTP 501) schema-mismatch signal. Probing v2 first also stops
the v1 endpoint from serving v2 dashboards with un-redacted queries.

* feat(public-dashboard): fetch v2 public panel data by key

Adds a by-key fetcher over the anonymous /api/v2/public/dashboards/{id}/panels/{key}/query_range
endpoint (the generated client omits the startTime/endTime params) and a store-free
usePublicPanelQuery that mirrors usePanelQuery's PanelQueryData shape. No variables and no
pagination — the public endpoint supports neither.

* feat(public-dashboard): render v2 public dashboards read-only

Adds a read-only v2 viewer that reuses the authenticated V2 panel renderers
(PanelHeader with hideActions, PanelBody, panel registry) and the pure layoutsToSections
util, with a forked read-only grid. The public page branches on the resolved schema:
v1 keeps the existing container, v2 renders the new viewer. Dashboard variables are not
rendered — the public endpoint does not substitute them.

* feat(public-dashboard): match the standard auto-refresh control

Replace the hand-rolled 'Off' select (which was styled inconsistently and clipped its
options) with a PublicAutoRefresh that mirrors the app's DateTimeSelectionV2 refresh cluster:
a grouped refresh button + auto-refresh popover (Auto Refresh checkbox + full interval list),
portal-rendered so nothing clips. It's prop-driven — the public viewer keeps managing its own
time window — so the container now tracks enabled + interval and exposes a manual refresh.
Also nudge the header-right gap 8→12px.

* feat(public-dashboard): declare v2 query_range params, drop the wrapper, address review

Declare startTime/endTime as query params on the v2 public query_range endpoint via
RequestQuery and regenerate the OpenAPI spec + orval client, so the generated
getPublicDashboardPanelQueryRangeV2 carries them. usePublicPanelQuery now calls the
generated fetcher directly and the hand-written wrapper is removed.

Also from review: drop the defensive panelDefinition guard so an unsupported kind
surfaces loudly, use lodash noop, and trim excessive comments across the v2 files.

* fix: bind query params from PublicWidgetQueryRangeParams

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-07-09 13:27:17 +00:00
Abhi kumar
f7bfd0eba6 fix(dashboard-v2): bug bash fixes III (#12041)
* fix(dashboard-v2): scroll the saved, cloned, or added panel/section into view

Saving a panel, closing the editor, cloning a panel, or adding a section
now reveals the affected panel/section instead of leaving the dashboard
scrolled to the top. Save resolves with the persisted panel id, a small
scroll-target store hands it to the grid, and a shared
scrollIntoViewWhenReady util polls for the optimistic DOM commit.

* fix(dashboard-v2): seed the metric unit per the kind's formatting controls

Replaces useMetricYAxisUnit with useSeedMetricUnit, driven by the kind's
Formatting controls (the same source buildPluginSpec reads): kinds with a
panel-wide unit seed formatting.unit; Table, which has none, seeds each
resolved value column's formatting.columnUnits instead. Column unit
selectors now also surface the metric-unit mismatch warning. Spec
read/write goes through a shared formattingSpec util.

* fix(dashboard-v2): stop grid placement from overlapping tall panels

findFreeSlot now probes the last-row candidate against every existing
item (mirroring the backend's overlap rejection) before placing there — a
tall panel from an earlier row can reach down into the last row — and
falls back to a fresh bottom row otherwise. Move-to-section reuses the
same primitive instead of always dropping to the bottom.

* fix(dashboard-v2): carry supported config across panel type switches

Switching visualization kind used to keep only formatting and thresholds,
dropping axes entirely and resetting legend/visualization/chartAppearance
to defaults. Each section seed now carries the old spec's fields the
target kind's controls declare: axes softMin/softMax/log scale, the time
preference, stacking/fillSpans, legend position, and chart appearance.
Unsupported fields (and legend customColors, keyed by series labels the
new kind may not reproduce) are still dropped.

* fix(dashboard-v2): keep context link editor alive on undecodable URL params

A literal % in a query value (e.g. ?search=95%) is not valid
percent-encoding, so decodeURIComponent threw URIError while seeding the
edit dialog and unmounted the page. Fall back to the raw string when a
key or value fails to decode.

* fix(dashboard-v2): don't auto-run the preview when a query is added

The query-type/datasource effect re-commits the draft on any structural
builder change, so adding a query immediately refetched the preview for
a still-empty query. Track the previous datasource list and skip the
commit when the change is a pure append — the new query commits on Run
Query as usual.

* fix(dashboard-v2): bootstrap variables and edit context on the editor route

A hard refresh (or direct URL) of the full-page editor mounts without
DashboardContainer or the variables bar, so the preview fetched with
unresolved variables and the store's edit context stayed cold. The
selection seeding moves out of useVariableSelection into a standalone
useSeedVariableSelection (URL → persisted store → default, with stale
URL entries pruned), which PanelEditorPage now mounts alongside
useResolvedVariables and the edit-context seed. The ?variables= param
is carried across dashboard ↔ editor navigation (withVariablesSearch)
so the selection survives the round trip (V1 parity).

* fix(dashboard-v2): carry thresholds and units onto table columns

Switching timeseries/number → table carried thresholds with an empty
columnName, which the save API rejects (TablePanelSpec.Thresholds[]
.ColumnName is required), and dropped the panel-wide unit entirely
(Table has no formatting.unit).

The renderer's keying rule is extracted as getAggregationColumnKey
(query name, or name.expression on multi-aggregation queries), and
getTableColumnKeys derives every enabled query's value-column keys
from the spec's queries alone, before any data exists — skipping
clickhouse_sql, whose columns key by the response's SQL alias.
Carried thresholds key onto the first derivable column and are
dropped when none exists instead of blocking the save; the panel-wide
unit fans out to every value column via formatting.columnUnits. The
carry is one-way: per-column units never seed a panel-wide unit back.

* chore: pr review changes

* chore: qb sticky header in view mode fix

* chore: pr review changes

* chore: pr review changes

- Unify panel/section reveal-scroll into one useScrollIntoView(id, ref, block)
  hook backed by a single scrollTargetId store (was two hooks + two ids).
- buildPluginSpec seeds return their slice (empty {}/[] when nothing to carry)
  instead of undefined; the omit-empty decision is centralized.
2026-07-09 12:44:44 +00:00
Nikhil Mantri
da250bfd3e fix: commented out container metrics to surface k8s.pod.start_time (#12042) 2026-07-09 12:09:53 +00:00
Nikhil Mantri
67007e2902 feat(infra-monitoring): Kubernetes Container Monitoring List API (#11946)
* chore: added types for containers

* chore: added querier query and constants

* chore: added helper queries

* chore: added wiring

* chore: added open api spec

* chore: endpoint and variable rename

* chore: use recency for container status.reason as well

* chore: pass orgID to getMetadata for containers (adapt to rebased main)

* chore: add metrics to the containers list for metadata and earliest time

* chore: corrected metrics list for metadata lookup

* chore: added changes to the checks API for new kube containers section

* chore: containers query modified

* chore: added integration tests

* chore: integration tests

* chore: goroutines in container monitoring

* chore: constants deduplication

* chore: inlined requests.Post call for this PR instead of wrapping in a function
2026-07-09 11:42:24 +00:00
Ashwin Bhatkal
ff3ec133b4 fix(dashboard-v2): stop false 'updated elsewhere' prompts on your own edits (#12035)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(dashboard-v2): close the 'updated elsewhere' dialog on reload

Reload set dismissedAt to null and relied on the refetched loaded copy's updatedAt
converging with the freshness query's serverUpdatedAt to hide the prompt. Those are two
separate queries and the convergence is racy, so the dialog often stayed open across
repeated reloads until Dismiss was clicked. Make Reload acknowledge the current version
(like Dismiss) and then refetch, so it closes on click; a genuinely newer server version
still re-prompts.

* fix(dashboard-v2): only prompt when the server copy is strictly newer

The stale-check compared the loaded and freshness copies of updatedAt with !==. Your own
edits advance the loaded copy immediately (the optimistic patch writes the PATCH response's
new updatedAt into the render cache), while the freshness query only re-fetches on window
focus. So right after creating a panel, renaming, or editing a variable, loaded jumped ahead
while freshness lagged, and !== flagged your own change as 'updated elsewhere' — a false
prompt that persisted across in-app navigation until a real window-focus refetch.

Compare with a strictly-newer (>) check so the prompt only fires when the server has a
version we have not loaded. A genuine external change still trips it; our own edits (loaded
ahead of the lagging freshness copy) no longer do.

* fix(dashboard-v2): probe freshness on visibilitychange, not refetchOnWindowFocus

The freshness check used a separate react-query query with initialData + refetchOnMount:false
so the first load made no extra request. But a query that only holds seeded data and never
fetched isn't reliably eligible for react-query's focus refetch, so on tab return no request
fired and external changes went undetected.

Drop the separate query and probe the server's updatedAt explicitly on 'visibilitychange'
(guarded to the visible transition): zero requests on first load, exactly one per tab return,
no dependence on react-query focus heuristics or staleTime bookkeeping. Comparison and
reload/dismiss behaviour are unchanged (strictly-newer; reload = dismiss + refetch).

* fix(dashboard-v2): probe on window focus too, and gate stale prompt on spec content

Two fixes to the freshness probe:

- Also listen to window 'focus', not just document 'visibilitychange'. Switching to
  another app (e.g. the editor) and back keeps the browser tab 'visible', so
  visibilitychange never fires and no probe ran — the reported 'no call when coming
  back to the page'. focus covers that; visibilitychange still covers tab switches.

- Prompt only when the server's spec actually differs (deep-equal), gated by
  strictly-newer. updatedAt is bumped by metadata-only writes like lock/unlock that
  don't change what the viewer sees; keying on spec makes those a non-event without
  needing the (void) lock/unlock call to return the new updatedAt. Hook now takes the
  loaded dashboard so it has both updatedAt and spec to compare.

* docs(dashboard-v2): clarify stale-check probes on both tab switch and app focus

* docs(dashboard-v2): trim useDashboardStaleCheck comments

* fix(dashboard-v2): detect external tag and lock changes too

Widen the stale-check content comparison from spec-only to spec + tags + lock state, so an
external tag edit or lock/unlock by another user surfaces the reload prompt. Own actions still
don't trip it: the render cache advances with them (optimistic patch / cache sync), so loaded
matches server.
2026-07-09 04:32:00 +00:00
Ashwin Bhatkal
0ea5399c15 feat(dashboard-v2): bug bash fixes II (list, dashboard page, create modal) (#12040)
* fix(dashboard-v2): source Home recent dashboards from the v2 list under the flag

* fix(dashboard-v2): keep same-key:value tags from collapsing to one word in the list

* fix(dashboard-v2): commit the inline dashboard-title edit on outside click

* fix(dashboard-v2): cap variable multi-select pills so the bar doesn't overflow

* fix(dashboard-v2): persist the variables-bar expand state and show "Less" when it loads expanded

* feat(dashboard-v2): reusable dashboard image picker for details + create modal

Extract the icon/image picker into a shared DashboardImagePicker used by both the dashboard settings form and the create-dashboard modal. Patch `/image` with add (not replace) when the field is absent, and lift the inline dropdown above the create-modal form fields.

* feat(dashboard-v2): simplify the create-modal templates tab to browse-and-request

Drop the placeholder gallery (search + category list + preview) for a clean, centered browse-and-request block: link out to the published template library, and let cloud users request a template we haven't built yet.

* fix(dashboard-v2): contain Escape/Enter and pass Cmd+Enter through the tag editor

Escape/Enter no longer bubble to close the host drawer/modal; Cmd/Ctrl+Enter passes through so a host form can submit on it (plain Enter still adds/commits a tag).

* feat(dashboard-v2): add/edit a dashboard's tags from the list

Add an "Add Tags"/"Edit Tags" kebab action (labelled by whether the dashboard has tags) opening the shared key:value editor, saving via an `add /tags` patch, with Cmd/Ctrl+Enter save. Gated on edit permission, disabled while locked. Also guard row navigation so clicks inside portaled overlays (menu/modals) don't open the dashboard.
2026-07-09 04:27:43 +00:00
Abhi kumar
f7aa3cee9c fix(dashboard-v2): view-modal, pie chart, and panel fixes (#12033)
* fix(dashboard-v2): tint chart-legend checkbox with its series color

The @signozhq/ui Checkbox declares --checkbox-checked-background on the
element itself via its data-color rule, so a value set on an ancestor was
shadowed and the per-series tint never applied. Feed the color through a
private --series-color var and override the vars on the element with a
selector that out-specifies the library rule.

* fix(dashboard-v2): make antd popups usable inside the view panel modal

The editor's antd Selects/pickers portal to document.body, outside the
modal Radix dialog, where Radix disables pointer events and traps focus, so
their menus opened but couldn't be clicked. Wrap the modal body in an antd
ConfigProvider whose getPopupContainer points at the dialog content, so all
antd popups render inside the interactive, focus-trapped layer.

* feat(dashboard-v2): add the list columns editor and pagination loader to the view modal

Surface the List panel's columns editor in the View modal (as in the full
editor) by exposing setSpec from useViewPanelMode and wiring it into the
query-builder footer. Its add-column combobox portals to body, so inside
the modal it opened behind the dialog — lift its z-index above the
query-builder popups so it stacks on top.

Also thread isPreviousData into PreviewPane so the List panel's page-change
loader shows during pagination, matching the full editor and dashboard.

* fix(dashboard-v2): measure chart dimensions before first paint

useResizeObserver seeded its size to 0 and only reported real dimensions
after paint via its debounced observer, so charts rendered once at 0 then
jumped — most visibly the pie legend, which rendered left-aligned then
re-centered once it learned it fit on one row. Add a useLayoutEffect that
measures the element synchronously before paint. In jsdom clientWidth is 0,
so tests are unaffected.

* feat(dashboard-v2): support per-slice color overrides for pie charts

Enable the legend colors control for pie charts. The control is fed by
useLegendSeries, which only handled time-series data, so pie returned no
series. Resolve pie legend series from the same scalar slices the renderer
draws (without overrides, for default colors) so the color keys match what
the chart looks up.

Extract the series resolution into utils/legendSeries.ts (a shared dedupe
primitive plus a pie and a time-series resolver) and reduce the hook to a
kind switch that returns none for kinds without a colors control.

* feat(dashboard-v2): offer dashboard root and every section in the move menu

Rework the "Move to section" submenu to list the dashboard root plus every
titled section (minus the panel's own), instead of only titled sections with
a separate "move out of section" entry. Hide it entirely when there is
nowhere to move.

* fix(dashboard-v2): hide the empty panel header in editor preview

When a panel has no title, description, or status and the actions menu is
suppressed (editor preview), render nothing instead of an empty header bar.
Add a min-height so the header keeps a stable size when it does render.

* fix(dashboard-v2): lazy-load panels per-panel instead of per-section

Move the viewport intersection observer down from Section to a new
SectionGridItem wrapper so each panel gates its own fetch. A board with
many panels now only queries the ones actually on screen, instead of
loading every panel in any section that scrolls into view.

* fix(dashboard-v2): hide "Extend time range" for panels with a fixed time preference

A panel locked to a non-global timePreference queries a fixed relative span
anchored to the dashboard end, so widening the ambient window can't surface
data for it — the "Extend time range" CTA was a no-op. NoData now takes the
panel and, via panelHasFixedTimePreference, drops the global extender for such
panels (only Retry remains). The View modal's local extender still wins.

* refactor(dashboard-v2): read panel time preference via shared helper

Panel.tsx read visualization.timePreference through an inline cast; replace it
with getPanelTimePreference so the header pill and NoData's extend gate share
one reader.

* feat(dashboard-v2): support per-series legend colors for bar and histogram panels

Route BarChartPanel and HistogramPanel through the time-series legend path in
useLegendSeries and enable the Legend `colors` control in both kinds' sections,
extending the per-series color overrides to these kinds.

* feat(dashboard-v2): pin the query-type tabs to the top of the editor scroll area

Make the query-type tab nav + Run Query button sticky so they stay visible
while the query body scrolls underneath; move the top padding onto the nav so
it keeps its spacing at rest and when pinned.
2026-07-09 04:23:37 +00:00
242 changed files with 8957 additions and 3147 deletions

View File

@@ -129,7 +129,7 @@ sqlstore:
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
transaction_mode: deferred
transaction_mode: immediate
##################### APIServer #####################
apiserver:

View File

@@ -4189,6 +4189,7 @@ components:
- namespaces
- clusters
- volumes
- kube_containers
type: string
InframonitoringtypesChecks:
properties:
@@ -4294,6 +4295,158 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesContainerCountsByReady:
properties:
notReady:
type: integer
ready:
type: integer
required:
- ready
- notReady
type: object
InframonitoringtypesContainerCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
imagePullBackOff:
type: integer
oomKilled:
type: integer
running:
type: integer
terminated:
type: integer
unknown:
type: integer
waiting:
type: integer
required:
- running
- waiting
- terminated
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- unknown
type: object
InframonitoringtypesContainerReady:
enum:
- ready
- not_ready
- no_data
type: string
InframonitoringtypesContainerRecord:
properties:
containerCountsByReady:
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByReady'
containerCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByStatus'
containerName:
type: string
cpu:
format: double
type: number
cpuLimitUtilization:
format: double
type: number
cpuRequestUtilization:
format: double
type: number
memory:
format: double
type: number
memoryLimitUtilization:
format: double
type: number
memoryRequestUtilization:
format: double
type: number
meta:
additionalProperties:
type: string
nullable: true
type: object
podUID:
type: string
ready:
$ref: '#/components/schemas/InframonitoringtypesContainerReady'
restarts:
format: int64
type: integer
status:
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
required:
- podUID
- containerName
- status
- containerCountsByStatus
- ready
- containerCountsByReady
- restarts
- cpu
- cpuRequestUtilization
- cpuLimitUtilization
- memory
- memoryRequestUtilization
- memoryLimitUtilization
- meta
type: object
InframonitoringtypesContainerStatus:
enum:
- running
- waiting
- terminated
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- unknown
- no_data
type: string
InframonitoringtypesContainers:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesContainerRecord'
type: array
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDaemonSetRecord:
properties:
currentNodes:
@@ -4968,6 +5121,32 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableContainers:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostableDaemonSets:
properties:
end:
@@ -7215,9 +7394,11 @@ components:
op:
$ref: '#/components/schemas/RuletypesCompareOperator'
recoveryTarget:
format: double
nullable: true
type: number
target:
format: double
nullable: true
type: number
targetUnit:
@@ -7501,6 +7682,7 @@ components:
selectedQueryName:
type: string
target:
format: double
nullable: true
type: number
targetUnit:
@@ -16099,6 +16281,86 @@ paths:
summary: List Jobs for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/kube_containers:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes containers with key kubeletstats
metrics: CPU usage (cores), CPU request/limit utilization, memory working
set, and memory request/limit utilization. Each container also reports health
signals from the k8s_cluster receiver: status (kubectl-style display status
derived from k8s.container.status.state + k8s.container.status.reason), restarts
(absolute count from k8s.container.restarts), and ready (ready/not_ready from
k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name),
stable across container restarts. Each container includes metadata attributes
(k8s.container.name, k8s.pod.name, container.image.name, container.image.tag,
k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such
as deployment/statefulset/daemonset/job). The response type is ''list'' for
the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container
with its current status and ready state) or ''grouped_list'' for custom groupBy
keys (each row aggregates containers in the group with per-status counts under
containerCountsByStatus, per-readiness counts under containerCountsByReady,
and restarts as the group sum). Status requires the optional k8s.container.status.state
and k8s.container.status.reason metrics; when either is missing, status is
omitted and a warning is returned while restarts and ready are still computed.
Supports filtering via a filter expression, custom groupBy, ordering by any
of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit),
and pagination via offset/limit. Also reports whether the requested time range
falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization,
cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization)
and restarts return -1 as a sentinel when no data is available for that field.'
operationId: ListContainers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableContainers'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesContainers'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Kubernetes Containers for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/namespaces:
post:
deprecated: false
@@ -17909,6 +18171,16 @@ paths:
public dashboard. The panel is addressed by its key in spec.panels.
operationId: GetPublicDashboardPanelQueryRangeV2
parameters:
- in: query
name: startTime
required: false
schema:
type: string
- in: query
name: endTime
required: false
schema:
type: string
- in: path
name: id
required: true

View File

@@ -75,7 +75,7 @@
"crypto-js": "4.2.0",
"d3-hierarchy": "3.1.2",
"dayjs": "^1.10.7",
"dompurify": "3.4.0",
"dompurify": "3.4.11",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
@@ -238,10 +238,18 @@
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.6",
"brace-expansion": "^2.0.2",
"brace-expansion": "^2.0.3",
"on-headers": "^1.1.0",
"js-cookie": "^3.0.7",
"tmp": "0.2.7",
"vite": "npm:rolldown-vite@7.3.1"
"vite": "npm:rolldown-vite@7.3.1",
"dompurify": "3.4.11",
"js-yaml@3": "3.15.0",
"js-yaml@4": "4.2.0",
"yaml@1": "1.10.3",
"react-router@6": "6.30.4",
"markdown-it": "14.2.0",
"mdast-util-to-hast@13": "13.2.1",
"protocol-buffers-schema": "3.6.1"
}
}

152
frontend/pnpm-lock.yaml generated
View File

@@ -19,11 +19,19 @@ overrides:
prismjs: 1.30.0
got: 11.8.5
form-data: 4.0.6
brace-expansion: ^2.0.2
brace-expansion: ^2.0.3
on-headers: ^1.1.0
js-cookie: ^3.0.7
tmp: 0.2.7
vite: npm:rolldown-vite@7.3.1
dompurify: 3.4.11
js-yaml@3: 3.15.0
js-yaml@4: 4.2.0
yaml@1: 1.10.3
react-router@6: 6.30.4
markdown-it: 14.2.0
mdast-util-to-hast@13: 13.2.1
protocol-buffers-schema: 3.6.1
importers:
@@ -79,7 +87,7 @@ importers:
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.23
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -153,8 +161,8 @@ importers:
specifier: ^1.10.7
version: 1.11.20
dompurify:
specifier: 3.4.0
version: 3.4.0
specifier: 3.4.11
version: 3.4.11
event-source-polyfill:
specifier: 1.0.31
version: 1.0.31
@@ -196,7 +204,7 @@ importers:
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
overlayscrollbars:
specifier: ^2.8.1
version: 2.9.2
@@ -3040,6 +3048,10 @@ packages:
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4143,8 +4155,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -4805,14 +4817,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.2.4:
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.4.0:
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
@@ -6055,12 +6061,12 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
js-yaml@3.15.0:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
hasBin: true
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
js-yaml@4.2.0:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true
jsdom@20.0.3:
@@ -6244,8 +6250,8 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
lint-staged@17.0.4:
resolution: {integrity: sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==}
@@ -6364,8 +6370,8 @@ packages:
mapbox-to-css-font@2.4.5:
resolution: {integrity: sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==}
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
markdown-it@14.2.0:
resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==}
hasBin: true
markdown-table@3.0.4:
@@ -6429,8 +6435,8 @@ packages:
mdast-util-to-hast@12.3.0:
resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==}
mdast-util-to-hast@13.1.0:
resolution: {integrity: sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==}
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
mdast-util-to-markdown@1.5.0:
resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==}
@@ -6772,7 +6778,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7
react-router: 6.30.4
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -6793,7 +6799,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7
react-router: 6.30.4
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -7174,8 +7180,8 @@ packages:
property-information@6.3.0:
resolution: {integrity: sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==}
protocol-buffers-schema@3.6.0:
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
protocol-buffers-schema@3.6.1:
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@@ -7690,8 +7696,8 @@ packages:
peerDependencies:
react: '>=15'
react-router@6.30.3:
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
@@ -8946,8 +8952,8 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
yaml@1.10.3:
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
engines: {node: '>= 6'}
yaml@2.8.2:
@@ -9068,7 +9074,7 @@ snapshots:
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
js-yaml: 4.2.0
'@babel/code-frame@7.29.0':
dependencies:
@@ -10327,7 +10333,7 @@ snapshots:
'@types/string-hash': 1.1.3
d3-interpolate: 3.0.1
date-fns: 4.1.0
dompurify: 3.2.4
dompurify: 3.4.11
eventemitter3: 5.0.1
fast_array_intersect: 1.1.0
history: 4.10.1
@@ -10478,7 +10484,7 @@ snapshots:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
js-yaml: 3.14.1
js-yaml: 3.15.0
resolve-from: 5.0.0
'@istanbuljs/schema@0.1.3': {}
@@ -11805,6 +11811,8 @@ snapshots:
'@remix-run/router@1.23.2': {}
'@remix-run/router@1.23.3': {}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
optional: true
@@ -12042,7 +12050,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -12071,7 +12079,7 @@ snapshots:
lodash-es: 4.18.1
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
nuqs: 2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-day-picker: 9.9.0(react@18.2.0)
react-dom: 18.2.0(react@18.2.0)
@@ -13057,7 +13065,7 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@2.0.2:
brace-expansion@2.1.1:
dependencies:
balanced-match: 1.0.2
@@ -13381,14 +13389,14 @@ snapshots:
import-fresh: 3.3.1
parse-json: 5.2.0
path-type: 4.0.0
yaml: 1.10.2
yaml: 1.10.3
optional: true
cosmiconfig@9.0.1(typescript@5.9.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
js-yaml: 4.2.0
parse-json: 5.2.0
optionalDependencies:
typescript: 5.9.3
@@ -13720,15 +13728,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.2.4:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.4.0:
dompurify@3.4.11:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -14416,7 +14416,7 @@ snapshots:
hast-util-from-parse5: 8.0.1
hast-util-to-parse5: 8.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.1.0
mdast-util-to-hast: 13.2.1
parse5: 7.3.0
unist-util-position: 5.0.0
unist-util-visit: 5.0.0
@@ -15342,12 +15342,12 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@3.14.1:
js-yaml@3.15.0:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.1.1:
js-yaml@4.2.0:
dependencies:
argparse: 2.0.1
@@ -15396,7 +15396,7 @@ snapshots:
'@types/json-schema': 7.0.15
'@types/lodash': 4.17.24
is-glob: 4.0.3
js-yaml: 4.1.1
js-yaml: 4.2.0
lodash: 4.18.1
minimist: 1.2.8
prettier: 3.8.3
@@ -15533,7 +15533,7 @@ snapshots:
lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
linkify-it@5.0.2:
dependencies:
uc.micro: 2.1.0
@@ -15654,11 +15654,11 @@ snapshots:
mapbox-to-css-font@2.4.5: {}
markdown-it@14.1.1:
markdown-it@14.2.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
linkify-it: 5.0.2
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
@@ -15772,7 +15772,7 @@ snapshots:
unist-util-position: 4.0.4
unist-util-visit: 4.1.2
mdast-util-to-hast@13.1.0:
mdast-util-to-hast@13.2.1:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.3
@@ -16049,19 +16049,19 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.1.1
minimatch@3.1.5:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.1.1
minimatch@5.1.9:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.1.1
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.1.1
minimist@1.2.8: {}
@@ -16075,7 +16075,7 @@ snapshots:
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
dompurify: 3.4.11
marked: 14.0.0
motion-dom@11.18.1:
@@ -16216,20 +16216,20 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.2.0
optionalDependencies:
react-router: 6.30.3(react@18.2.0)
react-router: 6.30.4(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0):
nuqs@2.8.9(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.2.0
optionalDependencies:
react-router: 6.30.3(react@18.2.0)
react-router: 6.30.4(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nwsapi@2.2.23: {}
@@ -16336,7 +16336,7 @@ snapshots:
find-up: 8.0.0
fs-extra: 11.3.3
jiti: 2.6.1
js-yaml: 4.1.1
js-yaml: 4.2.0
remeda: 2.34.0
string-argv: 0.3.2
tsconfck: 3.1.6(typescript@5.9.3)
@@ -16549,7 +16549,7 @@ snapshots:
postcss-load-config@3.1.4(postcss@8.5.14)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3)):
dependencies:
lilconfig: 2.1.0
yaml: 1.10.2
yaml: 1.10.3
optionalDependencies:
postcss: 8.5.14
ts-node: 10.9.1(@types/node@16.18.25)(typescript@5.9.3)
@@ -16654,7 +16654,7 @@ snapshots:
property-information@6.3.0: {}
protocol-buffers-schema@3.6.0: {}
protocol-buffers-schema@3.6.1: {}
proxy-from-env@1.1.0: {}
@@ -17264,7 +17264,7 @@ snapshots:
history: 5.3.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router: 6.30.3(react@18.2.0)
react-router: 6.30.4(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
react-router-dom@5.3.4(react@18.2.0):
@@ -17291,9 +17291,9 @@ snapshots:
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
react-router@6.30.3(react@18.2.0):
react-router@6.30.4(react@18.2.0):
dependencies:
'@remix-run/router': 1.23.2
'@remix-run/router': 1.23.3
react: 18.2.0
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
@@ -17517,7 +17517,7 @@ snapshots:
resolve-protobuf-schema@2.1.0:
dependencies:
protocol-buffers-schema: 3.6.0
protocol-buffers-schema: 3.6.1
resolve@1.22.11:
dependencies:
@@ -18209,7 +18209,7 @@ snapshots:
dependencies:
'@gerrit0/mini-shiki': 3.23.0
lunr: 2.3.9
markdown-it: 14.1.1
markdown-it: 14.2.0
minimatch: 10.2.5
typescript: 5.9.3
yaml: 2.8.4
@@ -18673,7 +18673,7 @@ snapshots:
yallist@4.0.0: {}
yaml@1.10.2: {}
yaml@1.10.3: {}
yaml@2.8.2: {}

View File

@@ -41,6 +41,7 @@ import type {
GetPublicDashboardDataV2200,
GetPublicDashboardDataV2PathParameters,
GetPublicDashboardPanelQueryRangeV2200,
GetPublicDashboardPanelQueryRangeV2Params,
GetPublicDashboardPanelQueryRangeV2PathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
@@ -1912,20 +1913,25 @@ export const invalidateGetPublicDashboardDataV2 = async (
*/
export const getPublicDashboardPanelQueryRangeV2 = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
method: 'GET',
params,
signal,
});
};
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
id,
key,
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
) => {
return [
`/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
...(params ? [params] : []),
] as const;
};
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
@@ -1933,6 +1939,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
@@ -1945,11 +1952,12 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
const queryKey =
queryOptions?.queryKey ??
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
> = ({ signal }) =>
getPublicDashboardPanelQueryRangeV2({ id, key }, params, signal);
return {
queryKey,
@@ -1978,6 +1986,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
@@ -1988,6 +1997,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
{ id, key },
params,
options,
);
@@ -2004,10 +2014,16 @@ export function useGetPublicDashboardPanelQueryRangeV2<
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
queryClient: QueryClient,
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
params?: GetPublicDashboardPanelQueryRangeV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
{
queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey(
{ id, key },
params,
),
},
options,
);

View File

@@ -21,6 +21,7 @@ import type {
GetChecks200,
GetChecksParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableContainersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
InframonitoringtypesPostableHostsDTO,
@@ -31,6 +32,7 @@ import type {
InframonitoringtypesPostableStatefulSetsDTO,
InframonitoringtypesPostableVolumesDTO,
ListClusters200,
ListContainers200,
ListDaemonSets200,
ListDeployments200,
ListHosts200,
@@ -548,6 +550,89 @@ export const useListJobs = <
> => {
return useMutation(getListJobsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes containers with key kubeletstats metrics: CPU usage (cores), CPU request/limit utilization, memory working set, and memory request/limit utilization. Each container also reports health signals from the k8s_cluster receiver: status (kubectl-style display status derived from k8s.container.status.state + k8s.container.status.reason), restarts (absolute count from k8s.container.restarts), and ready (ready/not_ready from k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name), stable across container restarts. Each container includes metadata attributes (k8s.container.name, k8s.pod.name, container.image.name, container.image.tag, k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such as deployment/statefulset/daemonset/job). The response type is 'list' for the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container with its current status and ready state) or 'grouped_list' for custom groupBy keys (each row aggregates containers in the group with per-status counts under containerCountsByStatus, per-readiness counts under containerCountsByReady, and restarts as the group sum). Status requires the optional k8s.container.status.state and k8s.container.status.reason metrics; when either is missing, status is omitted and a warning is returned while restarts and ready are still computed. Supports filtering via a filter expression, custom groupBy, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization, cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization) and restarts return -1 as a sentinel when no data is available for that field.
* @summary List Kubernetes Containers for Infra Monitoring
*/
export const listContainers = (
inframonitoringtypesPostableContainersDTO?: BodyType<InframonitoringtypesPostableContainersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListContainers200>({
url: `/api/v2/infra_monitoring/kube_containers`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableContainersDTO,
signal,
});
};
export const getListContainersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
> => {
const mutationKey = ['listContainers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listContainers>>,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> }
> = (props) => {
const { data } = props ?? {};
return listContainers(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListContainersMutationResult = NonNullable<
Awaited<ReturnType<typeof listContainers>>
>;
export type ListContainersMutationBody =
| BodyType<InframonitoringtypesPostableContainersDTO>
| undefined;
export type ListContainersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Kubernetes Containers for Infra Monitoring
*/
export const useListContainers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
> => {
return useMutation(getListContainersMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* @summary List Namespaces for Infra Monitoring

View File

@@ -5577,6 +5577,7 @@ export enum InframonitoringtypesCheckTypeDTO {
namespaces = 'namespaces',
clusters = 'clusters',
volumes = 'volumes',
kube_containers = 'kube_containers',
}
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
@@ -5856,6 +5857,174 @@ export interface InframonitoringtypesClustersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesContainerCountsByReadyDTO {
/**
* @type integer
*/
notReady: number;
/**
* @type integer
*/
ready: number;
}
export interface InframonitoringtypesContainerCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
terminated: number;
/**
* @type integer
*/
unknown: number;
/**
* @type integer
*/
waiting: number;
}
export enum InframonitoringtypesContainerReadyDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesContainerRecordDTOMeta =
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesContainerStatusDTO {
running = 'running',
waiting = 'waiting',
terminated = 'terminated',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
unknown = 'unknown',
no_data = 'no_data',
}
export interface InframonitoringtypesContainerRecordDTO {
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
containerCountsByStatus: InframonitoringtypesContainerCountsByStatusDTO;
/**
* @type string
*/
containerName: string;
/**
* @type number
* @format double
*/
cpu: number;
/**
* @type number
* @format double
*/
cpuLimitUtilization: number;
/**
* @type number
* @format double
*/
cpuRequestUtilization: number;
/**
* @type number
* @format double
*/
memory: number;
/**
* @type number
* @format double
*/
memoryLimitUtilization: number;
/**
* @type number
* @format double
*/
memoryRequestUtilization: number;
/**
* @type object,null
*/
meta: InframonitoringtypesContainerRecordDTOMeta;
/**
* @type string
*/
podUID: string;
ready: InframonitoringtypesContainerReadyDTO;
/**
* @type integer
* @format int64
*/
restarts: number;
status: InframonitoringtypesContainerStatusDTO;
}
export interface InframonitoringtypesContainersDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
*/
records: InframonitoringtypesContainerRecordDTO[];
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6438,6 +6607,33 @@ export interface InframonitoringtypesPostableClustersDTO {
start: number;
}
export interface InframonitoringtypesPostableContainersDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableDaemonSetsDTO {
/**
* @type integer
@@ -8284,10 +8480,12 @@ export interface RuletypesBasicRuleThresholdDTO {
op: RuletypesCompareOperatorDTO;
/**
* @type number,null
* @format double
*/
recoveryTarget?: number | null;
/**
* @type number,null
* @format double
*/
target: number | null;
/**
@@ -8481,6 +8679,7 @@ export interface RuletypesRuleConditionDTO {
selectedQueryName?: string;
/**
* @type number,null
* @format double
*/
target?: number | null;
/**
@@ -11041,6 +11240,14 @@ export type ListJobs200 = {
status: string;
};
export type ListContainers200 = {
data: InframonitoringtypesContainersDTO;
/**
* @type string
*/
status: string;
};
export type ListNamespaces200 = {
data: InframonitoringtypesNamespacesDTO;
/**
@@ -11366,6 +11573,19 @@ export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
id: string;
key: string;
};
export type GetPublicDashboardPanelQueryRangeV2Params = {
/**
* @type string
* @description undefined
*/
startTime?: string;
/**
* @type string
* @description undefined
*/
endTime?: string;
};
export type GetPublicDashboardPanelQueryRangeV2200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -2,7 +2,7 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
@@ -134,18 +134,17 @@ function CreateServiceAccountModal(): JSX.Element {
Cancel
</Button>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</AuthZTooltip>
<AuthZButton
checks={[SACreatePermission]}
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</AuthZButton>
</DialogFooter>
</DialogWrapper>
);

View File

@@ -20,6 +20,7 @@ function OtherFiltersSkeleton(): JSX.Element {
<Skeleton.Input
active
size="small"
className="qf-other-filters-skeleton"
// eslint-disable-next-line react/no-array-index-key
key={index}
/>

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
@@ -109,24 +109,21 @@ function KeyFormPhase({
<Button variant="solid" color="secondary" onClick={onClose}>
Cancel
</Button>
<AuthZTooltip
<AuthZButton
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
enabled={!!accountId}
authZEnabled={!!accountId}
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
<Button
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
</AuthZTooltip>
Create Key
</AuthZButton>
</div>
</div>
</>

View File

@@ -1,7 +1,7 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
@@ -84,20 +84,17 @@ function DeleteAccountModal(): JSX.Element {
<X size={12} />
Cancel
</Button>
<AuthZTooltip
<AuthZButton
checks={[buildSADeletePermission(accountId ?? '')]}
enabled={!!accountId}
authZEnabled={!!accountId}
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
>
<Trash2 size={12} />
Delete
</Button>
</AuthZTooltip>
<Trash2 size={12} />
Delete
</AuthZButton>
</div>
);

View File

@@ -7,6 +7,7 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
@@ -158,38 +159,36 @@ function EditKeyForm({
</form>
<div className="edit-key-modal__footer">
<AuthZTooltip
<AuthZButton
checks={[
buildAPIKeyDeletePermission(keyItem?.id ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyItem?.id}
authZEnabled={!!accountId && !!keyItem?.id}
variant="link"
color="destructive"
onClick={onRevokeClick}
>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
<Trash2 size={12} />
Revoke Key
</AuthZButton>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<AuthZTooltip
<AuthZButton
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!accountId && !!keyItem?.id}
authZEnabled={!!accountId && !!keyItem?.id}
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
<Button
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
</AuthZTooltip>
Save Changes
</AuthZButton>
</div>
</div>
</>

View File

@@ -1,12 +1,13 @@
import React, { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import { Pagination, Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import {
APIKeyCreatePermission,
APIKeyListPermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
@@ -24,10 +25,10 @@ interface KeysTabProps {
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
canUpdate?: boolean;
accountId?: string;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
}
interface BuildColumnsParams {
@@ -113,29 +114,26 @@ function buildColumns({
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
return (
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<Tooltip title={tooltipTitle}>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
</AuthZTooltip>
<Tooltip title={tooltipTitle}>
<AuthZButton
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
authZEnabled={!isDisabled && !!accountId}
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</AuthZButton>
</Tooltip>
);
},
},
@@ -149,6 +147,7 @@ function KeysTab({
accountId = '',
currentPage,
pageSize,
onPageChange,
}: KeysTabProps): JSX.Element {
const [, setIsAddKeyOpen] = useQueryState(
'add-key',
@@ -212,21 +211,18 @@ function KeysTab({
Learn more
</a>
</p>
<AuthZTooltip
<AuthZButton
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
enabled={!isDisabled && !!accountId}
authZEnabled={!isDisabled && !!accountId}
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</AuthZTooltip>
+ Add your first key
</AuthZButton>
</div>
);
}
@@ -278,6 +274,24 @@ function KeysTab({
})}
/>
<Pagination
current={currentPage}
pageSize={pageSize}
total={keys.length}
showTotal={(total: number, range: number[]): JSX.Element => (
<>
<span className="sa-drawer__pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-drawer__pagination-total"> of {total}</span>
</>
)}
showSizeChanger={false}
hideOnSinglePage
onChange={onPageChange}
className="sa-drawer__keys-pagination"
/>
<EditKeyModal keyItem={editKey} />
<RevokeKeyModal />
@@ -285,4 +299,7 @@ function KeysTab({
);
}
export default KeysTab;
export default withAuthZContent(KeysTab, {
checks: [APIKeyListPermission],
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
});

View File

@@ -6,15 +6,20 @@ import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { buildSAUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import {
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import SaveErrorItem from './SaveErrorItem';
import type { SaveError } from './utils';
import { Skeleton } from 'antd';
interface OverviewTabProps {
account: ServiceAccountRow;
@@ -23,7 +28,6 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
canUpdate?: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
@@ -39,7 +43,6 @@ function OverviewTab({
localRoles,
onRolesChange,
isDisabled,
canUpdate = true,
availableRoles,
rolesLoading,
rolesError,
@@ -86,23 +89,22 @@ function OverviewTab({
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled || !canUpdate ? (
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
enabled={!isDisabled && !canUpdate}
>
{isDisabled ? (
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
) : (
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
placeholder="Enter name"
/>
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
placeholder="Enter name"
/>
</AuthZTooltip>
)}
</div>
@@ -220,4 +222,9 @@ function OverviewTab({
);
}
export default OverviewTab;
export default withAuthZContent(OverviewTab, {
checks: (props): ReturnType<typeof buildSAReadPermission>[] => [
buildSAReadPermission(props.account.id),
],
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
});

View File

@@ -1,7 +1,7 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
@@ -45,23 +45,20 @@ export function RevokeKeyFooter({
<X size={12} />
Cancel
</Button>
<AuthZTooltip
<AuthZButton
checks={[
buildAPIKeyDeletePermission(keyId ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyId}
authZEnabled={!!accountId && !!keyId}
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
<Trash2 size={12} />
Revoke Key
</AuthZButton>
</>
);
}
@@ -111,7 +108,7 @@ function RevokeKeyModal(): JSX.Element {
}
function handleCancel(): void {
setRevokeKeyId(null);
void setRevokeKeyId(null);
}
return (

View File

@@ -1,11 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useQueryClient } from 'react-query';
import { Key, LayoutGrid, Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getListServiceAccountsQueryKey,
@@ -16,7 +22,6 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -28,15 +33,13 @@ import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import {
APIKeyCreatePermission,
APIKeyListPermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -47,7 +50,6 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -70,14 +72,12 @@ function toSaveApiError(err: unknown): APIError {
);
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
}: ServiceAccountDrawerProps): JSX.Element {
const [selectedAccountId, setSelectedAccountId] = useQueryState(
SA_QUERY_PARAMS.ACCOUNT,
);
const open = !!selectedAccountId;
const [activeTab, setActiveTab] = useQueryState(
SA_QUERY_PARAMS.TAB,
parseAsStringEnum<ServiceAccountDrawerTab>(
@@ -100,28 +100,14 @@ function ServiceAccountDrawer({
SA_QUERY_PARAMS.DELETE_SA,
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const queryClient = useQueryClient();
const { permissions: drawerPermissions, isLoading: isAuthZLoading } = useAuthZ(
selectedAccountId
? [
buildSAReadPermission(selectedAccountId),
buildSAUpdatePermission(selectedAccountId),
buildSADeletePermission(selectedAccountId),
APIKeyListPermission,
]
: [],
{ enabled: !!selectedAccountId },
);
const canRead =
drawerPermissions?.[buildSAReadPermission(selectedAccountId ?? '')]
?.isGranted ?? false;
const open = !!selectedAccountId;
const {
data: accountData,
@@ -131,7 +117,7 @@ function ServiceAccountDrawer({
refetch: refetchAccount,
} = useGetServiceAccount(
{ id: selectedAccountId ?? '' },
{ query: { enabled: canRead && !!selectedAccountId } },
{ query: { enabled: !!selectedAccountId } },
);
const account = useMemo(
@@ -145,7 +131,7 @@ function ServiceAccountDrawer({
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '', {
enabled: canRead && !!selectedAccountId,
enabled: !!selectedAccountId,
});
const roleSessionRef = useRef<string | null>(null);
@@ -194,16 +180,9 @@ function ServiceAccountDrawer({
refetch: refetchRoles,
} = useRoles();
const canListKeys =
drawerPermissions?.[APIKeyListPermission]?.isGranted ?? false;
const canUpdate =
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
?.isGranted ?? true;
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId && canListKeys } },
{ query: { enabled: !!selectedAccountId } },
);
const keys = keysData?.data ?? [];
@@ -217,7 +196,6 @@ function ServiceAccountDrawer({
}
}, [keysLoading, keys.length, keysPage, setKeysPage]);
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const retryNameUpdate = useCallback(async (): Promise<void> => {
@@ -375,23 +353,71 @@ function ServiceAccountDrawer({
]);
const handleClose = useCallback((): void => {
void setIsDeleteOpen(null);
void setIsAddKeyOpen(null);
void setSelectedAccountId(null);
setSaveErrors([]);
void setActiveTab(null);
void setKeysPage(null);
void setEditKeyId(null);
setSaveErrors([]);
void setIsAddKeyOpen(null);
void setIsDeleteOpen(null);
void setSelectedAccountId(null);
}, [
setSelectedAccountId,
setActiveTab,
setKeysPage,
setEditKeyId,
setIsAddKeyOpen,
setIsDeleteOpen,
setSelectedAccountId,
]);
const drawerContent = (
const footer = useMemo(
() =>
activeTab === ServiceAccountDrawerTab.Overview && !isDeleted && open ? (
<div className="sa-drawer__footer">
<AuthZButton
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</AuthZButton>
<div className="sa-drawer__footer-right">
<Button variant="outlined" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<AuthZButton
checks={[buildSAUpdatePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</AuthZButton>
</div>
</div>
) : null,
[
activeTab,
isDeleted,
open,
selectedAccountId,
isSaving,
isDirty,
handleClose,
handleSave,
setIsDeleteOpen,
],
);
const body = (
<div className="sa-drawer__layout">
<div className="sa-drawer__tabs">
<ToggleGroupSimple
@@ -433,26 +459,23 @@ function ServiceAccountDrawer({
]}
/>
{activeTab === ServiceAccountDrawerTab.Keys && (
<AuthZTooltip
<AuthZButton
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(selectedAccountId ?? ''),
]}
enabled={!isDeleted && !!selectedAccountId}
authZEnabled={!isDeleted && !!selectedAccountId}
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</AuthZTooltip>
<Plus size={12} />
Add Key
</AuthZButton>
)}
</div>
@@ -461,9 +484,7 @@ function ServiceAccountDrawer({
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
}`}
>
{(isAuthZLoading || isAccountLoading) && (
<Skeleton active paragraph={{ rows: 6 }} />
)}
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
{isAccountError && (
<ErrorInPlace
error={toAPIError(
@@ -472,141 +493,73 @@ function ServiceAccountDrawer({
)}
/>
)}
{!isAuthZLoading &&
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<>
{activeTab === ServiceAccountDrawerTab.Overview &&
(canRead && account ? (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
canUpdate={canUpdate}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
) : (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
))}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
)}
{!isAccountLoading && !isAccountError && (
<>
{activeTab === ServiceAccountDrawerTab.Overview &&
(account ? (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
) : (
<Skeleton active />
))}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
accountId={selectedAccountId ?? ''}
currentPage={keysPage}
pageSize={PAGE_SIZE}
onPageChange={(page): void => {
void setKeysPage(page);
}}
/>
)}
</>
)}
</div>
</div>
);
const footer = (
<div className="sa-drawer__footer">
{activeTab === ServiceAccountDrawerTab.Keys ? (
<Pagination
current={keysPage}
pageSize={PAGE_SIZE}
total={keys.length}
showTotal={(total: number, range: number[]): JSX.Element => (
<>
<span className="sa-drawer__pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-drawer__pagination-total"> of {total}</span>
</>
)}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => {
void setKeysPage(page);
}}
className="sa-drawer__keys-pagination"
/>
) : (
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
showCloseButton
showOverlay={false}
title="Service Account Details"
className="sa-drawer"
width="wide"
footer={footer}
>
{open && (
<>
{!isDeleted && (
<AuthZTooltip
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
enabled={!!selectedAccountId}
>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</AuthZTooltip>
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">
<Button variant="outlined" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</div>
)}
{body}
<DeleteAccountModal />
<AddKeyModal />
</>
)}
</div>
);
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
showCloseButton
showOverlay={false}
title="Service Account Details"
className="sa-drawer"
width="wide"
footer={footer}
>
{drawerContent}
</DrawerWrapper>
<DeleteAccountModal />
<AddKeyModal />
</>
</DrawerWrapper>
);
}

View File

@@ -1,4 +1,5 @@
import { toast } from '@signozhq/ui/sonner';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
@@ -59,6 +60,7 @@ describe('AddKeyModal', () => {
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json(createdKeyResponse)),
),
setupAuthzAdmin(),
);
});

View File

@@ -1,5 +1,6 @@
import { toast } from '@signozhq/ui/sonner';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -61,6 +62,7 @@ describe('EditKeyModal (URL-controlled)', () => {
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});

View File

@@ -1,5 +1,6 @@
import { toast } from '@signozhq/ui/sonner';
import { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -35,7 +36,7 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
{
id: 'key-2',
name: 'Staging Key',
expiresAt: 1924905600, // 2030-12-31
expiresAt: 1924948800, // 2030-12-31 12:00 UTC (noon to avoid timezone issues)
lastObservedAt: '2026-03-10T10:00:00Z',
serviceAccountId: 'sa-1',
},
@@ -47,6 +48,7 @@ const defaultProps = {
isDisabled: false,
currentPage: 1,
pageSize: 10,
onPageChange: jest.fn(),
};
function renderKeysTab(
@@ -67,6 +69,7 @@ describe('KeysTab', () => {
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});
@@ -74,9 +77,12 @@ describe('KeysTab', () => {
server.resetHandlers();
});
it('renders loading state', () => {
it('renders loading state', async () => {
renderKeysTab({ isLoading: true });
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
// Wait for authz to complete, then check for skeleton
await waitFor(() => {
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
it('renders empty state when no keys and clicking add sets add-key param', async () => {
@@ -91,9 +97,9 @@ describe('KeysTab', () => {
</NuqsTestingAdapter>,
);
expect(
screen.getByText(/No keys. Start by creating one./i),
).toBeInTheDocument();
await expect(
screen.findByText(/No keys. Start by creating one./i),
).resolves.toBeInTheDocument();
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
await user.click(addBtn);
expect(onUrlUpdate).toHaveBeenCalledWith(
@@ -103,10 +109,12 @@ describe('KeysTab', () => {
);
});
it('renders table with keys', () => {
it('renders table with keys', async () => {
renderKeysTab();
expect(screen.getByText('Production Key')).toBeInTheDocument();
await expect(
screen.findByText('Production Key'),
).resolves.toBeInTheDocument();
expect(screen.getByText('Staging Key')).toBeInTheDocument();
expect(screen.getByText('Never')).toBeInTheDocument();
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument();
@@ -122,7 +130,7 @@ describe('KeysTab', () => {
</NuqsTestingAdapter>,
);
const row = screen.getByText('Production Key').closest('tr');
const row = (await screen.findByText('Production Key')).closest('tr');
if (!row) {
throw new Error('Row not found');
}
@@ -146,6 +154,8 @@ describe('KeysTab', () => {
</NuqsTestingAdapter>,
);
// Wait for authz to complete and table to render
await screen.findByText('Production Key');
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
@@ -163,7 +173,8 @@ describe('KeysTab', () => {
renderKeysTab();
// Seed the keys cache so RevokeKeyModal can read the key name
// Wait for authz to complete and table to render
await screen.findByText('Production Key');
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
@@ -177,9 +188,11 @@ describe('KeysTab', () => {
});
});
it('disables actions when isDisabled is true', () => {
it('disables actions when isDisabled is true', async () => {
renderKeysTab({ isDisabled: true });
// Wait for authz to complete and table to render
await screen.findByText('Production Key');
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));

View File

@@ -1,4 +1,3 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
@@ -32,30 +31,6 @@ const activeAccountResponse = {
updatedAt: '2026-01-02T00:00:00Z',
};
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
@@ -118,7 +93,7 @@ describe('ServiceAccountDrawer — permissions', () => {
renderDrawer();
await waitFor(() => {
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
expect(screen.getByText(/read:serviceaccount/)).toBeInTheDocument();
});
});
@@ -140,7 +115,7 @@ describe('ServiceAccountDrawer — permissions', () => {
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
await waitFor(() => {
expect(screen.getByText(/factor-api-key:list/)).toBeInTheDocument();
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,3 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
@@ -7,30 +6,6 @@ import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';

View File

@@ -65,7 +65,9 @@ function TagKeyValueInput({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
// (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
commit();
}
@@ -93,11 +95,17 @@ function TagKeyValueInput({
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
// form (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
commitEdit();
} else if (e.key === 'Escape') {
// Contain Escape so it cancels the inline edit instead of bubbling up and
// closing the host drawer/modal.
e.preventDefault();
e.stopPropagation();
cancelEdit();
}
};

View File

@@ -31,12 +31,6 @@ export enum LOCALSTORAGE {
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -7,6 +7,7 @@ export const REACT_QUERY_KEY = {
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_RESOLVED: 'GET_PUBLIC_DASHBOARD_RESOLVED',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
GET_ALL_LICENCES: 'GET_ALL_LICENCES',

View File

@@ -163,23 +163,12 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.api-quick-filter-left-section {
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
width: 260px;
}
.api-module-right-section {
flex: 1;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -4,49 +4,21 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import DomainList from './Domains/DomainList';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="api-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<section className="api-quick-filter-left-section">
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
@@ -55,7 +27,7 @@ function Explorer(): JSX.Element {
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
/>
</ResizableBox>
</section>
<DomainList />
</div>
</Sentry.ErrorBoundary>

View File

@@ -43,7 +43,7 @@
&__title {
color: var(--l1-foreground);
font-size: 14px;
font-size: var(--periscope-font-size-base);
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
@@ -51,14 +51,14 @@
&__subtitle {
color: var(--l2-foreground);
font-size: 14px;
font-size: var(--periscope-font-size-base);
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
font-size: 14px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
line-height: 20px;
}
@@ -67,7 +67,7 @@
margin: 0;
margin-top: 8px;
color: var(--l2-foreground);
font-size: 14px;
font-size: var(--periscope-font-size-base);
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
@@ -106,7 +106,7 @@
border: 1px dashed var(--l1-border);
background: transparent;
color: var(--l2-foreground);
font-size: 14px;
font-size: var(--periscope-font-size-base);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
@@ -120,15 +120,15 @@
gap: 6px;
}
// Stack the message and the resources card; card matches the content
// width above it and is capped so it doesn't sprawl in a wide panel.
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;
justify-content: center;
margin-left: 21px;
flex-direction: column;
align-items: stretch;
gap: 16px;
width: fit-content;
max-width: 600px;
}
&__content {
@@ -142,7 +142,7 @@
background: var(--l2-background);
border: 1px solid var(--l1-border);
border-radius: 4px;
width: 332px;
width: 100%; // match the content width above
}
&__resources-title {
@@ -155,7 +155,6 @@
text-transform: uppercase;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--l1-border);
height: 46px;
}
&__resources-links {

View File

@@ -0,0 +1,4 @@
.wrapper :global(button[data-color]) {
--checkbox-checked-background: var(--series-color);
--checkbox-border-color: var(--series-color);
}

View File

@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
import { CSSProperties } from 'react';
import { CheckBoxProps } from '../types';
import styles from './CustomCheckBox.module.scss';
function CustomCheckBox({
data,
@@ -15,12 +16,11 @@ function CustomCheckBox({
const isChecked = graphVisibilityState[index] || false;
const colorStyle = {
'--checkbox-checked-background': color,
'--checkbox-border-color': color,
'--series-color': color,
} as CSSProperties;
return (
<span style={colorStyle}>
<span className={styles.wrapper} style={colorStyle}>
<Checkbox
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
value={isChecked}

View File

@@ -1,15 +1,20 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { Dashboard } from 'types/api/dashboard/getAll';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
@@ -17,6 +22,13 @@ import dialsUrl from '@/assets/Icons/dials.svg';
import { getItemIcon } from '../constants';
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
interface RecentDashboard {
id: string;
title: string;
tags: string[];
}
export default function Dashboards({
onUpdateChecklistDoneItem,
loadingUserPreferences,
@@ -26,33 +38,58 @@ export default function Dashboards({
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const isDashboardV2 = useIsDashboardV2();
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
// Fetch Dashboards
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
// selects; the inactive one stays disabled so it never fires.
const {
data: dashboardsList,
isLoading: isDashboardListLoading,
isError: isDashboardListError,
} = useGetAllDashboard();
data: v1List,
isLoading: v1Loading,
isError: v1Error,
} = useGetAllDashboard({ enabled: !isDashboardV2 });
const {
data: v2List,
isLoading: v2Loading,
isError: v2Error,
} = useListDashboardsForUserV2(
{
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
},
{ query: { enabled: isDashboardV2 } },
);
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
if (isDashboardV2) {
return (v2List?.data?.dashboards ?? []).map((d) => ({
id: d.id,
title: d.spec?.display?.name ?? d.name,
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
}));
}
return [...(v1List?.data ?? [])]
.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 5)
.map((d) => ({
id: d.id,
title: d.data.title,
tags: d.data.tags ?? [],
}));
}, [isDashboardV2, v1List, v2List]);
useEffect(() => {
if (!dashboardsList) {
return;
}
const sortedDashboards = dashboardsList.data.sort((a, b) => {
const aUpdateAt = new Date(a.updatedAt).getTime();
const bUpdateAt = new Date(b.updatedAt).getTime();
return bUpdateAt - aUpdateAt;
});
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
}
setSortedDashboards(sortedDashboards.slice(0, 5));
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -113,7 +150,7 @@ export default function Dashboards({
event.stopPropagation();
logEvent('Homepage: Dashboard clicked', {
dashboardId: dashboard.id,
dashboardName: dashboard.data.title,
dashboardName: dashboard.title,
});
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink());
@@ -143,12 +180,12 @@ export default function Dashboards({
/>
<div className="alert-rule-item-name home-data-item-name">
{dashboard.data.title}
{dashboard.title}
</div>
</div>
<div className="alert-rule-item-description home-data-item-tag">
{dashboard.data.tags?.map((tag) => (
{dashboard.tags.map((tag) => (
<Badge color="sienna" variant="outline" key={tag}>
{tag}
</Badge>

View File

@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
@@ -17,8 +16,6 @@ import {
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
</div>
)}
<div
className={`${styles.listContainer}${

View File

@@ -44,20 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
@@ -136,9 +142,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.quickFiltersToggleContainer {

View File

@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
@@ -17,8 +16,6 @@ import {
} from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
</div>
)}
<div
className={`${styles.listContainer}${

View File

@@ -44,20 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
@@ -136,9 +142,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.quickFiltersToggleContainer {

View File

@@ -44,15 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.quick-filters) {
@@ -117,9 +128,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.categorySelectorSection {
@@ -210,16 +219,8 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -23,8 +22,6 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</ResizableBox>
</div>
)}
<div

View File

@@ -44,15 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.quick-filters) {
@@ -117,9 +128,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.categorySelectorSection {
@@ -210,16 +219,8 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -23,8 +22,6 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</ResizableBox>
</div>
)}
<div

View File

@@ -33,7 +33,8 @@
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin: 0;
margin-bottom: var(--spacing-8);
margin-top: 0;
}
&__controls {

View File

@@ -3,23 +3,12 @@
flex-direction: row;
.meter-explorer-quick-filters-section {
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;
width: 280px;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.meter-explorer-content-section {
@@ -97,9 +86,7 @@
&.quick-filters-open {
.meter-explorer-content-section {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 280px);
}
}

View File

@@ -7,7 +7,6 @@ import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
@@ -19,8 +18,6 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -33,10 +30,6 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
const {
handleRunQuery,
@@ -62,16 +55,6 @@ function Explorer(): JSX.Element {
const [showQuickFilters, setShowQuickFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
@@ -144,19 +127,10 @@ function Explorer(): JSX.Element {
'quick-filters-open': showQuickFilters,
})}
>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
<div
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-meter-explorer"
@@ -168,7 +142,7 @@ function Explorer(): JSX.Element {
setShowQuickFilters(!showQuickFilters);
}}
/>
</ResizableBox>
</div>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">

View File

@@ -7,19 +7,48 @@ import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
import {
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import APIError from 'types/api/error';
import PermissionEditor from './components/PermissionEditor';
import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
import styles from './CreateEditRolePage.module.scss';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
function CreateEditRolePage(): JSX.Element {
function authzCheckFn(
_props: object,
router: RouterContext,
): BrandedPermission[] {
const match = router.matchPath<{ roleId: string }>(ROUTES.ROLE_DETAILS);
const roleId = match?.roleId ?? 'new';
const roleName = router.searchParams.get('name') ?? '';
const isCreateMode = roleId === 'new';
if (isCreateMode) {
return [RoleCreatePermission];
}
if (roleName) {
return [
buildRoleReadPermission(roleName),
buildRoleUpdatePermission(roleName),
];
}
return [];
}
function CreateEditRolePageContent(): JSX.Element {
const history = useHistory();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
@@ -47,9 +76,6 @@ function CreateEditRolePage(): JSX.Element {
saveError,
validationErrors,
isCreateMode,
hasRequiredPermission,
isAuthZLoading,
deniedPermission,
loadError,
} = useCreateEditRolePageActions(roleId, roleName);
@@ -81,10 +107,6 @@ function CreateEditRolePage(): JSX.Element {
roleName,
]);
if (!hasRequiredPermission && !isAuthZLoading) {
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div
@@ -127,7 +149,7 @@ function CreateEditRolePage(): JSX.Element {
);
}
if (isAuthZLoading || (isLoading && !isCreateMode) || isFeatureGateLoading) {
if ((isLoading && !isCreateMode) || isFeatureGateLoading) {
return (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -195,7 +217,12 @@ function CreateEditRolePage(): JSX.Element {
</Typography>
</div>
)}
<Button
<AuthZButton
checks={
isCreateMode
? [RoleCreatePermission]
: [buildRoleUpdatePermission(roleName)]
}
variant="solid"
color="primary"
onClick={handleSaveAndNavigate}
@@ -204,7 +231,7 @@ function CreateEditRolePage(): JSX.Element {
data-testid="save-button"
>
{isCreateMode ? 'Create role' : 'Save changes'}
</Button>
</AuthZButton>
</div>
</div>
@@ -290,4 +317,11 @@ function CreateEditRolePage(): JSX.Element {
);
}
export default CreateEditRolePage;
export default withAuthZPage(CreateEditRolePageContent, {
checks: authzCheckFn,
fallbackOnLoading: (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
),
});

View File

@@ -1,24 +1,21 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { server } from 'mocks-server/server';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import {
invalidLicense,
mockUseAuthZGrantAll,
setupAuthzAdmin,
} from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderCreatePage(
@@ -71,7 +68,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
),
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -80,7 +79,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
it('shows error when license is invalid', async () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -92,16 +93,19 @@ describe('CreateEditRolePage - Feature Gate', () => {
await expect(screen.findByText('Create Role')).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', () => {
it('shows back button when feature disabled', async () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
});
it('back button is enabled when feature disabled', () => {
it('back button is enabled when feature disabled', async () => {
renderCreatePage({ activeLicense: invalidLicense });
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
const cancelButton = await screen.findByTestId('cancel-button');
expect(cancelButton).not.toBeDisabled();
});
});
@@ -118,7 +122,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
),
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -127,7 +133,9 @@ describe('CreateEditRolePage - Feature Gate', () => {
it('shows error when license is invalid', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();

View File

@@ -1,16 +1,17 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen } from 'tests/test-utils';
import { mockUseAuthZDenyAll } from 'lib/authz/utils/authz-test-utils';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderCreatePage(): ReturnType<typeof render> {
@@ -31,7 +32,7 @@ function renderCreatePage(): ReturnType<typeof render> {
describe('CreateRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when create permission denied', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
server.use(setupAuthzDenyAll());
renderCreatePage();
@@ -43,19 +44,31 @@ describe('CreateRolePage - AuthZ', () => {
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});
server.use(
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
res(
ctx.delay(200),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
),
),
);
renderCreatePage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders create page when create permission granted', async () => {
server.use(setupAuthzAdmin());
renderCreatePage();
await expect(
screen.findByTestId('role-name-input'),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -3,27 +3,22 @@ import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderCreatePage(): ReturnType<typeof render> {
return render(
async function renderCreatePage(): Promise<ReturnType<typeof render>> {
const result = render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
@@ -35,61 +30,63 @@ function renderCreatePage(): ReturnType<typeof render> {
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('create-edit-role-page');
return result;
}
describe('CreateRolePage', () => {
describe('initial render', () => {
it('renders create role page with testId', () => {
renderCreatePage();
it('renders create role page with testId', async () => {
await renderCreatePage();
expect(screen.getByTestId('create-edit-role-page')).toBeInTheDocument();
});
it('shows breadcrumb with "Create role" as current page', () => {
renderCreatePage();
it('shows breadcrumb with "Create role" as current page', async () => {
await renderCreatePage();
const page = screen.getByTestId('create-edit-role-page');
const breadcrumbs = within(page).getAllByText('Create role');
expect(breadcrumbs.length).toBeGreaterThanOrEqual(1);
});
it('renders empty name input', () => {
renderCreatePage();
it('renders empty name input', async () => {
await renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).toHaveValue('');
});
it('renders empty description input', () => {
renderCreatePage();
it('renders empty description input', async () => {
await renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
expect(descInput).toHaveValue('');
});
it('name input is enabled in create mode', () => {
renderCreatePage();
it('name input is enabled in create mode', async () => {
await renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).not.toBeDisabled();
});
it('save button shows "Create role" text', () => {
renderCreatePage();
it('save button shows "Create role" text', async () => {
await renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toHaveTextContent('Create role');
});
it('save button is disabled when no changes', () => {
renderCreatePage();
it('save button is disabled when no changes', async () => {
await renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toBeDisabled();
});
it('does not show unsaved indicator initially', () => {
renderCreatePage();
it('does not show unsaved indicator initially', async () => {
await renderCreatePage();
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
});
@@ -98,7 +95,7 @@ describe('CreateRolePage', () => {
describe('form interactions', () => {
it('enables save button when name is entered', async () => {
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'test-role');
@@ -109,7 +106,7 @@ describe('CreateRolePage', () => {
it('shows unsaved indicator when form modified', async () => {
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-role');
@@ -121,7 +118,7 @@ describe('CreateRolePage', () => {
it('enables save button when description is entered', async () => {
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Some description');
@@ -134,7 +131,7 @@ describe('CreateRolePage', () => {
describe('cancel action', () => {
it('navigates to roles list on cancel', async () => {
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
@@ -163,7 +160,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-custom-role');
@@ -200,7 +197,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -218,7 +215,7 @@ describe('CreateRolePage', () => {
it('shows error banner with "Role name is required" when saving with empty name', async () => {
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -237,7 +234,7 @@ describe('CreateRolePage', () => {
it('clears error banner when user starts typing in name field', async () => {
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -270,7 +267,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'duplicate-role');
@@ -291,7 +288,7 @@ describe('CreateRolePage', () => {
describe('validation errors', () => {
it('shows validation error when Only Selected has no items', async () => {
const user = userEvent.setup();
renderCreatePage();
await renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -1,22 +1,43 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen } from 'tests/test-utils';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantByPrefix,
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { buildRoleUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const EDIT_ROLE_ID = 'test-role-123';
const EDIT_ROLE_NAME = 'test-role';
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: EDIT_ROLE_ID,
name: EDIT_ROLE_NAME,
description: 'Test role description',
type: 'custom',
transactionGroups: [],
},
}),
),
),
);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderEditPage(): ReturnType<typeof render> {
@@ -37,7 +58,7 @@ function renderEditPage(): ReturnType<typeof render> {
describe('EditRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when read permission denied', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
server.use(setupAuthzDenyAll());
renderEditPage();
@@ -47,7 +68,7 @@ describe('EditRolePage - AuthZ', () => {
});
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantByPrefix('read'));
server.use(setupAuthzDeny(buildRoleUpdatePermission(EDIT_ROLE_NAME)));
renderEditPage();
@@ -55,36 +76,35 @@ describe('EditRolePage - AuthZ', () => {
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
it('checks both read and update permissions for edit mode', () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
expect(mockUseAuthZ).toHaveBeenCalledWith(
expect.arrayContaining([
expect.stringContaining('read'),
expect.stringContaining('update'),
]),
);
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});
it('shows skeleton while checking permissions', async () => {
server.use(
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
res(
ctx.delay(200),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
),
),
);
renderEditPage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders edit page when both read and update permissions granted', async () => {
server.use(setupAuthzAdmin());
renderEditPage();
await expect(
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -4,14 +4,10 @@ import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const rolesApiBase = '*/api/v1/roles';
@@ -32,8 +28,8 @@ const roleWithTransactionGroups = {
};
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
setupAuthzAdmin(),
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(roleWithTransactionGroups)),
),
@@ -41,7 +37,6 @@ beforeEach(() => {
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
@@ -116,9 +111,9 @@ describe('EditRolePage', () => {
renderEditPage();
await waitFor(() => {
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
});
it('navigates to view page when cancel clicked in error state', async () => {
@@ -132,10 +127,8 @@ describe('EditRolePage', () => {
renderEditPage();
await waitFor(async () => {
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
});
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
await expect(
screen.findByTestId('role-details-redirect'),

View File

@@ -1,21 +1,18 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
import { TooltipProvider } from '@signozhq/ui/tooltip';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderPage(): ReturnType<typeof render> {
@@ -37,13 +34,13 @@ function renderPage(): ReturnType<typeof render> {
async function switchToJsonMode(): Promise<void> {
const user = userEvent.setup();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
const jsonRadio = await screen.findByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
}
async function switchToInteractiveMode(): Promise<void> {
const user = userEvent.setup();
const interactiveRadio = screen.getByTestId(
const interactiveRadio = await screen.findByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);

View File

@@ -1,31 +1,28 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = screen.getByTestId('expand-all-button');
const expandButton = await screen.findByTestId('expand-all-button');
await user.click(expandButton);
}
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
function renderPage(): ReturnType<typeof render> {
return render(
async function renderPage(): Promise<ReturnType<typeof render>> {
const result = render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
@@ -39,18 +36,20 @@ function renderPage(): ReturnType<typeof render> {
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('permission-editor');
return result;
}
describe('PermissionEditor', () => {
describe('mode toggle', () => {
it('renders permission editor with testId', () => {
renderPage();
it('renders permission editor with testId', async () => {
await renderPage();
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
});
it('defaults to interactive mode', () => {
renderPage();
it('defaults to interactive mode', async () => {
await renderPage();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
@@ -60,7 +59,7 @@ describe('PermissionEditor', () => {
it('switches to JSON mode when clicked', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -71,7 +70,7 @@ describe('PermissionEditor', () => {
it('switches back to interactive mode', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -87,8 +86,8 @@ describe('PermissionEditor', () => {
});
describe('resource cards', () => {
it('renders all resource cards', () => {
renderPage();
it('renders all resource cards', async () => {
await renderPage();
expect(
screen.getByTestId('resource-card-factor-api-key'),
@@ -99,8 +98,8 @@ describe('PermissionEditor', () => {
).toBeInTheDocument();
});
it('resource cards are collapsed by default', () => {
renderPage();
it('resource cards are collapsed by default', async () => {
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -112,7 +111,7 @@ describe('PermissionEditor', () => {
it('expands resource card when header clicked', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -126,7 +125,7 @@ describe('PermissionEditor', () => {
it('collapses expanded resource card when header clicked again', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -140,7 +139,7 @@ describe('PermissionEditor', () => {
});
it('shows granted count in resource card header', async () => {
renderPage();
await renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
await expect(
@@ -151,7 +150,7 @@ describe('PermissionEditor', () => {
describe('action toggles', () => {
it('renders action toggles for each available action', async () => {
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -170,7 +169,7 @@ describe('PermissionEditor', () => {
});
it('defaults all actions to None scope', async () => {
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -188,7 +187,7 @@ describe('PermissionEditor', () => {
it('changes scope to All when clicked', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -209,7 +208,7 @@ describe('PermissionEditor', () => {
it('updates granted count when scope changed', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -228,7 +227,7 @@ describe('PermissionEditor', () => {
describe('Only Selected scope', () => {
it('shows item input selector when Only Selected is chosen', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -245,7 +244,7 @@ describe('PermissionEditor', () => {
it('adds item when typed and Enter pressed', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -263,7 +262,7 @@ describe('PermissionEditor', () => {
it('adds item when Add button clicked', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -284,7 +283,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by comma', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -304,7 +303,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by space', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -324,7 +323,7 @@ describe('PermissionEditor', () => {
it('does not add duplicate items', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -344,7 +343,7 @@ describe('PermissionEditor', () => {
it('removes item when X clicked', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -367,7 +366,7 @@ describe('PermissionEditor', () => {
it('shows Add button disabled when input is empty', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -385,7 +384,7 @@ describe('PermissionEditor', () => {
describe('scope change confirmation dialog', () => {
it('shows confirm dialog when leaving Only Selected with items', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -407,7 +406,7 @@ describe('PermissionEditor', () => {
it('clears items when confirmed', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -434,7 +433,7 @@ describe('PermissionEditor', () => {
it('keeps items when cancelled', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -461,7 +460,7 @@ describe('PermissionEditor', () => {
it('does not show dialog when leaving Only Selected with no items', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -480,7 +479,7 @@ describe('PermissionEditor', () => {
describe('verbs without Only Selected option', () => {
it('does not show Only Selected for list verb', async () => {
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -501,8 +500,8 @@ describe('PermissionEditor', () => {
});
describe('collapse/expand all resources', () => {
it('shows expand/collapse toggle group', () => {
renderPage();
it('shows expand/collapse toggle group', async () => {
await renderPage();
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
@@ -510,7 +509,7 @@ describe('PermissionEditor', () => {
});
it('expands all cards when expand button clicked', async () => {
renderPage();
await renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -524,7 +523,7 @@ describe('PermissionEditor', () => {
describe('resource card error states', () => {
it('shows error border on collapsed card with validation error', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -554,7 +553,7 @@ describe('PermissionEditor', () => {
it('hides error border when card is expanded', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -591,7 +590,7 @@ describe('PermissionEditor', () => {
it('clears validation error when permission is changed', async () => {
const user = userEvent.setup();
renderPage();
await renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -16,7 +16,6 @@ import {
useRolePermissions,
useUpdateRolePermissions,
} from '../hooks/useRolePermissions';
import { useRoleAuthZ } from '../hooks/useRoleAuthZ';
import {
useRoleUnsavedChanges,
type RoleFormData,
@@ -43,9 +42,6 @@ interface UseCreateEditRolePageCallbacksResult {
saveError: APIError | null;
clearSaveError: () => void;
validationErrors: Set<string>;
hasRequiredPermission: boolean;
isAuthZLoading: boolean;
deniedPermission: string;
}
export function useCreateEditRolePageActions(
@@ -55,23 +51,6 @@ export function useCreateEditRolePageActions(
const history = useHistory();
const isCreateMode = roleId === 'new';
const {
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const deniedPermission = useMemo(() => {
if (isCreateMode) {
return 'role:create';
}
if (roleName) {
return `role:${roleName}:update`;
}
return `role:<missing-rule-name>:update`;
}, [isCreateMode, roleName]);
const [formData, setFormData] = useState<RoleFormData>({
name: '',
description: '',
@@ -261,10 +240,5 @@ export function useCreateEditRolePageActions(
saveError,
clearSaveError,
validationErrors,
hasRequiredPermission: isCreateMode
? hasCreatePermission
: hasReadPermission && hasUpdatePermission,
isAuthZLoading,
deniedPermission,
};
}

View File

@@ -13,7 +13,6 @@ import APIError from 'types/api/error';
interface UseDeleteRoleModalProps {
roleId?: string | null;
isManaged: boolean;
hasDeletePermission: boolean;
onDeleteSuccess?: () => void;
}
@@ -31,7 +30,7 @@ interface UseDeleteRoleModalResult {
export function useDeleteRoleModal(
props: UseDeleteRoleModalProps,
): UseDeleteRoleModalResult {
const { roleId, isManaged, hasDeletePermission, onDeleteSuccess } = props;
const { roleId, isManaged, onDeleteSuccess } = props;
const queryClient = useQueryClient();
const [deleteTargetRoleId, setDeleteTargetRoleId] = useState<string | null>(
@@ -79,10 +78,8 @@ export function useDeleteRoleModal(
const isDeleteModalOpen = deleteTargetRoleId !== null;
const isDeleteDisabled = isManaged || !hasDeletePermission;
const deleteDisabledReason = isManaged
? 'Managed roles cannot be deleted'
: 'You do not have permission to delete this role';
const isDeleteDisabled = isManaged;
const deleteDisabledReason = 'Managed roles cannot be deleted';
return {
isDeleteModalOpen,

View File

@@ -5,12 +5,11 @@ import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { RoleType } from 'types/roles';
@@ -24,23 +23,14 @@ type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesRoleDTO };
interface RolesListingTableProps {
interface RolesListContentProps {
searchQuery: string;
}
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
const { isRolesEnabled } = useRolesFeatureGate();
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
RoleListPermission,
]);
const hasListPermission = listPerms?.[RoleListPermission]?.isGranted ?? false;
const { data, isLoading, isError, error } = useListRoles({
query: { enabled: hasListPermission },
});
const { data, isLoading, isError, error } = useListRoles();
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
@@ -155,11 +145,7 @@ function RolesListingTable({
</>
);
if (!hasListPermission && listPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:list" />;
}
if (isAuthZLoading || isLoading) {
if (isLoading) {
return (
<div className={styles.rolesListingTable}>
<Skeleton active paragraph={{ rows: 5 }} />
@@ -281,4 +267,11 @@ function RolesListingTable({
);
}
export default RolesListingTable;
export default withAuthZPage<RolesListContentProps>(RolesListContent, {
checks: [RoleListPermission],
fallbackOnLoading: (
<div className={styles.rolesListingTable}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
),
});

View File

@@ -1,11 +1,14 @@
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import ROUTES from 'constants/routes';
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import {
RoleCreatePermission,
RoleListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import RolesListingTable from './RolesComponents/RolesListingTable';
@@ -37,24 +40,25 @@ function RolesSettings(): JSX.Element {
</div>
<div className={styles.rolesSettingsContent}>
<div className={styles.rolesSettingsToolbar}>
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
<AuthZTooltip checks={[RoleListPermission]}>
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</AuthZTooltip>
{isRolesEnabled && (
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className={styles.roleSettingsToolbarButton}
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
<AuthZButton
checks={[RoleCreatePermission]}
variant="solid"
color="primary"
className={styles.roleSettingsToolbarButton}
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
>
<Plus size={14} />
Custom role
</AuthZButton>
)}
</div>
<RolesListingTable searchQuery={searchQuery} />

View File

@@ -10,11 +10,17 @@ import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useGetRole } from 'api/generated/services/role';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RoleType } from 'types/roles';
@@ -27,7 +33,7 @@ import { useViewRolePageActions } from './useViewRolePageActions';
import styles from './ViewRolePage.module.scss';
function ViewRolePage(): JSX.Element {
function ViewRolePageContent(): JSX.Element {
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
useRolesFeatureGate();
@@ -45,26 +51,15 @@ function ViewRolePage(): JSX.Element {
handleTabChange,
} = useViewRolePageActions();
const {
hasReadPermission,
readRolePermission,
hasUpdatePermission,
updateRolePermission,
hasDeletePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const { data, isLoading, error } = useGetRole(
{ id: roleId ?? '' },
{ query: { enabled: !!roleId && hasReadPermission } },
{ query: { enabled: !!roleId } },
);
const role = data?.data;
const isManaged = role?.type === RoleType.MANAGED;
const {
isDeleteModalOpen,
isDeleteDisabled,
deleteDisabledReason,
deleteError,
handleOpenDeleteModal,
handleCloseDeleteModal,
@@ -72,7 +67,6 @@ function ViewRolePage(): JSX.Element {
} = useDeleteRoleModal({
roleId,
isManaged: isManaged ?? false,
hasDeletePermission,
onDeleteSuccess: handleCancel,
});
@@ -144,12 +138,6 @@ function ViewRolePage(): JSX.Element {
],
);
if (!hasReadPermission && !isAuthZLoading) {
return (
<PermissionDeniedFullPage permissionName={readRolePermission.object} />
);
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
@@ -187,7 +175,7 @@ function ViewRolePage(): JSX.Element {
);
}
if (isAuthZLoading || isLoading || isFeatureGateLoading) {
if (isLoading || isFeatureGateLoading) {
return (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -244,47 +232,55 @@ function ViewRolePage(): JSX.Element {
</div>
<div className={styles.viewRolePageActions}>
<TooltipSimple
title={isDeleteDisabled ? deleteDisabledReason : 'Open delete modal'}
>
<Button
{isManaged ? (
<TooltipSimple title="Managed roles cannot be deleted">
<Button
variant="link"
color="destructive"
disabled
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleDeletePermission(roleName)]}
variant="link"
color="destructive"
onClick={handleOpenDeleteModal}
disabled={isDeleteDisabled}
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
</TooltipSimple>
</AuthZButton>
)}
<Divider type="vertical" />
<TooltipSimple
title={
isManaged
? 'Managed roles cannot be updated'
: hasUpdatePermission
? 'Open update page'
: `You are not authorized to perform ${updateRolePermission.object}`
}
>
<Button
{isManaged ? (
<TooltipSimple title="Managed roles cannot be updated">
<Button
variant="solid"
color="primary"
disabled
data-testid="save-button"
>
Update
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleUpdatePermission(roleName)]}
variant="solid"
color="primary"
data-testid="save-button"
disabled={isManaged || !hasUpdatePermission}
onClick={handleRedirectToUpdate}
style={
isManaged || !hasUpdatePermission
? { pointerEvents: 'auto' }
: undefined
}
>
Update
</Button>
</TooltipSimple>
</AuthZButton>
)}
</div>
</div>
@@ -336,4 +332,14 @@ function ViewRolePage(): JSX.Element {
);
}
export default ViewRolePage;
export default withAuthZPage(ViewRolePageContent, {
checks: (_props: object, router: RouterContext) => {
const roleName = router.searchParams.get('name') ?? '';
return roleName ? [buildRoleReadPermission(roleName)] : [];
},
fallbackOnLoading: (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
),
});

View File

@@ -37,7 +37,7 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelBtn = screen.getByTestId('cancel-button');
const cancelBtn = await screen.findByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
@@ -61,7 +61,10 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const updateBtn = screen.getByTestId('save-button');
const updateBtn = await screen.findByTestId('save-button');
await waitFor(() => {
expect(updateBtn).not.toBeDisabled();
});
await user.click(updateBtn);
await expect(
@@ -76,7 +79,10 @@ describe('ViewRolePage - Actions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const deleteBtn = screen.getByTestId('delete-button');
const deleteBtn = await screen.findByTestId('delete-button');
await waitFor(() => {
expect(deleteBtn).not.toBeDisabled();
});
await user.click(deleteBtn);
await expect(
@@ -105,7 +111,11 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
await user.click(screen.getByTestId('delete-button'));
const deleteBtn = await screen.findByTestId('delete-button');
await waitFor(() => {
expect(deleteBtn).not.toBeDisabled();
});
await user.click(deleteBtn);
await expect(
screen.findByText(/Are you sure you want to delete the role/),

View File

@@ -1,15 +1,17 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
mockUseAuthZGrantByPrefix,
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
@@ -25,25 +27,15 @@ import {
mockPermissionsData,
} from './testUtils';
const mockUseAuthZGrantReadDeleteDenied = mockUseAuthZGrantByPrefix(
'read',
'update',
);
const mockUseAuthZGrantReadUpdateDenied = mockUseAuthZGrantByPrefix(
'read',
'delete',
);
describe('ViewRolePage - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('permission denied', () => {
it('shows permission denied page when read permission denied', async () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZDenyAll);
server.use(setupAuthzDenyAll());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
@@ -63,10 +55,8 @@ describe('ViewRolePage - AuthZ', () => {
});
describe('update button visibility', () => {
it('enables Update button when update permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
it('enables Update button when update permission granted', async () => {
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -92,13 +82,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
expect(screen.getByTestId('save-button')).toBeInTheDocument();
await expect(
screen.findByTestId('save-button'),
).resolves.toBeInTheDocument();
});
it('disables Update button when update permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
it('disables Update button when update permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'delete'));
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -124,13 +114,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
expect(screen.getByTestId('save-button')).toBeDisabled();
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
});
it('disables Update button when role is managed', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
it('disables Update button when role is managed', async () => {
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -160,15 +150,15 @@ describe('ViewRolePage - AuthZ', () => {
},
);
expect(screen.getByTestId('save-button')).toBeDisabled();
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
});
it('shows managed role tooltip when update button hovered on managed role', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -198,7 +188,7 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const updateButton = screen.getByTestId('save-button');
const updateButton = await screen.findByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
@@ -208,12 +198,8 @@ describe('ViewRolePage - AuthZ', () => {
});
});
it('shows authorization tooltip when update permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
it('disables and shows denial attribute when update permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'delete'));
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -239,22 +225,17 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const updateButton = screen.getByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
/You are not authorized to perform/,
);
const updateButton = screen.getByTestId('save-button');
expect(updateButton).toBeDisabled();
expect(updateButton).toHaveAttribute('data-denied-permissions');
});
});
});
describe('delete button visibility', () => {
it('disables Delete button when delete permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
it('disables Delete button when delete permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'update'));
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -280,88 +261,81 @@ describe('ViewRolePage - AuthZ', () => {
},
);
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('enables Delete button when delete permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
});
it('shows permission denied tooltip when delete permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
const deleteButton = screen.getByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
'You do not have permission to delete this role',
);
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
});
it('enables Delete button when delete permission granted', async () => {
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
});
});
it('disables and shows denial attribute when delete permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'update'));
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof roleApi.useGetRole>);
jest.spyOn(useRolePermissionsModule, 'useRolePermissions').mockReturnValue({
data: mockPermissionsData,
isLoading: false,
isError: false,
error: null,
} as ReturnType<typeof useRolePermissionsModule.useRolePermissions>);
render(
<TooltipProvider>
<ViewRolePage />
</TooltipProvider>,
undefined,
{
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
},
);
await waitFor(() => {
const deleteButton = screen.getByTestId('delete-button');
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute('data-denied-permissions');
});
});
it('shows managed role tooltip when role is managed', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -391,7 +365,7 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const deleteButton = screen.getByTestId('delete-button');
const deleteButton = await screen.findByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
@@ -404,15 +378,9 @@ describe('ViewRolePage - AuthZ', () => {
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
jest.spyOn(useAuthZModule, 'useAuthZ').mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,

View File

@@ -38,28 +38,32 @@ describe('ViewRolePage - Custom Role', () => {
).resolves.toBeInTheDocument();
});
it('shows Update button for custom roles', () => {
it('shows Update button for custom roles', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('save-button')).toBeInTheDocument();
await expect(screen.findByTestId('save-button')).resolves.toBeInTheDocument();
});
it('shows Cancel button', () => {
it('shows Cancel button', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
});
it('shows Delete button', () => {
it('shows Delete button', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
await expect(
screen.findByTestId('delete-button'),
).resolves.toBeInTheDocument();
});
it('renders created/updated timestamps labels', async () => {

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
@@ -16,13 +16,12 @@ import {
describe('ViewRolePage - Edge Cases', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows fallback for missing description', async () => {
@@ -53,7 +52,7 @@ describe('ViewRolePage - Edge Cases', () => {
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
});
it('shows fallback for invalid timestamps', () => {
it('shows fallback for invalid timestamps', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
@@ -79,11 +78,14 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
it('shows fallback for undefined timestamps', () => {
it('shows fallback for undefined timestamps', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
@@ -109,6 +111,9 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});

View File

@@ -1,10 +1,10 @@
import { Route, Switch } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
@@ -18,16 +18,15 @@ import {
describe('ViewRolePage - Error State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('displays error component when API has error but role data exists', () => {
it('displays error component when API has error but role data exists', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -46,7 +45,9 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
});
it('displays error state with title when API fails without role data', async () => {
@@ -64,10 +65,12 @@ describe('ViewRolePage - Error State', () => {
await expect(
screen.findByText('Failed to load role'),
).resolves.toBeInTheDocument();
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
});
it('shows back button on error state', () => {
it('shows back button on error state', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
@@ -79,7 +82,9 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
});
it('navigates to roles list when back button clicked on error state', async () => {
@@ -105,7 +110,7 @@ describe('ViewRolePage - Error State', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelButton = screen.getByTestId('cancel-button');
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
await expect(

View File

@@ -1,10 +1,10 @@
import * as roleApi from 'api/generated/services/role';
import { FeatureKeys } from 'constants/features';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import { server } from 'mocks-server/server';
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
import {
invalidLicense,
mockUseAuthZGrantAll,
setupAuthzAdmin,
} from 'lib/authz/utils/authz-test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -17,9 +17,7 @@ import {
describe('ViewRolePage - Feature Gate', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
@@ -31,6 +29,7 @@ describe('ViewRolePage - Feature Gate', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('feature disabled', () => {
@@ -46,7 +45,9 @@ describe('ViewRolePage - Feature Gate', () => {
},
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -58,28 +59,34 @@ describe('ViewRolePage - Feature Gate', () => {
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', () => {
it('shows back button when feature disabled', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
});
it('back button is enabled when feature disabled', () => {
it('back button is enabled when feature disabled', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
await waitFor(() => {
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
});
});

View File

@@ -1,6 +1,6 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { render } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -13,13 +13,12 @@ import {
describe('ViewRolePage - Loading State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows skeleton while fetching role', () => {

View File

@@ -1,5 +1,5 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -19,7 +19,7 @@ describe('ViewRolePage - Managed Role', () => {
jest.restoreAllMocks();
});
it('disables Delete button for managed roles', () => {
it('disables Delete button for managed roles', async () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -30,10 +30,12 @@ describe('ViewRolePage - Managed Role', () => {
},
);
expect(screen.getByTestId('delete-button')).toBeDisabled();
await waitFor(() => {
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
});
it('disables Update button for managed roles', () => {
it('disables Update button for managed roles', async () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -44,10 +46,12 @@ describe('ViewRolePage - Managed Role', () => {
},
);
expect(screen.getByTestId('save-button')).toBeDisabled();
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
});
it('still shows Cancel button for managed roles', () => {
it('still shows Cancel button for managed roles', async () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -58,6 +62,8 @@ describe('ViewRolePage - Managed Role', () => {
},
);
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from 'tests/test-utils';
@@ -17,8 +17,15 @@ import {
mockPermissionsData,
} from './testUtils';
async function waitForPageReady(): Promise<void> {
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
}
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
await waitForPageReady();
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
@@ -30,6 +37,7 @@ describe('ViewRolePage - Permission Overview', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders Transaction Groups section label', async () => {
@@ -42,19 +50,21 @@ describe('ViewRolePage - Permission Overview', () => {
).resolves.toBeInTheDocument();
});
it('renders permission overview container', () => {
it('renders permission overview container', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
});
it('shows resource permission cards', () => {
it('shows resource permission cards', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-factor-api-key'),
).toBeInTheDocument();
@@ -64,11 +74,12 @@ describe('ViewRolePage - Permission Overview', () => {
).toBeInTheDocument();
});
it('displays granted count for each resource', () => {
it('displays granted count for each resource', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('granted-count-factor-api-key'),
).toBeInTheDocument();
@@ -77,16 +88,15 @@ describe('ViewRolePage - Permission Overview', () => {
describe('ViewRolePage - Permission Overview Loading State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows skeleton when permissions are loading', () => {
it('shows skeleton when permissions are loading', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -105,22 +115,22 @@ describe('ViewRolePage - Permission Overview Loading State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
});
});
describe('ViewRolePage - Permission Overview Error State', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows error when permissions fail to load', () => {
it('shows error when permissions fail to load', async () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -139,19 +149,19 @@ describe('ViewRolePage - Permission Overview Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
});
});
describe('ViewRolePage - Scope: ALL permissions', () => {
beforeEach(() => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "All" badge for actions with ALL scope', async () => {
@@ -182,7 +192,7 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
expect(screen.getByTestId('scope-badge-create')).toHaveTextContent('All');
});
it('shows full granted count when all actions are ALL', () => {
it('shows full granted count when all actions are ALL', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -205,6 +215,7 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
'3 / 3 granted',
);
@@ -212,8 +223,13 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
});
describe('ViewRolePage - Scope: NONE permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "None" badge for actions with NONE scope', async () => {
@@ -244,7 +260,7 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
expect(screen.getByTestId('scope-badge-delete')).toHaveTextContent('None');
});
it('shows zero granted count when all actions are NONE', () => {
it('shows zero granted count when all actions are NONE', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -268,6 +284,7 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'0 / 4 granted',
);
@@ -275,8 +292,13 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
});
describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "Only selected" badge with count', async () => {
@@ -340,7 +362,7 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
await expect(screen.findByText('key-def-456')).resolves.toBeInTheDocument();
});
it('counts ONLY_SELECTED as granted in count', () => {
it('counts ONLY_SELECTED as granted in count', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -362,6 +384,7 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
'1 / 2 granted',
);
@@ -408,8 +431,13 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
});
describe('ViewRolePage - Mixed permission scopes', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders all three scope types in single resource card', async () => {
@@ -458,7 +486,7 @@ describe('ViewRolePage - Mixed permission scopes', () => {
);
});
it('renders multiple resources with different scope combinations', () => {
it('renders multiple resources with different scope combinations', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -502,6 +530,7 @@ describe('ViewRolePage - Mixed permission scopes', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'2 / 2 granted',
);
@@ -515,8 +544,13 @@ describe('ViewRolePage - Mixed permission scopes', () => {
});
describe('ViewRolePage - Unknown resources', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders unknown resource with fallback label', async () => {
@@ -540,6 +574,7 @@ describe('ViewRolePage - Unknown resources', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-future-resource'),
).toBeInTheDocument();
@@ -576,7 +611,7 @@ describe('ViewRolePage - Unknown resources', () => {
).resolves.toBeInTheDocument();
});
it('handles resource with empty actions', () => {
it('handles resource with empty actions', async () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -595,6 +630,7 @@ describe('ViewRolePage - Unknown resources', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-empty-resource'),
).toBeInTheDocument();
@@ -611,13 +647,15 @@ describe('ViewRolePage - View mode toggle', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders Interactive/JSON toggle', () => {
it('renders Interactive/JSON toggle', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-view-mode-list')).toBeInTheDocument();
expect(screen.getByTestId('permission-view-mode-json')).toBeInTheDocument();
});
@@ -629,6 +667,7 @@ describe('ViewRolePage - View mode toggle', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
@@ -645,6 +684,7 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders copy button in JSON view', async () => {
@@ -654,6 +694,7 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
@@ -669,6 +710,7 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);

View File

@@ -3,12 +3,12 @@ import {
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
@@ -79,9 +79,7 @@ export const mockPermissionsData = {
};
export function mockHooksForCustomRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -99,9 +97,7 @@ export function mockHooksForCustomRole(): void {
}
export function mockHooksWithPermissions(permissions: unknown): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -119,9 +115,7 @@ export function mockHooksWithPermissions(permissions: unknown): void {
}
export function mockHooksForManagedRole(): void {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
server.use(setupAuthzAdmin());
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,

View File

@@ -11,23 +11,21 @@ import {
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
invalidLicense,
mockUseAuthZGrantAll,
setupAuthzAdmin,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import RolesSettings from '../RolesSettings';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
setupAuthzAdmin(),
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
@@ -35,7 +33,6 @@ describe('RolesSettings', () => {
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
@@ -273,4 +270,18 @@ describe('RolesSettings', () => {
// Total dashes expected: 2 (for both dates)
expect(dashFallback.length).toBeGreaterThanOrEqual(2);
});
it('disables search input when user lacks list permission', async () => {
server.use(
setupAuthzDeny(RoleListPermission),
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
const searchInput = await screen.findByPlaceholderText('Search for roles...');
expect(searchInput).toBeDisabled();
});
});

View File

@@ -1,88 +0,0 @@
import { useMemo } from 'react';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
ParsedPermissionObject,
parsePermission,
} from 'lib/authz/hooks/useAuthZ/utils';
interface UseRoleAuthZResult {
readRolePermission: ParsedPermissionObject;
updateRolePermission: ParsedPermissionObject;
deleteRolePermission: ParsedPermissionObject;
hasCreatePermission: boolean;
hasReadPermission: boolean;
hasUpdatePermission: boolean;
hasDeletePermission: boolean;
isAuthZLoading: boolean;
}
export function useRoleAuthZ(roleName: string): UseRoleAuthZResult {
const readRolePermissionName = buildRoleReadPermission(roleName);
const updateRolePermissionName = buildRoleUpdatePermission(roleName);
const deleteRolePermissionName = buildRoleDeletePermission(roleName);
const permissionsToCheck = useMemo(() => {
const perms = [RoleCreatePermission];
if (roleName) {
perms.push(
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
);
}
return perms;
}, [
roleName,
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
]);
const { permissions, isLoading: isAuthZLoading } =
useAuthZ(permissionsToCheck);
const hasCreatePermission = useMemo(() => {
if (permissions === null) {
return false;
}
return permissions[RoleCreatePermission]?.isGranted ?? false;
}, [permissions]);
const hasReadPermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleReadPermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasUpdatePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleUpdatePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasDeletePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleDeletePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
return {
readRolePermission: parsePermission(readRolePermissionName),
updateRolePermission: parsePermission(updateRolePermissionName),
deleteRolePermission: parsePermission(deleteRolePermissionName),
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
hasDeletePermission,
isAuthZLoading,
};
}

View File

@@ -28,7 +28,7 @@ describe('ServiceAccountsSettings — FGA', () => {
);
});
it('shows PermissionDeniedFullPage when list permission is denied', async () => {
it('shows denied callout when list permission is denied', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
@@ -47,14 +47,40 @@ describe('ServiceAccountsSettings — FGA', () => {
renderPage();
await waitFor(() => {
expect(
screen.getByText('Uh-oh! You are not authorized'),
).toBeInTheDocument();
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
});
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('shows page header and disables search when list permission is denied', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map(() => false),
),
),
);
}),
);
renderPage();
await waitFor(() => {
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
});
expect(screen.getByText('Service Accounts')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search by name or email...'),
).toBeDisabled();
});
it('shows table when list permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
@@ -78,7 +104,7 @@ describe('ServiceAccountsSettings — FGA', () => {
});
expect(
screen.queryByText('Uh-oh! You are not authorized'),
screen.queryByText(/is not authorized to perform/),
).not.toBeInTheDocument();
});

View File

@@ -44,6 +44,7 @@
display: flex;
align-items: center;
gap: var(--spacing-4);
padding-bottom: var(--spacing-6);
}
&__search {

View File

@@ -1,14 +1,17 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Input } from '@signozhq/ui/input';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import { invalidateListServiceAccounts } from 'api/generated/services/serviceaccount';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import Spinner from 'components/Spinner';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
import ServiceAccountsTable, {
PAGE_SIZE,
@@ -17,7 +20,6 @@ import {
SACreatePermission,
SAListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -38,6 +40,13 @@ import {
import './ServiceAccountsSettings.styles.scss';
function ServiceAccountsSettings(): JSX.Element {
const queryClient = useQueryClient();
const { permissions: authzPermissions, isLoading: isAuthZLoading } = useAuthZ([
SAListPermission,
]);
const canListServiceAccounts =
authzPermissions?.[SAListPermission]?.isGranted ?? false;
const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [currentPage, setPage] = useQueryState(
SA_QUERY_PARAMS.PAGE,
parseAsInteger.withDefault(1),
@@ -52,25 +61,19 @@ function ServiceAccountsSettings(): JSX.Element {
FilterMode.All,
),
);
const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [, setIsCreateModalOpen] = useQueryState(
SA_QUERY_PARAMS.CREATE_SA,
parseAsBoolean.withDefault(false),
);
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
SAListPermission,
]);
const hasListPermission = listPerms?.[SAListPermission]?.isGranted ?? false;
const {
data: serviceAccountsData,
isLoading,
isError,
error,
refetch: handleCreateSuccess,
} = useListServiceAccounts({ query: { enabled: hasListPermission } });
} = useListServiceAccounts({ query: { enabled: canListServiceAccounts } });
const controlsDisabled = isAuthZLoading || !canListServiceAccounts;
const allAccounts = useMemo(
(): ServiceAccountRow[] =>
@@ -199,9 +202,9 @@ function ServiceAccountsSettings(): JSX.Element {
if (options?.closeDrawer) {
void setSelectedAccountId(null);
}
void handleCreateSuccess();
void invalidateListServiceAccounts(queryClient);
},
[handleCreateSuccess, setSelectedAccountId],
[queryClient, setSelectedAccountId],
);
return (
@@ -223,31 +226,32 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
</div>
{isAuthZLoading || isLoading ? (
<Spinner height="50vh" />
) : !hasListPermission ? (
<PermissionDeniedFullPage permissionName="serviceaccount:list" />
) : (
<div className="sa-settings__list-section">
<div className="sa-settings__controls">
<DropdownMenuSimple
menu={{ items: filterMenuItems }}
className="sa-settings-filter-dropdown"
>
<Button
variant="solid"
color="secondary"
className="sa-settings-filter-trigger"
<div className="sa-settings__list-section">
<div className="sa-settings__controls">
<AuthZTooltip checks={[SAListPermission]}>
<span>
<DropdownMenuSimple
menu={{ items: filterMenuItems }}
className="sa-settings-filter-dropdown"
>
<span>{filterLabel}</span>
<ChevronDown
size={12}
className="sa-settings-filter-trigger__chevron"
/>
</Button>
</DropdownMenuSimple>
<Button
variant="solid"
color="secondary"
className="sa-settings-filter-trigger"
disabled={controlsDisabled}
>
<span>{filterLabel}</span>
<ChevronDown
size={12}
className="sa-settings-filter-trigger__chevron"
/>
</Button>
</DropdownMenuSimple>
</span>
</AuthZTooltip>
<div className="sa-settings__search">
<div className="sa-settings__search">
<AuthZTooltip checks={[SAListPermission]}>
<Input
type="search"
name="service-accounts-search"
@@ -258,23 +262,25 @@ function ServiceAccountsSettings(): JSX.Element {
void setPage(1);
}}
className="sa-settings-search-input"
disabled={controlsDisabled}
/>
</div>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</AuthZTooltip>
</div>
<AuthZButton
checks={[SACreatePermission]}
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</AuthZButton>
</div>
<AuthZGuardContent checks={[SAListPermission]}>
{isError ? (
<ErrorInPlace
error={toAPIError(
@@ -289,8 +295,8 @@ function ServiceAccountsSettings(): JSX.Element {
onRowClick={handleRowClick}
/>
)}
</div>
)}
</AuthZGuardContent>
</div>
<CreateServiceAccountModal />

View File

@@ -1,4 +1,3 @@
import type { ReactNode } from 'react';
import userEvent from '@testing-library/user-event';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
@@ -14,46 +13,6 @@ const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const ROLES_ENDPOINT = '*/api/v1/roles';
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/dialog', () => ({
...jest.requireActual('@signozhq/ui/dialog'),
DialogWrapper: ({
children,
open,
title,
}: {
children?: ReactNode;
open: boolean;
title?: string;
}): JSX.Element | null =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
const mockServiceAccountsAPI = [
{
id: 'sa-1',
@@ -173,11 +132,11 @@ describe('ServiceAccountsSettings (integration)', () => {
</NuqsTestingAdapter>,
);
fireEvent.click(
await screen.findByRole('button', {
name: /View service account CI Bot/i,
}),
);
const viewButton = await screen.findByRole('button', {
name: /View service account CI Bot/i,
});
fireEvent.click(viewButton);
await expect(
screen.findByRole('button', { name: /Delete Service Account/i }),

View File

@@ -0,0 +1,94 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
PublicDashboardSchema,
useGetResolvedPublicDashboard,
} from '../useGetResolvedPublicDashboard';
jest.mock('api/generated/services/dashboard', () => ({
getPublicDashboardDataV2: jest.fn(),
}));
jest.mock('api/dashboard/public/getPublicDashboardData', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockV2 = getPublicDashboardDataV2 as jest.Mock;
const mockV1 = getPublicDashboardDataAPI as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
// A schema mismatch on the v2 endpoint surfaces as an AxiosError with HTTP 501 and this
// error code; anything else must NOT trigger the v1 fallback.
const schemaMismatchError = {
isAxiosError: true,
response: {
status: 501,
data: { error: { code: 'dashboard_invalid_data', message: 'not in v6' } },
},
};
describe('useGetResolvedPublicDashboard', () => {
beforeEach(() => {
mockV2.mockReset();
mockV1.mockReset();
});
it('returns the v2 model when the v2 endpoint succeeds and never calls v1', async () => {
mockV2.mockResolvedValue({ status: 'success', data: { dashboard: {} } });
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-1'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V2);
expect(mockV2).toHaveBeenCalledWith({ id: 'id-1' });
expect(mockV1).not.toHaveBeenCalled();
});
it('falls back to v1 when the v2 endpoint reports a schema mismatch', async () => {
mockV2.mockRejectedValue(schemaMismatchError);
mockV1.mockResolvedValue({
httpStatusCode: 200,
data: { dashboard: { data: { title: 'v1' } } },
});
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-2'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.schema).toBe(PublicDashboardSchema.V1);
expect(mockV1).toHaveBeenCalledWith({ id: 'id-2' });
});
it('surfaces a non-schema-mismatch v2 error without falling back to v1', async () => {
mockV2.mockRejectedValue({
isAxiosError: true,
response: { status: 500, data: { error: { code: 'internal' } } },
});
const { result } = renderHook(() => useGetResolvedPublicDashboard('id-3'), {
wrapper,
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockV1).not.toHaveBeenCalled();
});
it('does not fetch without an id', () => {
renderHook(() => useGetResolvedPublicDashboard(''), { wrapper });
expect(mockV2).not.toHaveBeenCalled();
expect(mockV1).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { getPublicDashboardDataV2 } from 'api/generated/services/dashboard';
import { DashboardtypesGettablePublicDashboardDataV2DTO } from 'api/generated/services/sigNoz.schemas';
import getPublicDashboardDataAPI from 'api/dashboard/public/getPublicDashboardData';
import { AxiosError, isAxiosError } from 'axios';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { ErrorV2Resp } from 'types/api';
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
export enum PublicDashboardSchema {
V1 = 'v1',
V2 = 'v2',
}
export type ResolvedPublicDashboard =
| {
schema: PublicDashboardSchema.V2;
data: DashboardtypesGettablePublicDashboardDataV2DTO;
}
| { schema: PublicDashboardSchema.V1; data: PublicDashboardDataProps };
// The v2 endpoint rejects non-v6 rows with this code — our signal that it's a v1 dashboard.
const V2_SCHEMA_MISMATCH_CODE = 'dashboard_invalid_data';
function isV2SchemaMismatch(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
const { response } = error as AxiosError<ErrorV2Resp>;
return response?.data?.error?.code === V2_SCHEMA_MISMATCH_CODE;
}
// Probe v2 first, fall back to v1 only on a schema mismatch. v1-first is unsafe: it 200s for a
// v2 dashboard with queries un-redacted. Other v2 errors re-throw rather than mis-render as v1.
async function resolvePublicDashboard(
id: string,
): Promise<ResolvedPublicDashboard> {
try {
const v2 = await getPublicDashboardDataV2({ id });
return { schema: PublicDashboardSchema.V2, data: v2.data };
} catch (error) {
if (!isV2SchemaMismatch(error)) {
throw error;
}
const v1 = await getPublicDashboardDataAPI({ id });
return { schema: PublicDashboardSchema.V1, data: v1.data };
}
}
export const useGetResolvedPublicDashboard = (
id: string,
): UseQueryResult<ResolvedPublicDashboard, Error> =>
useQuery<ResolvedPublicDashboard, Error>({
queryFn: () => resolvePublicDashboard(id),
queryKey: [REACT_QUERY_KEY.GET_PUBLIC_DASHBOARD_RESOLVED, id],
enabled: !!id,
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import debounce from 'lodash-es/debounce';
export type Dimensions = {
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
height: ref.current?.clientHeight || 0,
});
// Measure before paint so the first frame has real dimensions, not 0 (the
// debounced observer below only catches up after paint → layout jump).
useLayoutEffect(() => {
const node = ref.current;
if (node) {
setSize({ width: node.clientWidth, height: node.clientHeight });
}
}, [ref]);
useEffect(() => {
const handleResize = debounce(
(entries: ResizeObserverEntry[]) => {

View File

@@ -20,23 +20,12 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.all-errors-quick-filter-section {
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
width: 260px;
}
.all-errors-right-section {
flex: 1;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -18,18 +18,12 @@ import Toolbar from 'container/Toolbar/Toolbar';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { routes } from './config';
import { useAllErrorsQueryState } from './QueryStateContext';
import './AllErrors.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function AllErrors(): JSX.Element {
const { pathname } = useLocation();
const { handleRunQuery } = useQueryBuilder();
@@ -61,38 +55,17 @@ function AllErrors(): JSX.Element {
setShowFilters((prev) => !prev);
};
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="all-errors-quick-filter-section"
handleTestId="quick-filters-resize-handle"
>
<section className={cx('all-errors-quick-filter-section')}>
<QuickFilters
className="qf-exceptions"
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</ResizableBox>
</section>
)}
<section
className={cx(

View File

@@ -1,4 +1,4 @@
import { KeyboardEvent } from 'react';
import { type FocusEvent, KeyboardEvent } from 'react';
import {
Check,
Globe,
@@ -90,12 +90,20 @@ function DashboardInfo({
}
};
// Clicking outside the editor commits, matching the input's Enter behaviour.
// Guard against blurs that move focus to the Save/Cancel buttons within it.
const onEditorBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
onCommit();
}
};
return (
<div className={styles.dashboardInfo}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}

View File

@@ -0,0 +1,32 @@
.trigger {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.options {
min-width: min-content;
}
.item {
width: min-content;
span {
vertical-align: middle;
}
}
.image {
height: 16px;
width: 16px;
}

View File

@@ -0,0 +1,43 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import cx from 'classnames';
import { Base64Icons } from '../utils';
import styles from './DashboardImagePicker.module.scss';
interface Props {
// The selected image — one of the base64 icon data-URIs.
image: string;
onChange: (value: string) => void;
// Consumers set the trigger's border-radius (e.g. rounded-left when joined to
// a name input); this component owns size / background / icon-only styling.
triggerClassName?: string;
}
// Icon picker shared by the dashboard-details settings and the create-dashboard
// modal so both choose from the same `Base64Icons` set.
function DashboardImagePicker({
image,
onChange,
triggerClassName,
}: Props): JSX.Element {
return (
<Select value={image} onChange={(value): void => onChange(value as string)}>
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
<SelectContent className={styles.options} withPortal={false}>
{Base64Icons.map((icon) => (
<SelectItem key={icon} value={icon} className={styles.item}>
<img src={icon} alt="dashboard-icon" className={styles.image} />
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DashboardImagePicker;

View File

@@ -25,38 +25,10 @@
}
}
// The image-picker owns its size/background; here we only round the left edge so
// it joins flush with the name input to its right.
.dashboardImageInput {
display: flex;
width: 32px;
min-width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
background: var(--l3-background);
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
svg {
display: none;
}
}
.dashboardImageOptions {
min-width: min-content;
}
.dashboardImageSelectItem {
width: min-content;
span {
vertical-align: middle;
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {

View File

@@ -1,17 +1,11 @@
import { Dispatch, SetStateAction } from 'react';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { Base64Icons } from '../utils';
import DashboardImagePicker from '../DashboardImagePicker/DashboardImagePicker';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
@@ -43,30 +37,11 @@ function DashboardInfoForm({
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
value={image}
onChange={(value): void => onImageChange(value as string)}
>
<SelectTrigger className={styles.dashboardImageInput} />
<SelectContent
className={styles.dashboardImageOptions}
withPortal={false}
>
{Base64Icons.map((icon) => (
<SelectItem
key={icon}
value={icon}
className={styles.dashboardImageSelectItem}
>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<DashboardImagePicker
image={image}
onChange={onImageChange}
triggerClassName={styles.dashboardImageInput}
/>
<Input
testId="dashboard-name"

View File

@@ -83,7 +83,18 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
);
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));
// `replace` fails when the image doesn't exist yet, so add it when the
// dashboard has none (`add` creates or replaces the member). Key off the
// raw stored value, not the `Base64Icons[0]`-defaulted local `image`.
ops.push(
op(
dashboard.image
? DashboardtypesPatchOpDTO.replace
: DashboardtypesPatchOpDTO.add,
'/image',
updatedImage,
),
);
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
@@ -96,6 +107,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
description,
updatedImage,
image,
dashboard.image,
updatedTags,
tagsAsStrings,
]);

View File

@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';

View File

@@ -5,7 +5,7 @@ import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [

View File

@@ -1,4 +1,4 @@
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import {
clearSeriesColor,
filterLegendSeries,

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(

View File

@@ -1,7 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -49,6 +49,23 @@ describe('ContextLinksSection utils', () => {
{ key: 'q', value: '{{x}}' },
]);
});
it('treats undecodable percent sequences as literal text instead of throwing', () => {
expect(getUrlParams('/logs?search=95%')).toStrictEqual([
{ key: 'search', value: '95%' },
]);
expect(getUrlParams('/logs?95%=value')).toStrictEqual([
{ key: '95%', value: 'value' },
]);
});
it('decodes a valid escape once even when the result has a stray percent', () => {
// %2525 double-decodes to '%'; 95%25 decodes once to '95%' and stops there
expect(getUrlParams('/logs?a=95%25&b=%2525')).toStrictEqual([
{ key: 'a', value: '95%' },
{ key: 'b', value: '%' },
]);
});
});
describe('updateUrlWithParams', () => {

View File

@@ -29,18 +29,22 @@ export function insertVariableAtCursor(
);
}
// Values may be double-encoded on the wire; decode a second time only when it changes
// the string, so already-single-encoded values are left intact.
function decodeForDisplay(value: string): string {
const decoded = decodeURIComponent(value);
// Users can type raw `%` into the URL field (e.g. `?q=95%`), which is not valid
// percent-encoding — treat undecodable input as literal text instead of throwing.
function safeDecodeURIComponent(value: string): string {
try {
const doubleDecoded = decodeURIComponent(decoded);
return doubleDecoded !== decoded ? doubleDecoded : decoded;
return decodeURIComponent(value);
} catch {
return decoded;
return value;
}
}
// Values may be double-encoded on the wire, so decode twice; a second decode is a
// no-op once nothing is left to unescape, leaving single-encoded values intact.
function decodeForDisplay(value: string): string {
return safeDecodeURIComponent(safeDecodeURIComponent(value));
}
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
export function getUrlParams(url: string): UrlParam[] {
const [, queryString] = url.split('?');
@@ -53,7 +57,7 @@ export function getUrlParams(url: string): UrlParam[] {
const [key, value] = pair.split('=');
if (key) {
params.push({
key: decodeURIComponent(key),
key: safeDecodeURIComponent(key),
value: decodeForDisplay(value || ''),
});
}

View File

@@ -11,6 +11,8 @@ interface ColumnUnitsProps {
columns: TableColumnOption[];
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
value: Record<string, string>;
/** Unit the selected metric was sent with; each column warns if its unit mismatches. */
metricUnit?: string;
onChange: (next: Record<string, string>) => void;
}
@@ -23,6 +25,7 @@ interface ColumnUnitsProps {
function ColumnUnits({
columns,
value,
metricUnit,
onChange,
}: ColumnUnitsProps): JSX.Element {
if (columns.length === 0) {
@@ -53,6 +56,7 @@ function ColumnUnits({
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
value={value[column.key]}
initialValue={metricUnit}
containerClassName={styles.columnUnitSelector}
onChange={(unit): void => setUnit(column.key, unit)}
/>

View File

@@ -81,6 +81,7 @@ function FormattingSection({
<ColumnUnits
columns={tableColumns}
value={value?.columnUnits ?? {}}
metricUnit={metricUnit}
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
/>
</div>

View File

@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
@@ -100,4 +100,33 @@ describe('FormattingSection', () => {
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
it('warns when a column unit mismatches the metric unit', () => {
// metric sent in seconds, but the column is set to bytes.
render(
<FormattingSection
value={{ columnUnits: { A: 'By' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.getByLabelText('warning')).toBeInTheDocument();
});
it('shows no warning when the column unit matches the metric unit', () => {
render(
<FormattingSection
value={{ columnUnits: { A: 's' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
});

View File

@@ -7,4 +7,9 @@
.dropdown {
width: 260px;
/* The dropdown portals to body; lift it above the query builder's antd
* popups (1050) so it stays clickable, incl. inside the View modal.
* TODO: remove after the antd -> @signozhq/ui migration
**/
z-index: 1100;
}

View File

@@ -63,7 +63,7 @@ function AddColumnDropdown({
<Plus size={16} />
</Button>
</ComboboxTrigger>
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
<ComboboxContent arrow side="bottom" align="end" className={styles.dropdown}>
<ComboboxCommand shouldFilter={false}>
<ComboboxInput
value={searchText}

View File

@@ -10,7 +10,9 @@
}
.scrollArea {
padding: 12px;
// Top padding lives on the sticky tab nav instead, so the nav keeps its
// breathing room both at rest and when pinned to the top.
padding: 0 12px 12px 12px;
}
.tabsContainer {
@@ -24,11 +26,22 @@
background-color: var(--l1-background) !important;
}
:global(.ant-tabs-nav) {
// `padding-top` owns the nav's top spacing (moved off `.scrollArea`).
padding-top: 12px;
background-color: var(--l1-background);
&::before {
border-color: var(--l2-border);
}
}
}
// Opt-in pin to the top of the scroll area; the View modal opts out (shares a scroll area with its own header).
.stickyNav :global(.ant-tabs-nav) {
position: sticky;
top: 0px;
z-index: 1100;
}
.queryTypeTab {
display: flex;
align-items: center;

View File

@@ -7,6 +7,7 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import PromQLIcon from 'assets/Dashboard/PromQl';
@@ -45,6 +46,8 @@ interface PanelEditorQueryBuilderProps {
onCancelQuery: () => void;
/** Optional content pinned below the builder (e.g. the List columns editor). */
footer?: ReactNode;
/** Pin the tabs + Run Query row to the top of the scroll area. Off in the View modal, which shares a scroll area with its own header. */
stickyHeader?: boolean;
}
/**
@@ -59,6 +62,7 @@ function PanelEditorQueryBuilder({
onStageRunQuery,
onCancelQuery,
footer,
stickyHeader = true,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
@@ -156,7 +160,9 @@ function PanelEditorQueryBuilder({
<div className={styles.scrollArea}>
<Tabs
type="card"
className={styles.tabsContainer}
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
tabBarExtraContent={

View File

@@ -5,6 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
/**
* Characterization test for the editor's composition: which derived values and
@@ -19,7 +20,7 @@ const mockRefetch = jest.fn();
const mockCancelQuery = jest.fn();
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
const mockOnChangePanelKind = jest.fn();
const mockSave = jest.fn().mockResolvedValue(undefined);
const mockSave = jest.fn().mockResolvedValue('panel-1');
const mockUseDraft = jest.fn();
jest.mock('../hooks/usePanelEditorDraft', () => ({
@@ -61,8 +62,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
jest.mock('../hooks/useTableColumns', () => ({
useTableColumns: (): [] => [],
}));
jest.mock('../hooks/useMetricYAxisUnit', () => ({
useMetricYAxisUnit: (): unknown => ({
jest.mock('../hooks/useSeedMetricUnit', () => ({
useSeedMetricUnit: (): unknown => ({
metricUnit: undefined,
isLoading: false,
}),
@@ -104,12 +105,17 @@ jest.mock('@signozhq/ui/sonner', () => ({
const mockHeaderProps = jest.fn();
jest.mock('../Header/Header', () => ({
__esModule: true,
default: (props: { onSave: () => void }): JSX.Element => {
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
mockHeaderProps(props);
return (
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
<>
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
<button type="button" data-testid="editor-close" onClick={props.onClose}>
close
</button>
</>
);
},
}));
@@ -196,7 +202,10 @@ function setup(
}
describe('PanelEditorContainer composition', () => {
beforeEach(() => jest.clearAllMocks());
beforeEach(() => {
jest.clearAllMocks();
useScrollIntoViewStore.setState({ scrollTargetId: null });
});
it('renders the editor shell with preview, query builder, and config pane', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
@@ -299,6 +308,32 @@ describe('PanelEditorContainer composition', () => {
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() =>
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1'),
);
});
it('marks an existing panel to be revealed when the editor is closed', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-close'));
expect(useScrollIntoViewStore.getState().scrollTargetId).toBe('panel-1');
});
it('does not mark a scroll target when a new, unsaved panel is closed', async () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
await userEvent.click(screen.getByTestId('editor-close'));
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
it('offers Switch to View Mode for an existing panel', () => {
setup(makePanel('signoz/TimeSeriesPanel'));

View File

@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
@@ -29,18 +30,36 @@ jest.mock(
() => ({
flattenTimeSeries: jest.fn(),
getTimeSeriesResults: jest.fn(() => []),
getScalarResults: jest.fn(() => []),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn(() => []) }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/PieChartPanel/prepareData',
() => ({ preparePieData: jest.fn(() => []) }),
);
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const mockPreparePie = preparePieData as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const PIE_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/PieChartPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -88,6 +107,32 @@ describe('useLegendSeries', () => {
]);
});
it('resolves histogram panels via the time-series path', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
const { result } = renderHook(() => useLegendSeries(HISTOGRAM_PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'a', defaultColor: 'color:a' },
{ label: 'b', defaultColor: 'color:b' },
]);
// The pie path must not run for a histogram panel.
expect(mockPreparePie).not.toHaveBeenCalled();
});
it('resolves pie panels from their scalar slices, deduped by label', () => {
mockPreparePie.mockReturnValue([
{ label: 'x', color: 'c1' },
{ label: 'y', color: 'c2' },
{ label: 'x', color: 'c1' },
]);
const { result } = renderHook(() => useLegendSeries(PIE_PANEL, DATA));
expect(result.current).toStrictEqual([
{ label: 'x', defaultColor: 'c1' },
{ label: 'y', defaultColor: 'c2' },
]);
// The time-series path must not run for a pie panel.
expect(mockFlatten).not.toHaveBeenCalled();
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));

View File

@@ -1,103 +0,0 @@
import { renderHook } from '@testing-library/react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
describe('useMetricYAxisUnit', () => {
beforeEach(() => jest.clearAllMocks());
it('seeds the unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the metric has no unit', () => {
mockMetricUnit(undefined);
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
const { rerender } = renderHook(
(props: { unit: string | undefined }) =>
useMetricYAxisUnit({
isNewPanel: true,
unit: props.unit,
onSelectUnit,
}),
{ initialProps: { unit: undefined as string | undefined } },
);
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
// The metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ unit: 'bytes' });
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useMetricYAxisUnit({
isNewPanel: false,
unit: undefined,
onSelectUnit: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -247,11 +247,13 @@ describe('usePanelEditorQuerySync', () => {
});
describe('datasource switch', () => {
const withSource = (id: string, dataSource: string): Query =>
const withSource = (id: string, ...dataSources: string[]): Query =>
({
id,
queryType: 'builder',
builder: { queryData: [{ dataSource }] },
builder: {
queryData: dataSources.map((dataSource) => ({ dataSource })),
},
}) as unknown as Query;
it('commits the active query when a query datasource changes', () => {
@@ -286,6 +288,58 @@ describe('usePanelEditorQuerySync', () => {
expect(setSpec).not.toHaveBeenCalled();
});
it('does not commit when a query is added (the fresh query must not auto-run)', () => {
const state = builderState({ currentQuery: withSource('a', 'metrics') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
state.currentQuery = withSource('b', 'metrics', 'metrics');
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
it('commits when a query is removed', () => {
const state = builderState({
currentQuery: withSource('a', 'metrics', 'logs'),
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
state.currentQuery = withSource('b', 'metrics');
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('commits a datasource switch on a query added after mount', () => {
const state = builderState({ currentQuery: withSource('a', 'metrics') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
state.currentQuery = withSource('b', 'metrics', 'metrics');
rerender();
state.currentQuery = withSource('c', 'metrics', 'logs');
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
});
describe('query dirty + save', () => {

Some files were not shown because too many files have changed in this diff Show More