mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-30 15:20:45 +01:00
Compare commits
21 Commits
main
...
feat/saved
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15866d408 | ||
|
|
748a76dba3 | ||
|
|
79c475ec8c | ||
|
|
1b7a698a50 | ||
|
|
9cb27b0bc1 | ||
|
|
09698f8f6e | ||
|
|
14af7fbe10 | ||
|
|
65ae1c4489 | ||
|
|
dae8ee4291 | ||
|
|
6ba4a01989 | ||
|
|
0a8882739d | ||
|
|
93cdb3b540 | ||
|
|
b563cfca2b | ||
|
|
e99cf0ba3f | ||
|
|
5ccdde5fc7 | ||
|
|
3722f60195 | ||
|
|
20fa9fe0aa | ||
|
|
2597315122 | ||
|
|
6545b173f9 | ||
|
|
f86ba992a8 | ||
|
|
be3f6287c2 |
@@ -1771,15 +1771,12 @@ components:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
timestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- timestampMillis
|
||||
- data
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
@@ -2024,8 +2021,6 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
required:
|
||||
- account_id
|
||||
- cloud_account_id
|
||||
@@ -2035,7 +2030,6 @@ components:
|
||||
- providerAccountId
|
||||
- integrationConfig
|
||||
- removedAt
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesGettableServicesMetadata:
|
||||
properties:
|
||||
@@ -2135,9 +2129,6 @@ components:
|
||||
type: object
|
||||
providerAccountId:
|
||||
type: string
|
||||
syncedVersion:
|
||||
nullable: true
|
||||
type: integer
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
@@ -2150,18 +2141,6 @@ components:
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesRegionState:
|
||||
enum:
|
||||
- enabled
|
||||
- disabled
|
||||
type: string
|
||||
CloudintegrationtypesRegionSyncState:
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionState'
|
||||
required:
|
||||
- state
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
assets:
|
||||
@@ -2303,23 +2282,6 @@ components:
|
||||
metrics:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesSyncState:
|
||||
nullable: true
|
||||
properties:
|
||||
inSync:
|
||||
type: boolean
|
||||
regions:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionSyncState'
|
||||
type: object
|
||||
version:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- version
|
||||
- inSync
|
||||
- regions
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
|
||||
@@ -118,7 +118,7 @@ router.Handle("/api/v1/service_accounts", handler.New(
|
||||
The pieces:
|
||||
|
||||
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyField(func(req *T) string)` / `BodyFields(func(req *T) []string)` reading the request body the resource middleware decoded into the route's `OpenAPIDef.Request` type `T`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`). A handler on such a route reads the same decoded value with `coretypes.BodyFromContext[T](r.Context())`.
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
@@ -183,52 +183,32 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := cloudintegrationtypes.NewAccountFromStorable(storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
// The agent uses this response to clean up resources
|
||||
if account.RemovedAt != nil {
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
// update account with cloud provider account id and agent report (heartbeat)
|
||||
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
err = module.store.UpdateAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
// Get account as domain object for config access (enabled regions, etc.)
|
||||
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,7 +223,8 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, account, storedServices)
|
||||
// Delegate integration config building entirely to the provider module
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,7 +234,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
account.ID.StringValue(),
|
||||
integrationConfig,
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
|
||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -62,6 +62,9 @@ importers:
|
||||
'@grafana/data':
|
||||
specifier: ^11.6.15
|
||||
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@lezer/highlight':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
|
||||
@@ -3396,37 +3396,6 @@ export interface CloudintegrationtypesAWSServiceConfigDTO {
|
||||
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesRegionStateDTO {
|
||||
enabled = 'enabled',
|
||||
disabled = 'disabled',
|
||||
}
|
||||
export interface CloudintegrationtypesRegionSyncStateDTO {
|
||||
state: CloudintegrationtypesRegionStateDTO;
|
||||
}
|
||||
|
||||
export type CloudintegrationtypesSyncStateDTORegions = {
|
||||
[key: string]: CloudintegrationtypesRegionSyncStateDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesSyncStateDTO = {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
inSync: boolean;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
regions: CloudintegrationtypesSyncStateDTORegions;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
version: number;
|
||||
} | null;
|
||||
|
||||
export type CloudintegrationtypesAgentReportDTODataAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -3445,7 +3414,6 @@ export type CloudintegrationtypesAgentReportDTO = {
|
||||
* @type object,null
|
||||
*/
|
||||
data: CloudintegrationtypesAgentReportDTOData;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -3874,7 +3842,6 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
removedAt: string | null;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceMetadataDTO {
|
||||
@@ -3945,10 +3912,6 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
* @type string
|
||||
*/
|
||||
providerAccountId?: string;
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
syncedVersion?: number | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
clip-path: inset(0 -100vw 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) 10.5px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.left-actions {
|
||||
@@ -45,6 +46,9 @@
|
||||
justify-content: flex-start;
|
||||
|
||||
.text {
|
||||
padding-left: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -77,6 +81,8 @@
|
||||
}
|
||||
|
||||
.select-box {
|
||||
--combobox-trigger-width: auto;
|
||||
|
||||
display: flex;
|
||||
padding: 2px 5px;
|
||||
flex-direction: row;
|
||||
@@ -94,10 +100,9 @@
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
--button-variant-link-color: var(--l1-foreground);
|
||||
|
||||
.divider-filter {
|
||||
width: 1px;
|
||||
@@ -105,26 +110,8 @@
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-action-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.settings-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
.active {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Filter,
|
||||
Frown,
|
||||
RefreshCw,
|
||||
Settings2 as SettingsIcon,
|
||||
@@ -14,8 +13,9 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -57,6 +57,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
savedViewsHeader,
|
||||
} = props;
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
@@ -179,7 +180,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
// Helpers to reduce cognitive complexity in main render
|
||||
const renderLeftActions = (): JSX.Element => (
|
||||
<section className="left-actions">
|
||||
<Filter size="md" />
|
||||
<Typography.Text className="text">
|
||||
{displayedQueryName ? 'Filters for' : 'Filters'}
|
||||
</Typography.Text>
|
||||
@@ -216,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
</Combobox>
|
||||
) : (
|
||||
displayedQueryName && (
|
||||
<Tooltip
|
||||
<TooltipSimple
|
||||
title={`Filter currently in sync with query ${displayedQueryName}`}
|
||||
>
|
||||
<Typography.Text className="sync-tag">
|
||||
{displayedQueryName}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
@@ -230,51 +230,54 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderRightActions = (): JSX.Element => (
|
||||
<section className="right-actions">
|
||||
<Tooltip title="Reset All">
|
||||
<TooltipSimple title="Reset All">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Reset All"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleReset}
|
||||
prefix={<RefreshCw className="sync-icon" size="md" />}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
{showFilterCollapse && (
|
||||
<Tooltip title="Collapse Filters">
|
||||
<TooltipSimple title="Collapse Filters">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Collapse Filters"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDynamicFilters && (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
aria-label="Settings"
|
||||
className={classNames('right-action-icon-container', {
|
||||
active: isSettingsOpen,
|
||||
})}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={
|
||||
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
|
||||
<SettingsIcon
|
||||
className="settings-icon"
|
||||
data-testid="settings-icon"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{isDynamicFilters &&
|
||||
(isSettingsDisabled ? (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title="Settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
className={classNames({ active: isSettingsOpen })}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
data-testid="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -379,6 +382,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<div className="quick-filters-container">
|
||||
<div className="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
<section className="header">
|
||||
{renderLeftActions()}
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -21,6 +19,44 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.savedViews {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--l1-background);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.quickFilters {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
z-index: 1;
|
||||
|
||||
&.isOpen {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// No quick filters: the header sits in flow and the list stays under it
|
||||
.isStatic {
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
.quickFilters {
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.savedViews {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
@@ -31,3 +67,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import { ComponentProps, ReactNode, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
@@ -12,8 +17,15 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
// What the page configures; the layout adds the open / close wiring.
|
||||
type SavedViewsElementProps = Omit<
|
||||
ComponentProps<typeof SavedViewsPanel>,
|
||||
'onClose'
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
quickFilterProps?: QuickFiltersElementProps;
|
||||
savedViewProps?: SavedViewsElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
@@ -23,20 +35,67 @@ export interface QuickFiltersLayoutProps {
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
savedViewProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const hasQuickFilters = !!quickFilterProps;
|
||||
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
|
||||
|
||||
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
|
||||
|
||||
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsListOpen;
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
<TooltipProvider delayDuration={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<div
|
||||
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
|
||||
data-testid="quick-filters-layout-drawer"
|
||||
>
|
||||
{hasQuickFilters ? (
|
||||
<QuickFilters
|
||||
{...quickFilterProps}
|
||||
savedViewsHeader={savedViewsHeader}
|
||||
/>
|
||||
) : (
|
||||
savedViewsHeader
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
@@ -44,7 +103,7 @@ function QuickFiltersLayout({
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div>{children}</div>
|
||||
<div className={styles.contentInner}>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
@@ -7,15 +9,33 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
sidebar: 'sidebar',
|
||||
isStatic: 'isStatic',
|
||||
savedViews: 'savedViews',
|
||||
quickFilters: 'quickFilters',
|
||||
isOpen: 'isOpen',
|
||||
content: 'content',
|
||||
contentInner: 'contentInner',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSavedViewEnabled', () => ({
|
||||
useSavedViewEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
default: ({
|
||||
source,
|
||||
savedViewsHeader,
|
||||
}: {
|
||||
source: string;
|
||||
savedViewsHeader?: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -23,8 +43,16 @@ const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
const drawer = (): HTMLElement =>
|
||||
screen.getByTestId('quick-filters-layout-drawer');
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
@@ -40,11 +68,16 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<QuickFiltersLayout
|
||||
showFilters={false}
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
@@ -56,24 +89,140 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no sidebar at all without quick filters or saved views', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
className="root-extra"
|
||||
contentClassName="content-extra"
|
||||
testId="layout"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
'content-extra',
|
||||
);
|
||||
});
|
||||
|
||||
describe('saved views', () => {
|
||||
it('ignores savedViewProps while the flag is off', () => {
|
||||
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
|
||||
const { unmount } = render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
|
||||
// Saved views only, so nothing is left to show.
|
||||
unmount();
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const quickFilters = screen.getByTestId('quick-filters');
|
||||
|
||||
expect(quickFilters).toContainElement(
|
||||
screen.getByTestId('saved-views-header'),
|
||||
);
|
||||
expect(drawer()).toContainElement(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-open'));
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-close'));
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
});
|
||||
|
||||
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(sidebar).toHaveClass('isStatic');
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SIDEBAR_TOOLTIP_DELAY_MS = 500;
|
||||
@@ -8,29 +8,34 @@
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
transition: width 0.05s ease-in-out;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
transition:
|
||||
transform 0.2s ease-in-out,
|
||||
visibility 0s;
|
||||
|
||||
&.hidden {
|
||||
width: 0;
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 0.2s ease-in-out,
|
||||
visibility 0s 0.2s;
|
||||
}
|
||||
.qf-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.qf-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qf-header-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-6);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +52,17 @@
|
||||
}
|
||||
|
||||
.search {
|
||||
.ant-input {
|
||||
background-color: var(--l1-background);
|
||||
height: 46px;
|
||||
border-radius: 0;
|
||||
}
|
||||
--input-height: var(--spacing-20);
|
||||
--input-border-radius: 0;
|
||||
--input-border-color: transparent;
|
||||
--input-hover-border-color: transparent;
|
||||
--input-focus-border-color: transparent;
|
||||
--input-focus-outline-width: 0;
|
||||
--input-background: var(--l1-background);
|
||||
--input-hover-background: var(--l1-background);
|
||||
--input-focus-background: var(--l1-background);
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.qf-other-filters-list {
|
||||
@@ -117,6 +128,7 @@
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
@@ -43,14 +44,17 @@ function QuickFiltersSettings({
|
||||
<>
|
||||
<div className="qf-header">
|
||||
<div className="qf-title">
|
||||
<TableColumnsSplit width={16} height={16} />
|
||||
<TableColumnsSplit size={16} />
|
||||
Edit quick filters
|
||||
</div>
|
||||
<X
|
||||
className="qf-header-icon"
|
||||
width={16}
|
||||
height={16}
|
||||
<SignozButton
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
onClick={handleSettingsClose}
|
||||
data-testid="quick-filters-settings-close"
|
||||
/>
|
||||
</div>
|
||||
<section className="search">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -61,6 +62,7 @@ export interface IQuickFiltersProps {
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
savedViewsHeader?: ReactNode;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { SavedViewSourcePage } from 'container/SavedViews/types';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
|
||||
@@ -24,7 +24,6 @@ const accountsResponse: ListAccounts200 = {
|
||||
agentReport: {
|
||||
timestampMillis: 1747114366214,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
providerAccountId: PROVIDER_ACCOUNT_ID,
|
||||
removedAt: null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
@@ -123,6 +124,7 @@ function Explorer(): JSX.Element {
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.meter }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
|
||||
@@ -10,6 +10,8 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -347,57 +349,62 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
value={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
|
||||
>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
value={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
isMetricUnitsLoading={isMetricUnitsLoading}
|
||||
isMetricUnitsError={isMetricUnitsError}
|
||||
metricUnits={units}
|
||||
metricNames={metricNames}
|
||||
metrics={metrics}
|
||||
handleOpenMetricDetails={handleOpenMetricDetails}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
isMetricUnitsLoading={isMetricUnitsLoading}
|
||||
isMetricUnitsError={isMetricUnitsError}
|
||||
metricUnits={units}
|
||||
metricNames={metricNames}
|
||||
metrics={metrics}
|
||||
handleOpenMetricDetails={handleOpenMetricDetails}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
|
||||
@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
dataSource: DataSource;
|
||||
aggregateOperator: string;
|
||||
aggregateOperator?: string;
|
||||
initialOptions?: InitialOptions;
|
||||
}
|
||||
|
||||
|
||||
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Check, Trash2, X } from '@signozhq/icons';
|
||||
import { AlertDialog } from '@signozhq/ui/alert-dialog';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { DeleteSavedViewDialogProps } from './types';
|
||||
|
||||
function DeleteSavedViewDialog({
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteSavedViewDialogProps): JSX.Element {
|
||||
return (
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="narrow"
|
||||
title="Delete this view"
|
||||
titleIcon={<Trash2 size={16} />}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
prefix={<X size={12} />}
|
||||
testId="delete-saved-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={onConfirm}
|
||||
prefix={<Check size={12} />}
|
||||
testId="delete-saved-view-confirm"
|
||||
>
|
||||
Delete view
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography.Text>
|
||||
Deleting this view is irreversible and cannot be undone.
|
||||
</Typography.Text>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteSavedViewDialog;
|
||||
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
function SaveChangesMenu({
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
|
||||
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
|
||||
],
|
||||
[onSaveAsNew, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
icon={<Check size={14} />}
|
||||
color="warning"
|
||||
disabled={disabled}
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveChangesMenu;
|
||||
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
95
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
95
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { SAVE_VIEW_MODAL_TITLE } from './constants';
|
||||
import { SaveViewModalProps } from './types';
|
||||
|
||||
import styles from './SaveViewModal.module.scss';
|
||||
|
||||
const NAME_INPUT_ID = 'save-view-name';
|
||||
|
||||
function SaveViewModal({
|
||||
mode,
|
||||
initialName = '',
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SaveViewModalProps): JSX.Element {
|
||||
const [name, setName] = useState(initialName);
|
||||
const displayName = name.trim();
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!displayName || isSaving) {
|
||||
return;
|
||||
}
|
||||
if (await onSave(displayName)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={SAVE_VIEW_MODAL_TITLE[mode]}
|
||||
testId="save-view-modal"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
testId="save-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Check size={14} />}
|
||||
loading={isSaving}
|
||||
disabled={!displayName}
|
||||
onClick={(): void => {
|
||||
void handleSave();
|
||||
}}
|
||||
testId="save-view-submit"
|
||||
>
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form className={styles.field} onSubmit={handleSubmit}>
|
||||
<label htmlFor={NAME_INPUT_ID}>
|
||||
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
|
||||
</label>
|
||||
<Input
|
||||
id={NAME_INPUT_ID}
|
||||
placeholder="Eg. payments-view"
|
||||
value={name}
|
||||
onChange={(event): void => setName(event.target.value)}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
testId="save-view-name"
|
||||
/>
|
||||
</form>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewModal;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { highlightQueryExpression } from './utils/highlightQueryExpression';
|
||||
|
||||
function SavedViewHighlightedExpression({
|
||||
expression,
|
||||
}: {
|
||||
expression: string;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const parts = useMemo(
|
||||
() => highlightQueryExpression(expression, isDarkMode),
|
||||
[expression, isDarkMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map(({ from, text, color }) =>
|
||||
color ? (
|
||||
<span key={from} style={{ color }}>
|
||||
{text}
|
||||
</span>
|
||||
) : (
|
||||
text
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHighlightedExpression;
|
||||
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
.anchor {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
--popover-padding: 0;
|
||||
--popover-background: var(--l1-background);
|
||||
--popover-border-color: var(--l2-border);
|
||||
--popover-border-radius: var(--radius-2);
|
||||
--popover-foreground: var(--l1-foreground);
|
||||
|
||||
width: 324px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.queryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.queryName {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
.metric {
|
||||
color: var(--l2-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.expression {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.noFilters {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.updated {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.metaText {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
import { SavedViewHoverCardProps } from './types';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
|
||||
function SavedViewHoverCard({
|
||||
target,
|
||||
onCardEnter,
|
||||
onCardLeave,
|
||||
onClose,
|
||||
}: SavedViewHoverCardProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const view = target?.view;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!view}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={styles.anchor}
|
||||
style={{ top: target?.anchorTop ?? 0, height: target?.anchorHeight ?? 0 }}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={styles.card}
|
||||
onOpenAutoFocus={(event): void => event.preventDefault()}
|
||||
onCloseAutoFocus={(event): void => event.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
<div
|
||||
className={styles.content}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
data-testid="saved-view-hover-card"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>
|
||||
No filters
|
||||
</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHoverCard;
|
||||
56
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
56
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SavedViewsEmptyStateProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsEmptyState({
|
||||
isSearching,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: SavedViewsEmptyStateProps): JSX.Element {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
if (isSearching) {
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-no-results">
|
||||
<Typography.Text className={styles.stateText}>
|
||||
No views match your search
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-empty">
|
||||
<Typography.Text className={styles.stateTitle}>No views yet</Typography.Text>
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Save the current view to open it later.
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={(): void => setIsModalOpen(true)}
|
||||
data-testid="saved-views-list-create"
|
||||
>
|
||||
Create view
|
||||
</Button>
|
||||
{isModalOpen && (
|
||||
<SaveViewModal
|
||||
mode="create"
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsModalOpen(false)}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsEmptyState;
|
||||
@@ -0,0 +1,74 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.isDirty {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--callout-warning-background),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.isError {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--callout-error-background),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
border-radius: var(--spacing-1);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&.isDirty {
|
||||
background: var(--callout-warning-background);
|
||||
color: var(--callout-warning-title);
|
||||
}
|
||||
|
||||
&.isError {
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
}
|
||||
}
|
||||
|
||||
.nameText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--callout-warning-icon);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
147
frontend/src/container/SavedViews/SavedViewsHeader.tsx
Normal file
147
frontend/src/container/SavedViews/SavedViewsHeader.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useRestoreLastUsedView } from './hooks/useRestoreLastUsedView';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent while the list is on screen.
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, isError, hasUnsavedChanges } =
|
||||
useActiveSavedView(source);
|
||||
const { selectView, revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
useRestoreLastUsedView({ source, selectView });
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useSaveNewView(createView);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
}
|
||||
}, [view, updateView]);
|
||||
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = isError
|
||||
? SAVED_VIEW_LOAD_FAILED_NAME
|
||||
: (view?.spec.displayName ?? MY_VIEW_NAME);
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.header, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title={name}>
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
{!view && !isError && (
|
||||
<SavedViewsIconButton
|
||||
title="Create new view"
|
||||
icon={<Plus size={14} />}
|
||||
disabled={isLoading}
|
||||
onClick={(): void => setModalMode('create')}
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
)}
|
||||
{view && !isDirty && !isError && (
|
||||
<SavedViewsIconButton
|
||||
title="Clear view"
|
||||
icon={<Undo2 size={14} />}
|
||||
onClick={clearView}
|
||||
testId="saved-views-clear"
|
||||
/>
|
||||
)}
|
||||
{view && isDirty && (
|
||||
<>
|
||||
<SaveChangesMenu
|
||||
disabled={isSaving}
|
||||
onSaveAsNew={openSaveAsNew}
|
||||
onUpdate={(): void => {
|
||||
void handleUpdate();
|
||||
}}
|
||||
/>
|
||||
<SavedViewsIconButton
|
||||
title="Discard changes"
|
||||
icon={<X size={14} />}
|
||||
color="warning"
|
||||
disabled={isSaving}
|
||||
onClick={(): void => revertView(view)}
|
||||
testId="saved-views-discard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isDirty && onOpenViews && (
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalMode && (
|
||||
<SaveViewModal
|
||||
mode={modalMode}
|
||||
isSaving={isSaving}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsHeader;
|
||||
41
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
41
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { SavedViewsIconButtonProps } from './types';
|
||||
|
||||
// forwardRef and rest props so a menu can use it as its trigger.
|
||||
const SavedViewsIconButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SavedViewsIconButtonProps
|
||||
>(function SavedViewsIconButton(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
testId,
|
||||
color = 'secondary',
|
||||
disabled,
|
||||
onClick,
|
||||
...triggerProps
|
||||
},
|
||||
ref,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={title}>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
color={color}
|
||||
size="icon"
|
||||
aria-label={title}
|
||||
prefix={icon}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
);
|
||||
});
|
||||
|
||||
export default SavedViewsIconButton;
|
||||
131
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
131
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
@@ -0,0 +1,131 @@
|
||||
.list {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: var(--spacing-6);
|
||||
|
||||
&:first-child:not(:last-child) {
|
||||
max-height: 40%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: var(--spacing-16);
|
||||
padding-right: var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
&.isActive {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.rowName {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rowMenu {
|
||||
display: flex;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.row:hover .rowMenu,
|
||||
.row:focus-within .rowMenu,
|
||||
.rowMenu:has([data-state='open']) {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--spacing-12);
|
||||
height: var(--spacing-12);
|
||||
padding: var(--spacing-1);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
--button-height: 100%;
|
||||
--button-padding: 0 var(--spacing-6);
|
||||
--button-justify-content: flex-start;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowNameText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stateTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.stateText {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
200
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
200
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
|
||||
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
|
||||
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
|
||||
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewsList } from './hooks/useSavedViewsList';
|
||||
import SavedViewHoverCard from './SavedViewHoverCard';
|
||||
import SavedViewsEmptyState from './SavedViewsEmptyState';
|
||||
import SavedViewsListSection from './SavedViewsListSection';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import {
|
||||
PendingSavedViewAction,
|
||||
SavedViewRowActionKey,
|
||||
SavedViewsListRowHoverProps,
|
||||
} from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsList({
|
||||
source,
|
||||
search,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
search: string;
|
||||
}): JSX.Element {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
useSavedViewsList(source, search);
|
||||
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
|
||||
useSavedViewRowActions(source);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<PendingSavedViewAction | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const hoverCard = useSavedViewHoverCard();
|
||||
const {
|
||||
onRowEnter: onHoverCardRowEnter,
|
||||
onRowLeave: onHoverCardRowLeave,
|
||||
close: closeHoverCard,
|
||||
} = hoverCard;
|
||||
|
||||
useEffect(() => {
|
||||
closeHoverCard();
|
||||
}, [search, closeHoverCard]);
|
||||
|
||||
const rowHover = useMemo<SavedViewsListRowHoverProps>(
|
||||
() => ({
|
||||
onRowEnter: (view, row): void => {
|
||||
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
onHoverCardRowEnter({
|
||||
view,
|
||||
anchorTop: rowRect.top - listTop,
|
||||
anchorHeight: rowRect.height,
|
||||
});
|
||||
},
|
||||
onRowLeave: onHoverCardRowLeave,
|
||||
onMenuOpen: closeHoverCard,
|
||||
}),
|
||||
[onHoverCardRowEnter, onHoverCardRowLeave, closeHoverCard],
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO, key: SavedViewRowActionKey): void => {
|
||||
if (key === 'copyLink') {
|
||||
copyViewLink(view);
|
||||
return;
|
||||
}
|
||||
closeHoverCard();
|
||||
setPendingAction({ key, view });
|
||||
},
|
||||
[copyViewLink, closeHoverCard],
|
||||
);
|
||||
|
||||
const closePendingAction = useCallback((): void => setPendingAction(null), []);
|
||||
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!pendingAction || !(await deleteView(pendingAction.view))) {
|
||||
return;
|
||||
}
|
||||
if (pendingAction.view.id === viewKey) {
|
||||
clearView();
|
||||
}
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, deleteView, viewKey, clearView]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading} data-testid="saved-views-list-loading">
|
||||
{Array.from({ length: SAVED_VIEWS_LIST_LOADING_ROWS }, (_, index) => (
|
||||
<Skeleton.Input key={index} active size="small" block />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-error">
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Could not load views
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={refetch}
|
||||
data-testid="saved-views-list-retry"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (createdByMe.length === 0 && createdByOthers.length === 0) {
|
||||
return (
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={saveNewView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={styles.list}
|
||||
onScrollCapture={closeHoverCard}
|
||||
data-testid="saved-views-list"
|
||||
>
|
||||
{createdByMe.length > 0 && (
|
||||
<SavedViewsListSection
|
||||
title="Created by me"
|
||||
views={createdByMe}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
testId="saved-views-created-by-me"
|
||||
/>
|
||||
)}
|
||||
{createdByOthers.length > 0 && (
|
||||
<SavedViewsListSection
|
||||
title="Created by others"
|
||||
views={createdByOthers}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
testId="saved-views-created-by-others"
|
||||
/>
|
||||
)}
|
||||
<SavedViewHoverCard
|
||||
target={hoverCard.target}
|
||||
onCardEnter={hoverCard.onCardEnter}
|
||||
onCardLeave={hoverCard.onCardLeave}
|
||||
onClose={closeHoverCard}
|
||||
/>
|
||||
{pendingAction?.key === 'editDetails' && (
|
||||
<SaveViewModal
|
||||
mode="edit"
|
||||
initialName={pendingAction.view.spec.displayName}
|
||||
isSaving={isRenaming}
|
||||
onClose={closePendingAction}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
renameView(pendingAction.view, displayName)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{pendingAction?.key === 'delete' && (
|
||||
<DeleteSavedViewDialog
|
||||
isDeleting={isDeleting}
|
||||
onCancel={closePendingAction}
|
||||
onConfirm={(): void => {
|
||||
void handleDelete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsList;
|
||||
61
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
61
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
import { SavedViewsListRowProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsListRow({
|
||||
view,
|
||||
isActive,
|
||||
onSelect,
|
||||
onClear,
|
||||
onAction,
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.rowName}
|
||||
onClick={(): void => {
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
}}
|
||||
data-testid="saved-views-row-name"
|
||||
>
|
||||
<span className={styles.rowNameText}>{name}</span>
|
||||
</Button>
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
icon={<Undo2 size={14} />}
|
||||
label="Clear view"
|
||||
tooltip="Clear view"
|
||||
onClick={onClear}
|
||||
testId="saved-views-row-clear"
|
||||
/>
|
||||
)}
|
||||
<span className={styles.rowMenu}>
|
||||
<SavedViewsRowMenu
|
||||
onAction={(key): void => onAction(view, key)}
|
||||
onOpen={hover.onMenuOpen}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsListRow;
|
||||
43
frontend/src/container/SavedViews/SavedViewsListSection.tsx
Normal file
43
frontend/src/container/SavedViews/SavedViewsListSection.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import SavedViewsListRow from './SavedViewsListRow';
|
||||
import { SavedViewsListSectionProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsListSection({
|
||||
title,
|
||||
views,
|
||||
activeViewId,
|
||||
onSelect,
|
||||
onClear,
|
||||
onAction,
|
||||
hover,
|
||||
testId,
|
||||
}: SavedViewsListSectionProps): JSX.Element {
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
<Typography.Text className={styles.sectionTitle}>{title}</Typography.Text>
|
||||
<div className={styles.sectionList}>
|
||||
<OverlayScrollbar>
|
||||
<div>
|
||||
{views.map((view) => (
|
||||
<SavedViewsListRow
|
||||
key={view.id}
|
||||
view={view}
|
||||
isActive={view.id === activeViewId}
|
||||
onSelect={onSelect}
|
||||
onClear={onClear}
|
||||
onAction={onAction}
|
||||
hover={hover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsListSection;
|
||||
@@ -0,0 +1,43 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-height: var(--spacing-20);
|
||||
--input-border-radius: 0;
|
||||
--input-border-color: transparent;
|
||||
--input-hover-border-color: transparent;
|
||||
--input-focus-border-color: transparent;
|
||||
--input-focus-outline-width: 0;
|
||||
--input-background: var(--l1-background);
|
||||
--input-hover-background: var(--l1-background);
|
||||
--input-focus-background: var(--l1-background);
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.titleText {
|
||||
color: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
56
frontend/src/container/SavedViews/SavedViewsPanel.tsx
Normal file
56
frontend/src/container/SavedViews/SavedViewsPanel.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { TowerControl, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
|
||||
import styles from './SavedViewsPanel.module.scss';
|
||||
|
||||
function SavedViewsPanel({
|
||||
source,
|
||||
onClose,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-testid="saved-views-panel"
|
||||
data-source={source}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<TowerControl size={16} />
|
||||
<Typography.Text className={styles.titleText}>All views</Typography.Text>
|
||||
</div>
|
||||
{onClose && (
|
||||
<SavedViewsIconButton
|
||||
title="Close"
|
||||
icon={<X size={14} />}
|
||||
onClick={onClose}
|
||||
testId="saved-views-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsPanel;
|
||||
40
frontend/src/container/SavedViews/SavedViewsRowButton.tsx
Normal file
40
frontend/src/container/SavedViews/SavedViewsRowButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { SavedViewsRowButtonProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
// forwardRef and rest props so a menu can use it as its trigger.
|
||||
const SavedViewsRowButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SavedViewsRowButtonProps
|
||||
>(function SavedViewsRowButton(
|
||||
{ icon, label, tooltip, testId, onClick, ...triggerProps },
|
||||
ref,
|
||||
): JSX.Element {
|
||||
const button = (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.rowButton}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<TooltipSimple title={tooltip}>{button}</TooltipSimple>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
});
|
||||
|
||||
export default SavedViewsRowButton;
|
||||
44
frontend/src/container/SavedViews/SavedViewsRowMenu.tsx
Normal file
44
frontend/src/container/SavedViews/SavedViewsRowMenu.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import { SavedViewsRowMenuProps } from './types';
|
||||
|
||||
function SavedViewsRowMenu({
|
||||
onAction,
|
||||
onOpen,
|
||||
}: SavedViewsRowMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(
|
||||
() =>
|
||||
SAVED_VIEW_ROW_ACTIONS.flatMap(
|
||||
({ key, label, icon: Icon, danger, hasDividerBefore }): MenuItem[] => [
|
||||
...(hasDividerBefore ? [{ type: 'divider' as const }] : []),
|
||||
{
|
||||
key,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
danger,
|
||||
onClick: (): void => onAction(key),
|
||||
},
|
||||
],
|
||||
),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} side="right" align="start">
|
||||
<SavedViewsRowButton
|
||||
icon={<Ellipsis size={14} />}
|
||||
label="More options"
|
||||
tooltip="More options"
|
||||
testId="saved-views-row-menu"
|
||||
onPointerDown={onOpen}
|
||||
onKeyDown={onOpen}
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsRowMenu;
|
||||
@@ -0,0 +1,263 @@
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
function twoQueries(): Query {
|
||||
const base = queryWith(DataSource.TRACES, 'has_error = true');
|
||||
const [first] = base.builder.queryData;
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
first,
|
||||
{
|
||||
...first,
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
filter: { expression: 'duration_nano > 1000' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function metricsQuery(): Query {
|
||||
const base = initialQueriesMap[DataSource.METRICS];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression: '' },
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'app.requests',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
const errors = makeView({
|
||||
id: 'view-1',
|
||||
displayName: 'Errors',
|
||||
updatedBy: 'editor@signoz.io',
|
||||
updatedAt: '2026-09-28T12:00:00Z',
|
||||
});
|
||||
const slow = makeView({
|
||||
id: 'view-2',
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 1000',
|
||||
});
|
||||
const unfiltered = makeView({
|
||||
id: 'view-3',
|
||||
displayName: 'Everything',
|
||||
expression: '',
|
||||
});
|
||||
const combined = makeView({
|
||||
id: 'view-4',
|
||||
displayName: 'Errors and slow',
|
||||
query: twoQueries(),
|
||||
});
|
||||
const requestsMetric = makeView({
|
||||
id: 'view-5',
|
||||
displayName: 'Request rate',
|
||||
source: SavedviewtypesSourceDTO.metrics,
|
||||
dataSource: DataSource.METRICS,
|
||||
query: metricsQuery(),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
async function renderPanel(
|
||||
source: SavedviewtypesSourceDTO = SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource = DataSource.TRACES,
|
||||
): Promise<void> {
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel source={source} onClose={jest.fn()} />,
|
||||
explorerUrl(ROUTES.TRACES_EXPLORER, {}),
|
||||
dataSource,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
jest.useFakeTimers();
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const card = (): HTMLElement | null =>
|
||||
screen.queryByTestId('saved-view-hover-card');
|
||||
|
||||
function advance(ms: number): void {
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
}
|
||||
|
||||
function openCard(name: string): void {
|
||||
fireEvent.mouseEnter(row(name));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
}
|
||||
|
||||
describe('SavedViewHoverCard', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
mockSavedViewsApi([errors, slow, unfiltered, combined, requestsMetric]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('opening and closing', () => {
|
||||
it('opens for a row after the delay, not before', async () => {
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS - 1);
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(1);
|
||||
expect(card()).toHaveTextContent('Errors');
|
||||
});
|
||||
|
||||
it('does not open when the pointer leaves before the delay', async () => {
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('stays open while the pointer moves into the card', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(card() as HTMLElement);
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS * 2);
|
||||
expect(card()).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseLeave(card() as HTMLElement);
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on leaving the row and reopens for the next row after the delay', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(row('Slow spans'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
expect(card()).toHaveTextContent('Slow spans');
|
||||
});
|
||||
|
||||
it('closes when the row menu is pressed', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.pointerDown(
|
||||
within(row('Errors')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
it('shows the filter, the updater and the update time', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
const content = card() as HTMLElement;
|
||||
expect(
|
||||
within(content).getByTestId('saved-view-hover-card-query'),
|
||||
).toHaveTextContent('has_error = true');
|
||||
expect(content).toHaveTextContent('editor@signoz.io');
|
||||
expect(content).toHaveTextContent('Sep 28, 2026');
|
||||
});
|
||||
|
||||
it('copies the filter expression', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('has_error = true');
|
||||
});
|
||||
|
||||
it('names each query when there are several, and copies them per line', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors and slow');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('A');
|
||||
expect(query).toHaveTextContent('B');
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(
|
||||
'A: has_error = true\nB: duration_nano > 1000',
|
||||
);
|
||||
});
|
||||
|
||||
it('says there are no filters, with nothing to copy', async () => {
|
||||
await renderPanel();
|
||||
openCard('Everything');
|
||||
|
||||
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
|
||||
'No filters',
|
||||
);
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the metric of a metrics query', async () => {
|
||||
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
|
||||
openCard('Request rate');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('app.requests · rate · sum');
|
||||
expect(query).not.toHaveTextContent('No filters');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() => expect(chip()).toHaveTextContent(name));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader last used view', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reopens on a bare explorer', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a url with its own query alone', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const baseView = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const withColumns = {
|
||||
...baseView,
|
||||
spec: {
|
||||
...baseView.spec,
|
||||
selectedFields: [{ name: 'service.name' }, { name: 'duration_nano' }],
|
||||
},
|
||||
};
|
||||
|
||||
function renderHeader(url: string): void {
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
|
||||
|
||||
describe('SavedViewsHeader when loading fails', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows the load failed state when the view in the url does not load', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(viewUrl(PATH, withColumns));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(chip()).toHaveTextContent('Saved view failed to load'),
|
||||
);
|
||||
expect(isDirty()).toBe(false);
|
||||
expect(screen.queryByTestId('saved-views-create')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the view clean when only the views list fails', async () => {
|
||||
mockSavedViewsApi([withColumns], { listFailures: Number.POSITIVE_INFINITY });
|
||||
renderHeader(viewUrl(PATH, withColumns));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('Errors');
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
|
||||
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
async function openDirtyView(
|
||||
view: ReturnType<typeof makeView>,
|
||||
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
|
||||
const rendered = renderHeader(viewUrl(PATH, view));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
view.spec.displayName,
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
act(() => {
|
||||
rendered.history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: view.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-discard')).toBeInTheDocument(),
|
||||
);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
async function chooseFromSaveMenu(item: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), name);
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader saving', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves the current state under a generated name and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
|
||||
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-view-submit')).toBeDisabled();
|
||||
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
expect(requests.created[0]).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
generateName: true,
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
schemaVersion: 'v2',
|
||||
spec: expect.objectContaining({
|
||||
displayName: 'Slow checkout',
|
||||
panelType: 'list',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View created',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
);
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'Slow checkout',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the modal open and reports a failed save', async () => {
|
||||
const requests = mockSavedViewsApi([], { createStatus: 500 });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(requests.created).toHaveLength(1);
|
||||
expect(isModalOpen()).toBe(true);
|
||||
expect(screen.getByTestId('save-view-name')).toHaveValue('Slow checkout');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancel closes the modal without saving', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
|
||||
await userEvent.click(screen.getByTestId('save-view-cancel'));
|
||||
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('save as new creates another view from the changed state', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors], { createdId: 'view-copy' });
|
||||
const { history } = await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Save as new view');
|
||||
|
||||
expect(within(modal()).getByText('Save as new view')).toBeInTheDocument();
|
||||
await submitName('Errors copy');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
const body = requests.created[0] as {
|
||||
spec: {
|
||||
displayName: string;
|
||||
queries: { spec: { filter: { expression: string } } }[];
|
||||
};
|
||||
};
|
||||
expect(body.spec.displayName).toBe('Errors copy');
|
||||
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
|
||||
expect(requests.updated).toHaveLength(0);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-copy'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('update stores the changed state on the open view, keeping its name', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors]);
|
||||
await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const { id, body } = requests.updated[0] as {
|
||||
id: string;
|
||||
body: {
|
||||
source: string;
|
||||
spec: {
|
||||
displayName: string;
|
||||
queries: { spec: { filter: { expression: string } } }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(id).toBe('view-1');
|
||||
expect(body.source).toBe(SavedviewtypesSourceDTO.traces);
|
||||
expect(body.spec.displayName).toBe('Errors');
|
||||
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View updated',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
),
|
||||
);
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useActiveSavedView } from '../hooks/useActiveSavedView';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
id: 'view-2',
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 1000',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
onOpenViews: (() => void) | undefined = jest.fn(),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={onOpenViews}
|
||||
/>
|
||||
<RenderRecorder />
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
|
||||
const saveChanges = (): HTMLElement =>
|
||||
screen.getByTestId('saved-views-save-changes');
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() => expect(chip()).toHaveTextContent(name));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
// Each render's answer from the hook the header reads, so a state that lasts a
|
||||
// single render is seen too.
|
||||
interface RenderRecord {
|
||||
viewId?: string;
|
||||
dirty: boolean;
|
||||
// The staged query had not caught up with the url yet.
|
||||
isStaging: boolean;
|
||||
}
|
||||
let renders: RenderRecord[] = [];
|
||||
function RenderRecorder(): null {
|
||||
const { view, hasUnsavedChanges } = useActiveSavedView(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
renders.push({
|
||||
viewId: view?.id,
|
||||
dirty: hasUnsavedChanges,
|
||||
isStaging: stagedQuery?.id !== compositeQuery?.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function recordRenders(change: () => void): Promise<RenderRecord[]> {
|
||||
renders = [];
|
||||
act(change);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
|
||||
);
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
return renders;
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows My view with create and all views when no view is open', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-save-changes')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-discard')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a skeleton while the view in the url loads', async () => {
|
||||
mockSavedViewsApi([errors], { getDelay: 150 });
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
expect(screen.getByTestId('saved-views-loading')).toBeInTheDocument();
|
||||
await waitForView('Errors');
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the open view clean, with clear and all views', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-create')).toBeNull();
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
expect(saveChanges()).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-open')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the view dirty when only the tab changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
act(() => {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
params.set(QueryParams.panelTypes, JSON.stringify(PANEL_TYPES.TABLE));
|
||||
history.push(`${PATH}?${params.toString()}`);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
});
|
||||
|
||||
it('discard puts the saved query and tab back', async () => {
|
||||
mockSavedViewsApi([slow]);
|
||||
const { history } = renderHeader(viewUrl(PATH, slow));
|
||||
await waitForView('Slow spans');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = true'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: slow.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
expect(urlParam(history, QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(JSON.stringify(slow.id));
|
||||
expect(urlParam(history, QueryParams.viewName)).toBeNull();
|
||||
const query = JSON.parse(
|
||||
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
|
||||
);
|
||||
expect(query.builder.queryData[0].filter.expression).toBe(
|
||||
'duration_nano > 1000',
|
||||
);
|
||||
});
|
||||
|
||||
it('clear drops the view from the url but keeps the time range', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors, '1h'));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
expect(urlParam(history, QueryParams.panelTypes)).toBeNull();
|
||||
expect(urlParam(history, QueryParams.relativeTime)).toBe('1h');
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}'),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
describe('unsaved changes indicator', () => {
|
||||
it('never flashes while switching between views already loaded', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderHeader(viewUrl(PATH, slow));
|
||||
await waitForView('Slow spans');
|
||||
act(() => history.push(viewUrl(PATH, errors)));
|
||||
await waitForView('Errors');
|
||||
|
||||
const states = await recordRenders(() => history.push(viewUrl(PATH, slow)));
|
||||
|
||||
expect(chip()).toHaveTextContent('Slow spans');
|
||||
expect(states.some((render) => render.isStaging)).toBe(true);
|
||||
expect(states.filter((render) => render.dirty)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('holds while the query keeps changing on a dirty view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
const states = await recordRenders(() =>
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'duration_nano > 5'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(states.some((render) => render.isStaging)).toBe(true);
|
||||
expect(states.filter((render) => !render.dirty)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('on metrics compares no columns and has no all views button', async () => {
|
||||
const cpu = makeView({
|
||||
id: 'view-3',
|
||||
displayName: 'CPU',
|
||||
source: SavedviewtypesSourceDTO.metrics,
|
||||
dataSource: DataSource.METRICS,
|
||||
expression: '',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
mockSavedViewsApi([cpu]);
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsHeader source={SavedviewtypesSourceDTO.metrics} />,
|
||||
viewUrl(ROUTES.METRICS_EXPLORER_EXPLORER, cpu),
|
||||
DataSource.METRICS,
|
||||
);
|
||||
|
||||
await waitForView('CPU');
|
||||
expect(isDirty()).toBe(false);
|
||||
expect(screen.queryByTestId('saved-views-open')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const mine = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const alsoMine = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
const theirs = makeView({
|
||||
id: 'view-3',
|
||||
displayName: 'Checkout errors',
|
||||
createdBy: 'someone@signoz.io',
|
||||
});
|
||||
|
||||
function renderPanel(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const rowNames = (sectionTestId: string): string[] =>
|
||||
within(screen.getByTestId(sectionTestId))
|
||||
.getAllByTestId('saved-views-row-name')
|
||||
.map((row) => row.textContent ?? '');
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
describe('SavedViewsPanel list', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('splits the views into created by me and created by others', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine, theirs]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual([
|
||||
'Errors',
|
||||
'Slow spans',
|
||||
]);
|
||||
expect(rowNames('saved-views-created-by-others')).toStrictEqual([
|
||||
'Checkout errors',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides a section with no views', async () => {
|
||||
mockSavedViewsApi([mine]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-created-by-me');
|
||||
expect(screen.queryByTestId('saved-views-created-by-others')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens a view from its row', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(alsoMine.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the open view and clears it from its row', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel(viewUrl(PATH, mine));
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const activeRow = row('Errors');
|
||||
expect(within(activeRow).getByTestId('saved-views-row-clear')).toBeVisible();
|
||||
expect(
|
||||
within(row('Slow spans')).queryByTestId('saved-views-row-clear'),
|
||||
).toBeNull();
|
||||
|
||||
await userEvent.click(within(activeRow).getByTestId('saved-views-row-clear'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the url alone when the open view is clicked again', async () => {
|
||||
mockSavedViewsApi([mine]);
|
||||
const { history } = renderPanel(viewUrl(PATH, mine));
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('filters both sections by name, ignoring case', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine, theirs]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
|
||||
await userEvent.type(screen.getByTestId('saved-views-search'), 'ERRORS');
|
||||
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
expect(rowNames('saved-views-created-by-others')).toStrictEqual([
|
||||
'Checkout errors',
|
||||
]);
|
||||
});
|
||||
|
||||
it('says so when nothing matches, and recovers when cleared', async () => {
|
||||
mockSavedViewsApi([mine]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const search = screen.getByTestId('saved-views-search');
|
||||
|
||||
await userEvent.type(search, 'nothing like this');
|
||||
expect(
|
||||
screen.getByTestId('saved-views-list-no-results'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-list-create')).toBeNull();
|
||||
|
||||
await userEvent.clear(search);
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers to create a view when there are none, and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
expect(toast.success).toHaveBeenCalledWith('View created', expect.anything());
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId('saved-views-created-by-me'),
|
||||
).resolves.toHaveTextContent('First view');
|
||||
});
|
||||
|
||||
it('shows the list error and loads the list on retry', async () => {
|
||||
mockSavedViewsApi([mine], { listFailures: 1 });
|
||||
renderPanel();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-retry'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-views-created-by-me'),
|
||||
).resolves.toHaveTextContent('Errors');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
id: 'view-2',
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 1000',
|
||||
});
|
||||
|
||||
function renderPanelWithHeader(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader source={SavedviewtypesSourceDTO.traces} />
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
async function chooseFromRowMenu(
|
||||
viewName: string,
|
||||
item: string,
|
||||
): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row(viewName)).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
|
||||
describe('SavedViewsPanel row menu', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
jest.mocked(toast.success).mockClear();
|
||||
});
|
||||
|
||||
it('offers copy link, edit details and delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
|
||||
const items = await screen.findAllByRole('menuitem');
|
||||
expect(items.map((item) => item.textContent)).toStrictEqual([
|
||||
'Copy link',
|
||||
'Edit details',
|
||||
'Delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy link');
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
const link = new URL(mockCopy.mock.calls[0][0]);
|
||||
expect(link.pathname).toBe(PATH);
|
||||
expect(link.searchParams.get(QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(slow.id),
|
||||
);
|
||||
expect(link.searchParams.get(QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(slow.spec.panelType),
|
||||
);
|
||||
expect(
|
||||
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
|
||||
).toContain('duration_nano > 1000');
|
||||
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
|
||||
});
|
||||
|
||||
it('renames a view without touching its query', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
expect(name).toHaveValue('Slow spans');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const [update] = requests.updated;
|
||||
expect(update.id).toBe(slow.id);
|
||||
expect(update.body).toMatchObject({
|
||||
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('View updated', expect.anything());
|
||||
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows the new name in the header when the open view is renamed', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Errors', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'All errors');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('All errors'));
|
||||
});
|
||||
|
||||
it('asks before deleting and keeps the view on cancel', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
|
||||
expect(screen.getByText('Delete this view')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Deleting this view is irreversible and cannot be undone.'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-cancel'));
|
||||
|
||||
expect(requests.deleted).toHaveLength(0);
|
||||
expect(row('Slow spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a view on confirm', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(requests.deleted).toStrictEqual([slow.id]));
|
||||
expect(toast.success).toHaveBeenCalledWith('View deleted', expect.anything());
|
||||
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
|
||||
});
|
||||
|
||||
it('clears the open view when it is deleted', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Errors', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull(),
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
});
|
||||
});
|
||||
102
frontend/src/container/SavedViews/__tests__/savedViewsApiMock.ts
Normal file
102
frontend/src/container/SavedViews/__tests__/savedViewsApiMock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
|
||||
export const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
export const TEST_USER_EMAIL = 'test@signoz.io';
|
||||
|
||||
export interface Requests {
|
||||
created: unknown[];
|
||||
updated: { id: string; body: unknown }[];
|
||||
deleted: string[];
|
||||
}
|
||||
|
||||
// Mutates `views` on create, update and delete, so a refetch sees the change.
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
createStatus = 201,
|
||||
createdId = 'view-new',
|
||||
getDelay = 0,
|
||||
listFailures = 0,
|
||||
}: {
|
||||
createStatus?: number;
|
||||
createdId?: string;
|
||||
getDelay?: number;
|
||||
listFailures?: number;
|
||||
} = {},
|
||||
): Requests {
|
||||
const requests: Requests = { created: [], updated: [], deleted: [] };
|
||||
let failedLists = 0;
|
||||
server.use(
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
if (failedLists < listFailures) {
|
||||
failedLists += 1;
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
const source = req.url.searchParams.get('source');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: views.filter((view) => !source || view.source === source),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
const view = views.find((v) => v.id === req.params.id);
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.delay(getDelay),
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: view }),
|
||||
);
|
||||
}),
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.created.push(body);
|
||||
if (createStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
views.push({
|
||||
...body,
|
||||
id: createdId,
|
||||
createdBy: TEST_USER_EMAIL,
|
||||
updatedBy: TEST_USER_EMAIL,
|
||||
} as SavedviewtypesSavedViewDTO);
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({ status: 'success', data: { id: createdId } }),
|
||||
);
|
||||
}),
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const id = String(req.params.id);
|
||||
requests.updated.push({ id, body });
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views[index] = { ...views[index], spec: body.spec };
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
rest.delete(`${API}/:id`, (req, res, ctx) => {
|
||||
const id = String(req.params.id);
|
||||
requests.deleted.push(id);
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views.splice(index, 1);
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { ReactElement, useEffect, useMemo } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { TEST_USER_EMAIL } from './savedViewsApiMock';
|
||||
|
||||
// The server stores queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
function asStoredByServer(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, v]) => [key, asStoredByServer(v)] as const)
|
||||
.filter(([, v]) => v !== undefined),
|
||||
);
|
||||
}
|
||||
return value === null || value === '' || value === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
export function queryWith(dataSource: DataSource, expression: string): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
export function makeView({
|
||||
id,
|
||||
displayName,
|
||||
source = SavedviewtypesSourceDTO.traces,
|
||||
dataSource = DataSource.TRACES,
|
||||
expression = 'has_error = true',
|
||||
query = queryWith(dataSource, expression),
|
||||
panelType = PANEL_TYPES.LIST,
|
||||
createdBy = TEST_USER_EMAIL,
|
||||
updatedBy = createdBy,
|
||||
updatedAt,
|
||||
}: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
source?: SavedviewtypesSourceDTO;
|
||||
dataSource?: DataSource;
|
||||
expression?: string;
|
||||
query?: Query;
|
||||
panelType?: PANEL_TYPES;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
updatedAt?: string;
|
||||
}): SavedviewtypesSavedViewDTO {
|
||||
const spec = toSavedViewSpec({
|
||||
query,
|
||||
panelType,
|
||||
displayName,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
name: `${displayName.toLowerCase()}-abc`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
JSON.parse(JSON.stringify(spec.queries)),
|
||||
) as SavedviewtypesSavedViewSpecDTO['queries'],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// Params written the way the query builder redirect writes them.
|
||||
export function explorerUrl(
|
||||
path: string,
|
||||
{
|
||||
query,
|
||||
panelType,
|
||||
viewKey,
|
||||
relativeTime = '15m',
|
||||
}: {
|
||||
query?: Query;
|
||||
panelType?: PANEL_TYPES;
|
||||
viewKey?: string;
|
||||
relativeTime?: string;
|
||||
},
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify({ ...query, id: `url-${Math.random()}` })),
|
||||
);
|
||||
}
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, JSON.stringify(panelType));
|
||||
}
|
||||
if (viewKey) {
|
||||
params.set(QueryParams.viewKey, JSON.stringify(viewKey));
|
||||
}
|
||||
params.set(QueryParams.relativeTime, relativeTime);
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function viewUrl(
|
||||
path: string,
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
relativeTime?: string,
|
||||
): string {
|
||||
return explorerUrl(path, {
|
||||
query: getSavedViewQuery(view),
|
||||
panelType: view.spec.panelType as unknown as PANEL_TYPES,
|
||||
viewKey: view.id,
|
||||
relativeTime,
|
||||
});
|
||||
}
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
// What the explorer page does around the header: writes its default query to a
|
||||
// bare url, and sets the provider's tab from the url (the provider alone seeds
|
||||
// it from the raw, still JSON-quoted param).
|
||||
function ExplorerShell({ dataSource }: { dataSource: DataSource }): null {
|
||||
const panelType = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const { handleSetConfig, updateAllQueriesOperators } = useQueryBuilder();
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap[dataSource],
|
||||
PANEL_TYPES.LIST,
|
||||
dataSource,
|
||||
),
|
||||
[updateAllQueriesOperators, dataSource],
|
||||
);
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
useEffect(() => {
|
||||
handleSetConfig(panelType, dataSource);
|
||||
}, [panelType, dataSource, handleSetConfig]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderWithExplorerProviders(
|
||||
ui: ReactElement,
|
||||
initialUrl: string,
|
||||
dataSource: DataSource = DataSource.TRACES,
|
||||
): RenderResult & { history: MemoryHistory } {
|
||||
const history = createMemoryHistory({ initialEntries: [initialUrl] });
|
||||
// Parts of the explorer read window.location directly, so it follows the
|
||||
// router's history.
|
||||
const syncWindowLocation = (): void =>
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${history.location.pathname}${history.location.search}`,
|
||||
);
|
||||
syncWindowLocation();
|
||||
history.listen(syncWindowLocation);
|
||||
__setSearchParamsGetterForTest(
|
||||
() => new URLSearchParams(history.location.search),
|
||||
);
|
||||
safeNavigateMock.mockImplementation((to) => {
|
||||
history.push(
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname ?? history.location.pathname}${to.search ?? ''}`,
|
||||
);
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const defaultAppContext = getAppContextMock('ADMIN');
|
||||
const appContext = {
|
||||
...defaultAppContext,
|
||||
user: { ...defaultAppContext.user, email: TEST_USER_EMAIL },
|
||||
};
|
||||
|
||||
const result = render(
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={mockStore(store.getState())}>
|
||||
<AppContext.Provider value={appContext}>
|
||||
<TimezoneProvider>
|
||||
<TooltipProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>
|
||||
<ExplorerShell dataSource={dataSource} />
|
||||
{ui}
|
||||
</QueryBuilderProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TooltipProvider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsAdapter>
|
||||
</CompatRouter>
|
||||
</Router>,
|
||||
);
|
||||
return { ...result, history };
|
||||
}
|
||||
|
||||
export function urlParam(history: MemoryHistory, key: string): string | null {
|
||||
return new URLSearchParams(history.location.search).get(key);
|
||||
}
|
||||
60
frontend/src/container/SavedViews/constants.ts
Normal file
60
frontend/src/container/SavedViews/constants.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
|
||||
|
||||
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
|
||||
|
||||
// Only the logs and traces explorers have columns and formatting to save.
|
||||
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
|
||||
Record<SavedviewtypesSourceDTO, DataSource>
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
|
||||
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
// What selecting a view writes to the url, so clearing it removes exactly these.
|
||||
export const SAVED_VIEW_URL_PARAMS = [
|
||||
QueryParams.viewKey,
|
||||
QueryParams.viewName,
|
||||
QueryParams.compositeQuery,
|
||||
QueryParams.panelTypes,
|
||||
];
|
||||
|
||||
export const MY_VIEW_NAME = 'My view';
|
||||
|
||||
export const SAVED_VIEW_LOAD_FAILED_NAME = 'Saved view failed to load';
|
||||
|
||||
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
|
||||
|
||||
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
|
||||
|
||||
// Long enough for the pointer to cross from the row into the card.
|
||||
export const SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS = 150;
|
||||
|
||||
export const SAVED_VIEW_UPDATED_AT_FORMAT = 'HH:mm:ss — MMM D, YYYY';
|
||||
|
||||
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
|
||||
create: 'Create new view',
|
||||
saveAsNew: 'Save as new view',
|
||||
edit: 'Edit view details',
|
||||
};
|
||||
|
||||
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
|
||||
{ key: 'copyLink', label: 'Copy link', icon: Link },
|
||||
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
hasDividerBefore: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
|
||||
|
||||
export function useActiveSavedView(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseActiveSavedViewResult {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: !!viewKey } },
|
||||
);
|
||||
const view = viewKey ? data?.data : undefined;
|
||||
|
||||
// The query builder stages the url query a render later. Until the ids
|
||||
// match, keep the last answer for this view; another view starts clean.
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const isStagedQueryCurrent =
|
||||
!!stagedQuery && stagedQuery.id === compositeQuery?.id;
|
||||
const lastResult = useRef<{ viewId?: string; hasUnsavedChanges: boolean }>({
|
||||
hasUnsavedChanges: false,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo((): boolean => {
|
||||
if (!view || isError) {
|
||||
return false;
|
||||
}
|
||||
if (!isStagedQueryCurrent) {
|
||||
return (
|
||||
lastResult.current.viewId === view.id &&
|
||||
lastResult.current.hasUnsavedChanges
|
||||
);
|
||||
}
|
||||
return hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isError,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
optionsDataSource,
|
||||
options,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
|
||||
}, [view?.id, hasUnsavedChanges]);
|
||||
|
||||
return {
|
||||
view,
|
||||
isLoading: !!viewKey && isLoading,
|
||||
isError: !!viewKey && isError,
|
||||
hasUnsavedChanges,
|
||||
};
|
||||
}
|
||||
55
frontend/src/container/SavedViews/hooks/useLastUsedView.ts
Normal file
55
frontend/src/container/SavedViews/hooks/useLastUsedView.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
PreservedViewsInLocalStorage,
|
||||
PreservedViewType,
|
||||
} from 'container/ExplorerOptions/types';
|
||||
|
||||
import { UseLastUsedViewResult } from '../types';
|
||||
|
||||
function readLastUsedViews(): PreservedViewsInLocalStorage {
|
||||
try {
|
||||
return JSON.parse(
|
||||
getLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) || '{}',
|
||||
) as PreservedViewsInLocalStorage;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Read at call time, not held in state: the hook is used from more than one
|
||||
// place and a cached copy would bring back a view another caller just cleared.
|
||||
export function useLastUsedView(
|
||||
savedViewSource: SavedviewtypesSourceDTO,
|
||||
): UseLastUsedViewResult {
|
||||
// The bar's enum carries the same four values as the v2 source.
|
||||
const source = savedViewSource as unknown as PreservedViewType;
|
||||
|
||||
const getLastUsedViewKey = useCallback(
|
||||
(): string | undefined => readLastUsedViews()[source]?.key,
|
||||
[source],
|
||||
);
|
||||
|
||||
const setLastUsedView = useCallback(
|
||||
(id: string, displayName: string): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({
|
||||
...readLastUsedViews(),
|
||||
[source]: { key: id, value: displayName },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[source],
|
||||
);
|
||||
|
||||
const clearLastUsedView = useCallback((): void => {
|
||||
const { [source]: _removed, ...rest } = readLastUsedViews();
|
||||
setLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, JSON.stringify(rest));
|
||||
}, [source]);
|
||||
|
||||
return { getLastUsedViewKey, setLastUsedView, clearLastUsedView };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { UseRestoreLastUsedViewArgs } from '../types';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
|
||||
export function useRestoreLastUsedView({
|
||||
source,
|
||||
selectView,
|
||||
}: UseRestoreLastUsedViewArgs): void {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { getLastUsedViewKey, clearLastUsedView } = useLastUsedView(source);
|
||||
|
||||
// Decided on the first render, before the explorer writes its default query
|
||||
// to the url; later an empty url means the view was cleared.
|
||||
const [lastUsedViewKey] = useState(() => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
const isBare =
|
||||
!params.get(QueryParams.viewKey) && !params.get(QueryParams.compositeQuery);
|
||||
return isBare ? getLastUsedViewKey() : undefined;
|
||||
});
|
||||
const hasRestored = useRef(false);
|
||||
|
||||
const shouldRestore = !!lastUsedViewKey && !viewKey && !hasRestored.current;
|
||||
|
||||
const { data, isError } = useGetSavedView(
|
||||
{ id: lastUsedViewKey ?? '' },
|
||||
{ query: { enabled: shouldRestore } },
|
||||
);
|
||||
const view = data?.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRestore) {
|
||||
return undefined;
|
||||
}
|
||||
if (isError) {
|
||||
hasRestored.current = true;
|
||||
clearLastUsedView();
|
||||
return undefined;
|
||||
}
|
||||
if (!view) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Deferred like the bar's restore, so it lands after the explorer has
|
||||
// written its default query to the url.
|
||||
const timeoutId = setTimeout(() => {
|
||||
hasRestored.current = true;
|
||||
selectView(view);
|
||||
}, 0);
|
||||
return (): void => clearTimeout(timeoutId);
|
||||
}, [shouldRestore, isError, view, clearLastUsedView, selectView]);
|
||||
}
|
||||
22
frontend/src/container/SavedViews/hooks/useSaveNewView.ts
Normal file
22
frontend/src/container/SavedViews/hooks/useSaveNewView.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
|
||||
export function useSaveNewView(
|
||||
createView: UseSavedViewActionsResult['createView'],
|
||||
): (displayName: string) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
if (!(await createView(displayName))) {
|
||||
return false;
|
||||
}
|
||||
toast.success('View created', {
|
||||
position: SAVED_VIEW_TOAST_POSITION,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[createView],
|
||||
);
|
||||
}
|
||||
186
frontend/src/container/SavedViews/hooks/useSavedViewActions.ts
Normal file
186
frontend/src/container/SavedViews/hooks/useSavedViewActions.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateGetSavedView,
|
||||
useCreateSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_OPTIONS_DATA_SOURCE,
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
SAVED_VIEW_URL_PARAMS,
|
||||
} from '../constants';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
|
||||
// Columns and formatting need no handling here: with a viewKey in the url the
|
||||
// preference provider reads them from the view, and without one it falls back
|
||||
// to the user's own.
|
||||
export function useSavedViewActions(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseSavedViewActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { currentQuery, stagedQuery, panelType, redirectWithQueryBuilderData } =
|
||||
useQueryBuilder();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { mutateAsync: createSavedView, isLoading: isCreating } =
|
||||
useCreateSavedView();
|
||||
const { mutateAsync: updateSavedView, isLoading: isUpdating } =
|
||||
useUpdateSavedView();
|
||||
|
||||
const invalidateViews = useCallback(
|
||||
(id?: string): Promise<void> => invalidateSavedViews(queryClient, source, id),
|
||||
[queryClient, source],
|
||||
);
|
||||
|
||||
const toCurrentSpec = useCallback(
|
||||
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
|
||||
toSavedViewSpec({
|
||||
query: stagedQuery ?? currentQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
color,
|
||||
}),
|
||||
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
handleExplorerTabChange(view.spec.panelType, {
|
||||
query: getSavedViewQuery(view),
|
||||
viewKey: view.id,
|
||||
});
|
||||
setLastUsedView(view.id, view.spec.displayName);
|
||||
},
|
||||
[handleExplorerTabChange, setLastUsedView],
|
||||
);
|
||||
|
||||
// The preference provider re-reads a view's columns only when the view is
|
||||
// fetched, so it is refetched as the view is applied again.
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
void invalidateGetSavedView(queryClient, { id: view.id });
|
||||
selectView(view);
|
||||
},
|
||||
[queryClient, selectView],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
// here; the time range and everything else a view does not set stays.
|
||||
const clearView = useCallback((): void => {
|
||||
clearLastUsedView();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
SAVED_VIEW_URL_PARAMS.forEach((param) => params.delete(param));
|
||||
safeNavigate(`${pathname}?${params.toString()}`);
|
||||
}, [clearLastUsedView, pathname, safeNavigate]);
|
||||
|
||||
const createView = useCallback(
|
||||
async (displayName: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
generateName: true,
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: toCurrentSpec(displayName),
|
||||
},
|
||||
});
|
||||
await invalidateViews();
|
||||
redirectWithQueryBuilderData(stagedQuery ?? currentQuery, {
|
||||
[QueryParams.viewKey]: data.id,
|
||||
});
|
||||
setLastUsedView(data.id, displayName);
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
'Could not save the view',
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[
|
||||
createSavedView,
|
||||
source,
|
||||
toCurrentSpec,
|
||||
invalidateViews,
|
||||
redirectWithQueryBuilderData,
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
],
|
||||
);
|
||||
|
||||
const updateView = useCallback(
|
||||
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: toCurrentSpec(view.spec.displayName, view.spec.display?.color),
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
'Could not update the view',
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
);
|
||||
|
||||
return {
|
||||
selectView,
|
||||
revertView,
|
||||
clearView,
|
||||
createView,
|
||||
updateView,
|
||||
isSaving: isCreating || isUpdating,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import {
|
||||
SavedViewHoverCardTarget,
|
||||
UseSavedViewHoverCardResult,
|
||||
} from '../types';
|
||||
|
||||
export function useSavedViewHoverCard(): UseSavedViewHoverCardResult {
|
||||
const [target, setTarget] = useState<SavedViewHoverCardTarget | null>(null);
|
||||
const openTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const cancelOpen = useCallback(
|
||||
(): void => clearTimeout(openTimer.current),
|
||||
[],
|
||||
);
|
||||
const cancelClose = useCallback(
|
||||
(): void => clearTimeout(closeTimer.current),
|
||||
[],
|
||||
);
|
||||
|
||||
const scheduleClose = useCallback((): void => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(
|
||||
() => setTarget(null),
|
||||
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
|
||||
);
|
||||
}, [cancelClose]);
|
||||
|
||||
const onRowEnter = useCallback(
|
||||
(next: SavedViewHoverCardTarget): void => {
|
||||
cancelOpen();
|
||||
openTimer.current = setTimeout(() => {
|
||||
cancelClose();
|
||||
setTarget(next);
|
||||
}, SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
},
|
||||
[cancelOpen, cancelClose],
|
||||
);
|
||||
|
||||
const onRowLeave = useCallback((): void => {
|
||||
cancelOpen();
|
||||
scheduleClose();
|
||||
}, [cancelOpen, scheduleClose]);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
cancelOpen();
|
||||
cancelClose();
|
||||
setTarget(null);
|
||||
}, [cancelOpen, cancelClose]);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
cancelOpen();
|
||||
cancelClose();
|
||||
},
|
||||
[cancelOpen, cancelClose],
|
||||
);
|
||||
|
||||
return {
|
||||
target,
|
||||
onRowEnter,
|
||||
onRowLeave,
|
||||
onCardEnter: cancelClose,
|
||||
onCardLeave: scheduleClose,
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
useDeleteSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from '../constants';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
|
||||
function showErrorToast(error: unknown, fallback: string): void {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
fallback,
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
}
|
||||
|
||||
export function useSavedViewRowActions(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseSavedViewRowActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
useDeleteSavedView();
|
||||
|
||||
const copyViewLink = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
},
|
||||
[pathname, copyToClipboard],
|
||||
);
|
||||
|
||||
const renameView = useCallback(
|
||||
async (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
displayName: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...view.spec, displayName },
|
||||
},
|
||||
});
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not update the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
// while the header still has it selected.
|
||||
const deleteView = useCallback(
|
||||
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
|
||||
try {
|
||||
await deleteSavedView({ pathParams: { id: view.id } });
|
||||
await invalidateSavedViews(queryClient, source);
|
||||
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not delete the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[deleteSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
|
||||
}
|
||||
35
frontend/src/container/SavedViews/hooks/useSavedViewsList.ts
Normal file
35
frontend/src/container/SavedViews/hooks/useSavedViewsList.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { UseSavedViewsListResult } from '../types';
|
||||
|
||||
export function useSavedViewsList(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
search: string,
|
||||
): UseSavedViewsListResult {
|
||||
const { user } = useAppContext();
|
||||
const { data, isLoading, isError, refetch } = useListSavedViews({ source });
|
||||
|
||||
const { createdByMe, createdByOthers } = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
const views = (data?.data ?? []).filter((view) =>
|
||||
view.spec.displayName.toLowerCase().includes(term),
|
||||
);
|
||||
return {
|
||||
createdByMe: views.filter((view) => view.createdBy === user.email),
|
||||
createdByOthers: views.filter((view) => view.createdBy !== user.email),
|
||||
};
|
||||
}, [data, search, user.email]);
|
||||
|
||||
return {
|
||||
createdByMe,
|
||||
createdByOthers,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch: (): void => {
|
||||
void refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
209
frontend/src/container/SavedViews/types.ts
Normal file
209
frontend/src/container/SavedViews/types.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { Link } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
export type SavedViewOptions = Pick<
|
||||
OptionsQuery,
|
||||
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
|
||||
>;
|
||||
|
||||
export interface ToSavedViewSpecArgs {
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES;
|
||||
displayName: string;
|
||||
options?: SavedViewOptions;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface HasUnsavedViewChangesArgs {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
stagedQuery: Query | null;
|
||||
panelType: PANEL_TYPES;
|
||||
options?: SavedViewOptions;
|
||||
}
|
||||
|
||||
export interface UseLastUsedViewResult {
|
||||
getLastUsedViewKey: () => string | undefined;
|
||||
setLastUsedView: (id: string, displayName: string) => void;
|
||||
clearLastUsedView: () => void;
|
||||
}
|
||||
|
||||
export interface UseActiveSavedViewResult {
|
||||
view: SavedviewtypesSavedViewDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export interface UseRestoreLastUsedViewArgs {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
selectView: UseSavedViewActionsResult['selectView'];
|
||||
}
|
||||
|
||||
export type SaveViewModalMode = 'create' | 'saveAsNew' | 'edit';
|
||||
|
||||
export interface SaveViewModalProps {
|
||||
mode: SaveViewModalMode;
|
||||
initialName?: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
// Resolves true when saved; the modal stays open otherwise.
|
||||
onSave: (displayName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SaveChangesMenuProps {
|
||||
disabled: boolean;
|
||||
onSaveAsNew: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsIconButtonProps {
|
||||
title: string;
|
||||
icon: ReactElement;
|
||||
testId: string;
|
||||
color?: 'secondary' | 'warning';
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface UseSavedViewsListResult {
|
||||
createdByMe: SavedviewtypesSavedViewDTO[];
|
||||
createdByOthers: SavedviewtypesSavedViewDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsListSectionProps {
|
||||
title: string;
|
||||
views: SavedviewtypesSavedViewDTO[];
|
||||
activeViewId?: string;
|
||||
onSelect: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
onClear: () => void;
|
||||
onAction: SavedViewsListRowProps['onAction'];
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export interface SavedViewsListRowProps {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
isActive: boolean;
|
||||
onSelect: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
onClear: () => void;
|
||||
onAction: (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
key: SavedViewRowActionKey,
|
||||
) => void;
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
|
||||
|
||||
export interface SavedViewRowActionConfig {
|
||||
key: SavedViewRowActionKey;
|
||||
label: string;
|
||||
icon: typeof Link;
|
||||
danger?: boolean;
|
||||
hasDividerBefore?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingSavedViewAction {
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
export interface SavedViewsRowButtonProps {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
testId: string;
|
||||
onClick?: () => void;
|
||||
onPointerDown?: () => void;
|
||||
onKeyDown?: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsRowMenuProps {
|
||||
onAction: (key: SavedViewRowActionKey) => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface DeleteSavedViewDialogProps {
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export interface UseSavedViewRowActionsResult {
|
||||
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
renameView: (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
displayName: string,
|
||||
) => Promise<boolean>;
|
||||
deleteView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isRenaming: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardTarget {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
anchorTop: number;
|
||||
anchorHeight: number;
|
||||
}
|
||||
|
||||
export interface UseSavedViewHoverCardResult {
|
||||
target: SavedViewHoverCardTarget | null;
|
||||
onRowEnter: (target: SavedViewHoverCardTarget) => void;
|
||||
onRowLeave: () => void;
|
||||
onCardEnter: () => void;
|
||||
onCardLeave: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardProps {
|
||||
target: SavedViewHoverCardTarget | null;
|
||||
onCardEnter: () => void;
|
||||
onCardLeave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsListRowHoverProps {
|
||||
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
|
||||
onRowLeave: () => void;
|
||||
onMenuOpen: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewQuerySummary {
|
||||
queryName: string;
|
||||
expression: string;
|
||||
metric?: string;
|
||||
}
|
||||
|
||||
export interface HighlightedQueryPart {
|
||||
from: number;
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewsEmptyStateProps {
|
||||
isSearching: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: (displayName: string) => Promise<boolean>;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { findSavedView } from '../findSavedView';
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [{ id: 'a' }, { id: 'b' }] as SavedviewtypesSavedViewDTO[];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
|
||||
import { getSavedViewQuery } from '../getSavedViewQuery';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
@@ -91,36 +91,3 @@ describe('getSavedViewQuery', () => {
|
||||
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [
|
||||
{ ...makeView(), id: 'a' },
|
||||
{ ...makeView(), id: 'b' },
|
||||
];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getSavedViewQuery } from '../getSavedViewQuery';
|
||||
import { hasUnsavedViewChanges } from '../hasUnsavedViewChanges';
|
||||
import { toSavedViewSpec } from '../toSavedViewSpec';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function query(dataSource: DataSource, expression = 'has_error = true'): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
// The server stores the queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
function asStoredByServer(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, v]) => [key, asStoredByServer(v)] as const)
|
||||
.filter(([, v]) => v !== undefined);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
return value === null || value === '' || value === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
function viewFrom(
|
||||
spec: SavedviewtypesSavedViewSpecDTO,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id: 'view-1',
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
JSON.parse(JSON.stringify(spec.queries)),
|
||||
) as SavedviewtypesSavedViewSpecDTO['queries'],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// What the query builder stages after the view is opened: the stored query,
|
||||
// with its defaults filled in.
|
||||
function stagedFrom(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
dataSource: DataSource,
|
||||
): Query {
|
||||
const saved = getSavedViewQuery(view);
|
||||
return {
|
||||
...saved,
|
||||
builder: {
|
||||
...saved.builder,
|
||||
queryData: saved.builder.queryData.map((queryData) => ({
|
||||
...initialQueryBuilderFormValuesMap[dataSource],
|
||||
...queryData,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ name: 'service.name', key: 'service.name' },
|
||||
{ name: 'name', key: 'name' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
const formatting = {
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
};
|
||||
|
||||
describe('hasUnsavedViewChanges', () => {
|
||||
const options = { selectColumns: columns, ...formatting };
|
||||
const view = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options,
|
||||
}),
|
||||
);
|
||||
const staged = stagedFrom(view, DataSource.TRACES);
|
||||
|
||||
it('is dirty when the columns change', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, selectColumns: [columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a traces view without columns as the default columns', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, selectColumns: defaultTraceSelectedColumns },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('on logs', () => {
|
||||
const logsView = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: { selectColumns: columns, ...formatting },
|
||||
}),
|
||||
);
|
||||
const logsStaged = stagedFrom(logsView, DataSource.LOGS);
|
||||
// The explorer always adds the required logs columns.
|
||||
const shownColumns = ensureLogsRequiredColumns(columns);
|
||||
|
||||
it('is clean with the required columns the explorer adds', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { selectColumns: shownColumns, ...formatting },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty when the formatting changes', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a view without formatting as the default formatting', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: {
|
||||
selectColumns: defaultLogsSelectedColumns,
|
||||
format: 'table',
|
||||
maxLines: 1,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores fields the save path never writes, kept by older views', () => {
|
||||
const metricsView = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.METRICS, ''),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'Old metrics view',
|
||||
}),
|
||||
);
|
||||
const [storedQuery] = metricsView.spec.queries;
|
||||
const olderView = {
|
||||
...metricsView,
|
||||
spec: {
|
||||
...metricsView.spec,
|
||||
queries: [
|
||||
{
|
||||
...storedQuery,
|
||||
spec: {
|
||||
...storedQuery.spec,
|
||||
aggregations: [
|
||||
{ metricName: 'erlang_vm_msacc_sleep_seconds_total', reduceTo: 'avg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: olderView,
|
||||
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores formatting on traces', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
|
||||
import { highlightQueryExpression } from '../highlightQueryExpression';
|
||||
|
||||
const EXPRESSION =
|
||||
"service.name = 'frontend' AND duration_nano > 100 AND status IN ['ok', 'error']";
|
||||
|
||||
function stringColor(styles: typeof copilotDarkStyle): string | undefined {
|
||||
return (styles ?? []).find(({ tag }) =>
|
||||
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
|
||||
)?.color;
|
||||
}
|
||||
|
||||
describe('highlightQueryExpression', () => {
|
||||
it.each([
|
||||
['dark', true],
|
||||
['light', false],
|
||||
])('keeps every character of the expression in %s mode', (_, isDarkMode) => {
|
||||
const parts = highlightQueryExpression(EXPRESSION, isDarkMode);
|
||||
|
||||
expect(parts.map(({ text }) => text).join('')).toBe(EXPRESSION);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dark', true, copilotDarkStyle],
|
||||
['light', false, githubLightStyle],
|
||||
])(
|
||||
'colours string values like the %s query search theme',
|
||||
(_, isDarkMode, styles) => {
|
||||
const parts = highlightQueryExpression(EXPRESSION, isDarkMode);
|
||||
|
||||
expect(parts.find(({ text }) => text === "'frontend'")?.color).toBe(
|
||||
stringColor(styles),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns nothing for an empty expression', () => {
|
||||
expect(highlightQueryExpression('', true)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { toSavedViewSource } from '../toSavedViewSource';
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
|
||||
import { toSavedViewPanelType, toSavedViewSpec } from '../toSavedViewSpec';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function tracesQuery(): Query {
|
||||
const base = initialQueriesMap.traces;
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression: 'has_error = true' },
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'service.name',
|
||||
key: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
isIndexed: true,
|
||||
},
|
||||
{ key: 'name' },
|
||||
] as unknown as TelemetryFieldKey[];
|
||||
|
||||
describe('toSavedViewPanelType', () => {
|
||||
it('folds a dashboard only panel type to graph', () => {
|
||||
expect(toSavedViewPanelType(PANEL_TYPES.PIE)).toBe(
|
||||
SavedviewtypesPanelTypeDTO.graph,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSpec', () => {
|
||||
it('stores only the field identity of the columns, naming key only ones', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: {
|
||||
selectColumns: columns,
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spec.selectedFields).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{ name: 'name' },
|
||||
]);
|
||||
expect(spec.display).toStrictEqual({
|
||||
format: 'raw',
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the colour in display', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'Errors',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
expect(spec.panelType).toBe(SavedviewtypesPanelTypeDTO.graph);
|
||||
expect(spec.requestType).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
|
||||
expect(spec.display).toStrictEqual({ color: 'red' });
|
||||
});
|
||||
});
|
||||
8
frontend/src/container/SavedViews/utils/findSavedView.ts
Normal file
8
frontend/src/container/SavedViews/utils/findSavedView.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
17
frontend/src/container/SavedViews/utils/getSavedViewQuery.ts
Normal file
17
frontend/src/container/SavedViews/utils/getSavedViewQuery.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewQuerySummary } from '../types';
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
|
||||
export function getSavedViewQuerySummaries(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedViewQuerySummary[] {
|
||||
return getSavedViewQuery(view)
|
||||
.builder.queryData.map((query): SavedViewQuerySummary => {
|
||||
const aggregation = query.aggregations?.[0] as MetricAggregation | undefined;
|
||||
return {
|
||||
queryName: query.queryName,
|
||||
expression: convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters ?? { items: [], op: 'AND' },
|
||||
query.filter?.expression,
|
||||
).filter.expression.trim(),
|
||||
metric:
|
||||
query.dataSource === DataSource.METRICS && aggregation?.metricName
|
||||
? [
|
||||
aggregation.metricName,
|
||||
aggregation.timeAggregation,
|
||||
aggregation.spaceAggregation,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.filter(({ expression, metric }) => expression.length > 0 || !!metric);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { Preferences } from 'providers/preferences/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function withColumnNames(
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
): TelemetryFieldKey[] | null {
|
||||
if (!columns) {
|
||||
return null;
|
||||
}
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
name: column.name ?? column.key,
|
||||
}));
|
||||
}
|
||||
|
||||
// The columns and formatting the explorer shows for a saved view, with the
|
||||
// defaults filled in where the view has none.
|
||||
export function getViewColumnsAndFormatting(
|
||||
spec: SavedviewtypesSavedViewSpecDTO | undefined,
|
||||
dataSource: DataSource,
|
||||
): Preferences {
|
||||
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
|
||||
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
return {
|
||||
columns: ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
),
|
||||
formatting: {
|
||||
maxLines: spec?.display?.maxLines || 1,
|
||||
format: (spec?.display?.format as LogViewMode) || 'table',
|
||||
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return { columns: selectedFields || defaultTraceSelectedColumns };
|
||||
}
|
||||
return { columns: [] };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { HasUnsavedViewChangesArgs } from '../types';
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
import { toSavedViewPanelType } from './toSavedViewSpec';
|
||||
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
|
||||
|
||||
// The server drops empty values when it stores a query, while the query
|
||||
// builder fills them with defaults, so both sides lose them before comparing.
|
||||
function withoutEmptyValues(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map(withoutEmptyValues)
|
||||
.filter((item) => item !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
|
||||
.filter(([, item]) => item !== undefined);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
if (value === null || value === '' || value === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Both sides go through the v5 mapper, so fields it never writes (reduceTo on
|
||||
// a graph in older views) drop out; the builder's shape gets different defaults.
|
||||
export function hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
options,
|
||||
}: HasUnsavedViewChangesArgs): boolean {
|
||||
if (!stagedQuery) {
|
||||
return false;
|
||||
}
|
||||
if (view.spec.panelType !== toSavedViewPanelType(panelType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { queries: storedQueries } = mapCompositeQueryFromQuery(
|
||||
getSavedViewQuery(view),
|
||||
panelType,
|
||||
);
|
||||
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
|
||||
stagedQuery,
|
||||
panelType,
|
||||
);
|
||||
if (
|
||||
!isEqual(withoutEmptyValues(storedQueries), withoutEmptyValues(stagedQueries))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
dataSource,
|
||||
);
|
||||
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
|
||||
fields.map((field) => field.name ?? field.key);
|
||||
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dataSource !== DataSource.LOGS || !formatting) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
formatting.format !== options.format ||
|
||||
formatting.maxLines !== options.maxLines ||
|
||||
formatting.fontSize !== options.fontSize
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { Highlighter, highlightTree, Tag } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
|
||||
import { HighlightedQueryPart } from '../types';
|
||||
|
||||
function createThemeHighlighter(styles: typeof copilotDarkStyle): Highlighter {
|
||||
const colorByTag = new Map<Tag, string>();
|
||||
(styles ?? []).forEach(({ tag, color }) => {
|
||||
if (typeof color !== 'string') {
|
||||
return;
|
||||
}
|
||||
(Array.isArray(tag) ? tag : [tag]).forEach((themeTag: Tag) => {
|
||||
if (!colorByTag.has(themeTag)) {
|
||||
colorByTag.set(themeTag, color);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
style: (tags): string | null => {
|
||||
const match = tags
|
||||
.flatMap((tag) => tag.set)
|
||||
.find((candidate) => colorByTag.has(candidate));
|
||||
return match ? (colorByTag.get(match) ?? null) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DARK_HIGHLIGHTER = createThemeHighlighter(copilotDarkStyle);
|
||||
const LIGHT_HIGHLIGHTER = createThemeHighlighter(githubLightStyle);
|
||||
|
||||
export function highlightQueryExpression(
|
||||
expression: string,
|
||||
isDarkMode: boolean,
|
||||
): HighlightedQueryPart[] {
|
||||
const tree = javascriptLanguage.parser.parse(expression);
|
||||
const parts: HighlightedQueryPart[] = [];
|
||||
let position = 0;
|
||||
|
||||
highlightTree(
|
||||
tree,
|
||||
isDarkMode ? DARK_HIGHLIGHTER : LIGHT_HIGHLIGHTER,
|
||||
(from, to, color) => {
|
||||
if (from > position) {
|
||||
parts.push({ from: position, text: expression.slice(position, from) });
|
||||
}
|
||||
parts.push({ from, text: expression.slice(from, to), color });
|
||||
position = to;
|
||||
},
|
||||
);
|
||||
|
||||
if (position < expression.length) {
|
||||
parts.push({ from: position, text: expression.slice(position) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
import {
|
||||
invalidateGetSavedView,
|
||||
invalidateListSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export async function invalidateSavedViews(
|
||||
queryClient: QueryClient,
|
||||
source: SavedviewtypesSourceDTO,
|
||||
id?: string,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
invalidateListSavedViews(queryClient),
|
||||
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
|
||||
// The saved views bar still lists through v1.
|
||||
queryClient.invalidateQueries([{ sourcepage: source }]),
|
||||
]);
|
||||
}
|
||||
23
frontend/src/container/SavedViews/utils/toSavedViewSource.ts
Normal file
23
frontend/src/container/SavedViews/utils/toSavedViewSource.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewSourcePage } from '../types';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
80
frontend/src/container/SavedViews/utils/toSavedViewSpec.ts
Normal file
80
frontend/src/container/SavedViews/utils/toSavedViewSpec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
SavedviewtypesDisplayDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import { panelTypeToRequestType } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { ToSavedViewSpecArgs } from '../types';
|
||||
|
||||
const SAVED_VIEW_PANEL_TYPES = new Set<string>(
|
||||
Object.values(SavedviewtypesPanelTypeDTO),
|
||||
);
|
||||
|
||||
// Explorers only produce list / trace / graph / table; anything else a caller
|
||||
// hands over (a dashboard panel type) is stored as a time series view.
|
||||
export function toSavedViewPanelType(
|
||||
panelType: PANEL_TYPES,
|
||||
): SavedviewtypesPanelTypeDTO {
|
||||
return SAVED_VIEW_PANEL_TYPES.has(panelType)
|
||||
? (panelType as unknown as SavedviewtypesPanelTypeDTO)
|
||||
: SavedviewtypesPanelTypeDTO.graph;
|
||||
}
|
||||
|
||||
// The api stores the field identity only; `key` and the column flags are ui state.
|
||||
function toFieldKey(
|
||||
column: TelemetryFieldKey,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO {
|
||||
return {
|
||||
name: column.name ?? column.key ?? '',
|
||||
...(column.signal && {
|
||||
signal: column.signal as unknown as TelemetrytypesSignalDTO,
|
||||
}),
|
||||
...(column.fieldContext && {
|
||||
fieldContext:
|
||||
column.fieldContext as unknown as TelemetrytypesFieldContextDTO,
|
||||
}),
|
||||
...(column.fieldDataType && {
|
||||
fieldDataType:
|
||||
column.fieldDataType as unknown as TelemetrytypesFieldDataTypeDTO,
|
||||
}),
|
||||
...(column.description && { description: column.description }),
|
||||
...(column.unit && { unit: column.unit }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toSavedViewSpec({
|
||||
query,
|
||||
panelType,
|
||||
displayName,
|
||||
options,
|
||||
color,
|
||||
}: ToSavedViewSpecArgs): SavedviewtypesSavedViewSpecDTO {
|
||||
const { queries } = mapCompositeQueryFromQuery(query, panelType);
|
||||
|
||||
const display: SavedviewtypesDisplayDTO = {
|
||||
...(color && { color }),
|
||||
...(options?.format && { format: options.format }),
|
||||
...(options?.maxLines && { maxLines: options.maxLines }),
|
||||
...(options?.fontSize && { fontSize: options.fontSize }),
|
||||
};
|
||||
|
||||
return {
|
||||
displayName,
|
||||
panelType: toSavedViewPanelType(panelType),
|
||||
requestType: panelTypeToRequestType(panelType),
|
||||
queries: (queries ?? []) as unknown as Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
...(options?.selectColumns?.length && {
|
||||
selectedFields: options.selectColumns.map(toFieldKey),
|
||||
}),
|
||||
...(Object.keys(display).length > 0 && { display }),
|
||||
};
|
||||
}
|
||||
@@ -73,32 +73,20 @@ export const useHandleExplorerTabChange = (): {
|
||||
}
|
||||
|
||||
const query = currentQueryData?.query || getUpdateQuery(newPanelType);
|
||||
const nextViewKey = currentQueryData?.viewKey || viewKey;
|
||||
const nextViewName = currentQueryData?.viewName || viewName;
|
||||
|
||||
if (redirectToUrl) {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
}
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
|
||||
...(nextViewName ? { [QueryParams.viewName]: nextViewName } : {}),
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
},
|
||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
|
||||
);
|
||||
|
||||
@@ -295,11 +295,7 @@ const account = (
|
||||
provider,
|
||||
providerAccountId: ACCOUNTS[provider][index],
|
||||
config: accountConfig(provider),
|
||||
agentReport: {
|
||||
timestampMillis: Date.now() - 45 * 1000,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
agentReport: { timestampMillis: Date.now() - 45 * 1000, data: null },
|
||||
createdAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
removedAt: null,
|
||||
|
||||
@@ -4,15 +4,10 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.log-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-module-right-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
.log-explorer-query-container {
|
||||
@@ -39,26 +34,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.log-quick-filter-left-section {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.log-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import LogsExplorer from '../index';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.LOGS_EXPLORER}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/Toolbar/Toolbar',
|
||||
() =>
|
||||
function Toolbar({
|
||||
showLiveLogs,
|
||||
onGoLive,
|
||||
}: {
|
||||
showLiveLogs?: boolean;
|
||||
onGoLive?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<button type="button" data-testid="go-live" onClick={onGoLive}>
|
||||
Live
|
||||
</button>
|
||||
<span data-testid="live-state">{showLiveLogs ? 'live' : 'not-live'}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/TimeSeriesView/TimeSeriesView',
|
||||
() =>
|
||||
function TimeSeriesView(): JSX.Element {
|
||||
return <div>Time Series Chart</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/LogsExplorerChart',
|
||||
() =>
|
||||
function LogsExplorerChart(): JSX.Element {
|
||||
return <div>Frequency chart content</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('container/LiveLogs/LiveLogsContainer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div>Live logs</div>,
|
||||
}));
|
||||
|
||||
jest.mock('constants/panelTypes', () => ({
|
||||
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
|
||||
}));
|
||||
|
||||
jest.mock('d3-interpolate', () => ({
|
||||
interpolate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): unknown => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: { maxLines: 1, format: 'table', fontSize: 'small', version: 1 },
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function page(): JSX.Element {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[ROUTES.LOGS_EXPLORER]}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Logs Explorer live mode', () => {
|
||||
// useLocation is mocked without a search string, so the tab is driven
|
||||
// through the hook that reads it from the url.
|
||||
const panelTypeSpy = jest.spyOn(
|
||||
panelTypesQueryParamHooks,
|
||||
'useGetPanelTypesQueryParam',
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
panelTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('ends when the url moves off the list tab, whatever moved it', () => {
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
|
||||
const { rerender } = render(page());
|
||||
|
||||
fireEvent.click(screen.getByTestId('go-live'));
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
|
||||
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.TIME_SERIES);
|
||||
rerender(page());
|
||||
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('not-live');
|
||||
});
|
||||
|
||||
it('stays on while the url keeps the list tab', () => {
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
|
||||
const { rerender } = render(page());
|
||||
|
||||
fireEvent.click(screen.getByTestId('go-live'));
|
||||
rerender(page());
|
||||
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
// https://virtuoso.dev/mocking-in-tests/
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import {
|
||||
@@ -130,6 +131,30 @@ describe('Logs Explorer Tests', () => {
|
||||
// expect(oldLogsCTA).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the tab the url panelTypes param names', () => {
|
||||
// useLocation is mocked without a search string in this file, so drive
|
||||
// the param through the hook that reads it.
|
||||
const panelTypeSpy = jest
|
||||
.spyOn(panelTypesQueryParamHooks, 'useGetPanelTypesQueryParam')
|
||||
.mockReturnValue(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/logs-explorer/?panelTypes=%22graph%22']}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.timeseries-view-tab')).toHaveClass(
|
||||
'active-tab',
|
||||
);
|
||||
expect(container.querySelector('.list-view-tab')).not.toHaveClass(
|
||||
'active-tab',
|
||||
);
|
||||
panelTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
// update this test properly
|
||||
it.skip('Logs Explorer Page should render with data', async () => {
|
||||
// mocking the query range API to return the logs
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -51,9 +51,13 @@ function LogsExplorer(): JSX.Element {
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(
|
||||
() => panelTypeToExplorerView[panelTypesFromUrl],
|
||||
);
|
||||
const selectedView = panelTypeToExplorerView[panelTypesFromUrl];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedView !== ExplorerViews.LIST) {
|
||||
setShowLiveLogs(false);
|
||||
}
|
||||
}, [selectedView]);
|
||||
|
||||
const [showFilters, setShowFilters] = useState<boolean>(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
@@ -116,15 +120,10 @@ function LogsExplorer(): JSX.Element {
|
||||
);
|
||||
|
||||
handleSetConfig(nextPanelType, DataSource.LOGS);
|
||||
setSelectedView(view);
|
||||
|
||||
if (view !== ExplorerViews.LIST) {
|
||||
setShowLiveLogs(false);
|
||||
}
|
||||
|
||||
handleExplorerTabChange(nextPanelType, querySearchParameters);
|
||||
},
|
||||
[handleSetConfig, handleExplorerTabChange, setSelectedView],
|
||||
[handleSetConfig, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
@@ -225,21 +224,19 @@ function LogsExplorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<EventSourceProvider>
|
||||
<div
|
||||
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
|
||||
<QuickFiltersLayout
|
||||
className="logs-module-page"
|
||||
showFilters={showFilters}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.logs }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-logs-explorer',
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
{showFilters && (
|
||||
<section className={cx('log-quick-filter-left-section')}>
|
||||
<QuickFilters
|
||||
className="qf-logs-explorer"
|
||||
signal={SignalType.LOGS}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section className={cx('log-module-right-section')}>
|
||||
<section className="log-module-right-section">
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
@@ -292,7 +289,7 @@ function LogsExplorer(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</EventSourceProvider>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&.is-explorer-tab .ant-tabs-content-holder {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-use';
|
||||
import cx from 'classnames';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
|
||||
@@ -43,7 +45,9 @@ function MetricsExplorerPage(): JSX.Element {
|
||||
|
||||
return (
|
||||
<RouteTab
|
||||
className="metrics-explorer-page"
|
||||
className={cx('metrics-explorer-page', {
|
||||
'is-explorer-tab': pathname === ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
})}
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
@@ -111,8 +112,9 @@ function TracesExplorer(): JSX.Element {
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
const selectedView = useMemo(
|
||||
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
[searchParams, panelTypesFromUrl],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
@@ -138,8 +140,6 @@ function TracesExplorer(): JSX.Element {
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
@@ -285,6 +285,7 @@ function TracesExplorer(): JSX.Element {
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
showFilters={isOpen}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.traces }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -32,8 +32,8 @@ jest.mock('../updater/usePreferenceUpdater', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseListSavedViews = useListSavedViews as jest.MockedFunction<
|
||||
typeof useListSavedViews
|
||||
const mockedUseGetSavedView = useGetSavedView as jest.MockedFunction<
|
||||
typeof useGetSavedView
|
||||
>;
|
||||
|
||||
function makeView(
|
||||
@@ -55,18 +55,30 @@ function makeView(
|
||||
} as unknown as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// Stable objects, like react-query's cache; a fresh one per render loops the
|
||||
// sync effect.
|
||||
function mockViews(views: SavedviewtypesSavedViewDTO[]): void {
|
||||
mockedUseListSavedViews.mockReturnValue({
|
||||
data: { status: 'success', data: views },
|
||||
} as unknown as ReturnType<typeof useListSavedViews>);
|
||||
const responses = new Map(
|
||||
views.map((view) => [
|
||||
view.id,
|
||||
{ data: { status: 'success', data: view }, dataUpdatedAt: 0 },
|
||||
]),
|
||||
);
|
||||
const notLoaded = { data: undefined, dataUpdatedAt: 0 };
|
||||
mockedUseGetSavedView.mockImplementation(
|
||||
({ id }) =>
|
||||
(responses.get(id) ?? notLoaded) as unknown as ReturnType<
|
||||
typeof useGetSavedView
|
||||
>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('usePreferenceSync in saved view mode', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseListSavedViews.mockReset();
|
||||
mockedUseGetSavedView.mockReset();
|
||||
});
|
||||
|
||||
it('fetches the list for the data source only in saved view mode', () => {
|
||||
it('fetches the view only in saved view mode with a view id', () => {
|
||||
mockViews([]);
|
||||
|
||||
renderHook(() =>
|
||||
@@ -77,8 +89,8 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockedUseListSavedViews).toHaveBeenCalledWith(
|
||||
{ source: 'logs' },
|
||||
expect(mockedUseGetSavedView).toHaveBeenCalledWith(
|
||||
{ id: '' },
|
||||
{ query: { enabled: false } },
|
||||
);
|
||||
});
|
||||
@@ -184,7 +196,7 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses defaults when the saved view id is not in the list', () => {
|
||||
it('uses defaults while the saved view is not loaded', () => {
|
||||
mockViews([makeView('other', SavedviewtypesSourceDTO.logs, {})]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { findSavedView, toSavedViewSource } from 'container/SavedViews/utils';
|
||||
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
@@ -30,52 +24,22 @@ export function usePreferenceSync({
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const { data: viewsData } = useListSavedViews(
|
||||
{ source: toSavedViewSource(dataSource) },
|
||||
{ query: { enabled: mode === PreferenceMode.SAVED_VIEW } },
|
||||
const isSavedViewMode = mode === PreferenceMode.SAVED_VIEW && !!savedViewId;
|
||||
const { data: viewData, dataUpdatedAt } = useGetSavedView(
|
||||
{ id: savedViewId ?? '' },
|
||||
{ query: { enabled: isSavedViewMode } },
|
||||
);
|
||||
|
||||
const [savedViewPreferences, setSavedViewPreferences] =
|
||||
useState<Preferences | null>(null);
|
||||
|
||||
const withColumnNames = (
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
): TelemetryFieldKey[] | null => {
|
||||
if (!columns) {
|
||||
return null;
|
||||
}
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
name: column.name ?? column.key,
|
||||
}));
|
||||
};
|
||||
|
||||
// dataUpdatedAt re-runs this on a refetch that returns the same view, so
|
||||
// discarding restores columns edited in memory.
|
||||
useEffect(() => {
|
||||
const spec = savedViewId
|
||||
? findSavedView(viewsData?.data, savedViewId)?.spec
|
||||
: undefined;
|
||||
const selectedFields = spec?.selectedFields as
|
||||
| TelemetryFieldKey[]
|
||||
| undefined;
|
||||
|
||||
let columns: TelemetryFieldKey[] = [];
|
||||
let formatting: FormattingOptions | undefined;
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
columns = ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
);
|
||||
formatting = {
|
||||
maxLines: spec?.display?.maxLines || 1,
|
||||
format: (spec?.display?.format as LogViewMode) || 'table',
|
||||
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
columns = selectedFields || defaultTraceSelectedColumns;
|
||||
}
|
||||
setSavedViewPreferences({ columns, formatting });
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
const view = viewData?.data;
|
||||
const spec = view && view.id === savedViewId ? view.spec : undefined;
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(spec, dataSource));
|
||||
}, [viewData, dataUpdatedAt, dataSource, savedViewId, mode]);
|
||||
|
||||
// We are using a reSync state because we have URL updates as well as local storage updates
|
||||
// and we want to make sure we are always using the latest preferences
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
@@ -77,7 +74,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: authDomainPostableRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
@@ -149,7 +146,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: authDomainUpdatableRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
@@ -199,20 +196,16 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
|
||||
// The extracted names are the roles the request body's mapping grants at SSO
|
||||
// login — see authDomainEffectiveRoleNames.
|
||||
func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
|
||||
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
|
||||
return authDomainEffectiveRoleNames(nil), nil
|
||||
}
|
||||
func authDomainPostableRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.BodyFields(func(req *authtypes.PostableAuthDomain) []string {
|
||||
return authDomainEffectiveRoleNames(req.RoleMapping)
|
||||
})
|
||||
}
|
||||
|
||||
roleMapping := new(authtypes.RoleMapping)
|
||||
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
|
||||
}
|
||||
|
||||
return authDomainEffectiveRoleNames(roleMapping), nil
|
||||
}}
|
||||
func authDomainUpdatableRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.BodyFields(func(req *authtypes.UpdatableAuthDomain) []string {
|
||||
return authDomainEffectiveRoleNames(req.RoleMapping)
|
||||
})
|
||||
}
|
||||
|
||||
// The extracted names are the roles the stored domain's mapping grants at SSO
|
||||
|
||||
@@ -350,7 +350,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.BodyJSONPath("account_id"),
|
||||
ID: coretypes.BodyField(func(req *citypes.PostableAgentCheckIn) string { return req.ID }),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
@@ -377,7 +377,12 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.BodyJSONPath("cloudIntegrationId"),
|
||||
ID: coretypes.BodyField(func(req *citypes.PostableAgentCheckIn) string {
|
||||
if req.CloudIntegrationID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.CloudIntegrationID.StringValue()
|
||||
}),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
|
||||
@@ -332,7 +332,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ParentResource: coretypes.ResourceMetaResourceIngestionKey,
|
||||
ParentID: coretypes.BodyJSONPath("keyId"),
|
||||
ParentID: coretypes.BodyField(func(req *gatewaytypes.PostableIngestionKeyLimit) string { return req.KeyID }),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceIngestionLimit,
|
||||
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
@@ -79,6 +80,14 @@ func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
|
||||
}}
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
|
||||
@@ -461,10 +461,11 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
RequiresBody: true,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -483,10 +484,11 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
RequiresBody: true,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
pkghandler "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -55,6 +56,14 @@ func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
|
||||
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Healthz),
|
||||
|
||||
@@ -358,10 +358,20 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.ServiceAccountID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.ServiceAccountID.StringValue()
|
||||
})),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.RoleID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.RoleID.StringValue()
|
||||
})),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
|
||||
@@ -68,7 +68,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.BodyJSONPath("name"),
|
||||
ID: coretypes.BodyField(func(req *zeustypes.PostableHost) string { return req.Name }),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -284,8 +285,8 @@ func (handler *handler) CreateIngestionKeyLimit(rw http.ResponseWriter, r *http.
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var req gatewaytypes.PostableIngestionKeyLimit
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
req, err := coretypes.BodyFromContext[gatewaytypes.PostableIngestionKeyLimit](r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/swaggest/openapi-go"
|
||||
"github.com/swaggest/openapi-go/openapi3"
|
||||
@@ -16,12 +19,15 @@ type Handler interface {
|
||||
http.Handler
|
||||
ServeOpenAPI(openapi.OperationContext)
|
||||
ResourceDefs() []ResourceDef
|
||||
Request() any
|
||||
BindBodyOptions() []binding.BindBodyOption
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
resourceDefs []ResourceDef
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
resourceDefs []ResourceDef
|
||||
bindBodyOptions []binding.BindBodyOption
|
||||
}
|
||||
|
||||
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
|
||||
@@ -47,6 +53,10 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
|
||||
opt(handler)
|
||||
}
|
||||
|
||||
if RequiresBody(handler.resourceDefs) && (openAPIDef.Request == nil || reflect.TypeOf(openAPIDef.Request).Kind() != reflect.Pointer) {
|
||||
panic(fmt.Sprintf("handler %s: a body extractor needs OpenAPIDef.Request to be a pointer, got %T", openAPIDef.ID, openAPIDef.Request))
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -135,3 +145,11 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
func (handler *handler) ResourceDefs() []ResourceDef {
|
||||
return handler.resourceDefs
|
||||
}
|
||||
|
||||
func (handler *handler) Request() any {
|
||||
return handler.openAPIDef.Request
|
||||
}
|
||||
|
||||
func (handler *handler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return handler.bindBodyOptions
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -22,6 +24,41 @@ func (bespokeOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
|
||||
func (bespokeOpenAPIHandler) ResourceDefs() []ResourceDef { return nil }
|
||||
|
||||
func (bespokeOpenAPIHandler) Request() any { return nil }
|
||||
|
||||
func (bespokeOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption { return nil }
|
||||
|
||||
func TestNewPanicsWhenBodyExtractorHasNoPointerRequest(t *testing.T) {
|
||||
type body struct{ ID string }
|
||||
bodyDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.BodyField(func(req *body) string { return req.ID }), Selector: coretypes.IDSelector}
|
||||
pathDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.PathParam("id"), Selector: coretypes.IDSelector}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
request any
|
||||
def ResourceDef
|
||||
panics bool
|
||||
}{
|
||||
{name: "BodyExtractor_ValueRequest_Panics", request: body{}, def: bodyDef, panics: true},
|
||||
{name: "BodyExtractor_NilRequest_Panics", request: nil, def: bodyDef, panics: true},
|
||||
{name: "BodyExtractor_PointerRequest_Registers", request: new(body), def: bodyDef, panics: false},
|
||||
{name: "PathExtractor_ValueRequest_Registers", request: body{}, def: pathDef, panics: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
register := func() {
|
||||
New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: testCase.name, Request: testCase.request}, WithResourceDefs(testCase.def))
|
||||
}
|
||||
if testCase.panics {
|
||||
assert.Panics(t, register)
|
||||
} else {
|
||||
assert.NotPanics(t, register)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachStabilities(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/development", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Development", SuccessStatusCode: http.StatusOK, Stability: StabilityDevelopment})).Methods(http.MethodGet)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package handler
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/binding"
|
||||
|
||||
type Option func(*handler)
|
||||
|
||||
func WithResourceDefs(defs ...ResourceDef) Option {
|
||||
@@ -7,3 +9,9 @@ func WithResourceDefs(defs ...ResourceDef) Option {
|
||||
h.resourceDefs = append(h.resourceDefs, defs...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithBindBodyOptions(opts ...binding.BindBodyOption) Option {
|
||||
return func(h *handler) {
|
||||
h.bindBodyOptions = append(h.bindBodyOptions, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type ResourceDef interface {
|
||||
// resolveRequest is unexported to seal the interface. It returns a slice so a
|
||||
// single def can fan out (e.g. a telemetry query touching multiple signals).
|
||||
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
|
||||
requiresBody() bool
|
||||
}
|
||||
|
||||
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
@@ -20,6 +21,17 @@ func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretyp
|
||||
return resolved
|
||||
}
|
||||
|
||||
// RequiresBody reports whether any def needs the decoded request body.
|
||||
func RequiresBody(defs []ResourceDef) bool {
|
||||
for _, def := range defs {
|
||||
if def.requiresBody() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// BasicResourceDef checks a single resource for one verb.
|
||||
type BasicResourceDef struct {
|
||||
Resource coretypes.Resource
|
||||
@@ -42,6 +54,10 @@ func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []core
|
||||
}
|
||||
}
|
||||
|
||||
func (def BasicResourceDef) requiresBody() bool {
|
||||
return def.ID.RequiresBody
|
||||
}
|
||||
|
||||
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
|
||||
// both source and target are authz-checked.
|
||||
type AttachDetachSiblingResourceDef struct {
|
||||
@@ -72,6 +88,10 @@ func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorC
|
||||
}
|
||||
}
|
||||
|
||||
func (def AttachDetachSiblingResourceDef) requiresBody() bool {
|
||||
return def.SourceIDs.RequiresBody || def.TargetIDs.RequiresBody
|
||||
}
|
||||
|
||||
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
|
||||
// rides along for audit context.
|
||||
type AttachDetachParentChildResourceDef struct {
|
||||
@@ -101,11 +121,20 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
|
||||
}
|
||||
}
|
||||
|
||||
func (def AttachDetachParentChildResourceDef) requiresBody() bool {
|
||||
return def.ParentID.RequiresBody || def.ChildIDs.RequiresBody
|
||||
}
|
||||
|
||||
type TelemetryResourceDef struct {
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
RequiresBody bool
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) requiresBody() bool {
|
||||
return def.RequiresBody
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -23,8 +25,8 @@ func NewResource(logger *slog.Logger) *Resource {
|
||||
|
||||
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
defs := resourceDefsFromRequest(req)
|
||||
if len(defs) == 0 {
|
||||
provider := handlerFromRequest(req)
|
||||
if provider == nil || len(provider.ResourceDefs()) == 0 {
|
||||
next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
@@ -36,18 +38,40 @@ func (middleware *Resource) Wrap(next http.Handler) http.Handler {
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
extractorCtx := coretypes.ExtractorContext{
|
||||
Request: req,
|
||||
RequestBody: body,
|
||||
}
|
||||
resolved := handler.ResolveRequest(defs, extractorCtx)
|
||||
defs := provider.ResourceDefs()
|
||||
|
||||
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
|
||||
var decoded any
|
||||
var decodeErr error
|
||||
if handler.RequiresBody(defs) {
|
||||
decoded, decodeErr = decodeBody(provider.Request(), body, provider.BindBodyOptions()...)
|
||||
}
|
||||
|
||||
extractorCtx := coretypes.ExtractorContext{Request: req, RequestBody: decoded}
|
||||
|
||||
var resolved []coretypes.ResolvedResource
|
||||
if decodeErr != nil {
|
||||
// authz renders the error inside the audit middleware, so the request is still logged
|
||||
resolved = []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(coretypes.Verb{}, coretypes.ActionCategory{}, decodeErr)}
|
||||
} else {
|
||||
resolved = handler.ResolveRequest(defs, extractorCtx)
|
||||
}
|
||||
|
||||
ctx := coretypes.NewContextWithExtractorContext(req.Context(), extractorCtx)
|
||||
ctx = coretypes.NewContextWithResolvedResources(ctx, resolved)
|
||||
next.ServeHTTP(rw, req.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
|
||||
func decodeBody(prototype any, body []byte, opts ...binding.BindBodyOption) (any, error) {
|
||||
decoded := reflect.New(reflect.TypeOf(prototype).Elem()).Interface()
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(body), decoded, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func handlerFromRequest(req *http.Request) handler.Handler {
|
||||
route := mux.CurrentRoute(req)
|
||||
if route == nil {
|
||||
return nil
|
||||
@@ -63,5 +87,5 @@ func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.ResourceDefs()
|
||||
return provider
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user