From 99901ccbd3e11f5b165dd4e72bb02abc75e14926 Mon Sep 17 00:00:00 2001 From: memodi Date: Wed, 1 Jul 2026 12:13:37 -0400 Subject: [PATCH 1/3] feature views --- .mk/standalone.mk | 4 +- AGENTS.md | 87 +++++++--- Makefile | 4 +- config/sample-config.yaml | 39 +++-- web/locales/en/plugin__netobserv-plugin.json | 10 +- web/src/components/__tests-data__/panels.ts | 9 +- .../__tests__/netflow-traffic.spec.tsx | 51 +----- .../components/dropdowns/view-selector.tsx | 62 +++++++ web/src/components/netflow-traffic.tsx | 47 +++++- web/src/model/__tests__/views.spec.ts | 106 ++++++++++++ web/src/model/netflow-context.ts | 3 +- web/src/model/views.ts | 155 ++++++++++++++++++ web/src/utils/config-validation-hook.ts | 18 +- web/src/utils/local-storage-hook.ts | 21 +++ web/src/utils/netflow-capabilities-hook.ts | 45 ++++- web/src/utils/overview-panels.ts | 16 +- web/src/utils/router.ts | 8 + web/src/utils/url-sync-hook.ts | 11 +- web/src/utils/url.ts | 3 +- 19 files changed, 582 insertions(+), 117 deletions(-) create mode 100644 web/src/components/dropdowns/view-selector.tsx create mode 100644 web/src/model/__tests__/views.spec.ts create mode 100644 web/src/model/views.ts diff --git a/.mk/standalone.mk b/.mk/standalone.mk index 45b68d410..ea16f5bcb 100644 --- a/.mk/standalone.mk +++ b/.mk/standalone.mk @@ -7,14 +7,14 @@ start-frontend-standalone: install-frontend ## Run frontend as standalone start-standalone: YQ build-backend install-frontend ## Run backend and frontend as standalone $(YQ) '.server.port |= 9002 | .server.metricsPort |= 9003 | .consoleMode |= "Standalone"' ./config/sample-config.yaml > ./config/config.yaml @echo "### Starting backend on http://localhost:9002" - bash -c "trap 'fuser -k 9002/tcp' EXIT; \ + bash -c "trap 'lsof -ti tcp:9002 | xargs kill -9 2>/dev/null || true' EXIT; \ ./plugin-backend $(CMDLINE_ARGS) & cd web && FLAVOR=${FLAVOR} npm run start:standalone" .PHONY: start-standalone-mock start-standalone-mock: YQ build-backend install-frontend ## Run backend using mocks and frontend as standalone $(YQ) '.server.port |= 9002 | .server.metricsPort |= 9003 | .consoleMode |= "Mock"' ./config/sample-config.yaml > ./config/config.yaml @echo "### Starting backend on http://localhost:9002 using mock" - bash -c "trap 'fuser -k 9002/tcp' EXIT; \ + bash -c "trap 'lsof -ti tcp:9002 | xargs kill -9 2>/dev/null || true' EXIT; \ ./plugin-backend $(CMDLINE_ARGS) & cd web && FLAVOR=${FLAVOR} npm run start:standalone" .PHONY: just-build-frontend diff --git a/AGENTS.md b/AGENTS.md index f2443cc38..d3dc89221 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,15 @@ Best practices for AI coding agents on NetObserv Web Console. ## Project Context -**NetObserv Web Console** - OpenShift Console dynamic plugin for network observability visualization (also deployable as standalone app). +**NetObserv Web Console** - OpenShift Console dynamic plugin for network +observability visualization (also deployable as standalone app). **Stack:** - **Frontend**: TypeScript, React 18, PatternFly 6, React Router 7, i18next 25 -- **Plugin SDK**: `@openshift-console/dynamic-plugin-sdk` 4.22+ (plugin mode only) -- **Backend**: Go HTTP server for Loki queries, Kubernetes resources, Prometheus metrics +- **Plugin SDK**: `@openshift-console/dynamic-plugin-sdk` 4.22+ (plugin mode + only) +- **Backend**: Go HTTP server for Loki queries, Kubernetes resources, Prometheus + metrics **Deployment Modes:** - **Plugin**: Integrated into OpenShift Console (OCP 4.22+) @@ -25,7 +28,9 @@ Best practices for AI coding agents on NetObserv Web Console. | 4.15-4.18 | `main-pf5` | PF5 | | ≤ 4.14 | `main-pf4` | PF4 | -See [OpenShift Console PatternFly documentation](https://github.com/openshift/console/tree/main/frontend/packages/console-dynamic-plugin-sdk#patternfly) for plugin compatibility details. +See +[OpenShift Console PatternFly documentation](https://github.com/openshift/console/tree/main/frontend/packages/console-dynamic-plugin-sdk#patternfly) +for plugin compatibility details. **Key Directories:** - `web/src/components/`: React components (forms, tables, topology, etc.) @@ -44,7 +49,8 @@ See [OpenShift Console PatternFly documentation](https://github.com/openshift/co ### 🚨 OpenShift Console Plugin SDK - Plugin mode must use `@openshift-console/dynamic-plugin-sdk` APIs -- Plugin mode must follow OpenShift Console conventions for navigation, extensions, theming +- Plugin mode must follow OpenShift Console conventions for navigation, + extensions, theming - Standalone mode uses the same codebase but without Console integration - Test both plugin and standalone modes @@ -69,7 +75,9 @@ See [OpenShift Console PatternFly documentation](https://github.com/openshift/co Be specific about file paths, existing patterns, and testing requirements. -**Good**: "Add dnslatency to ColumnsId enum in web/src/utils/columns.ts. Define in config/sample-config.yaml. Update Loki query in pkg/loki/flow_query.go. Test both modes." +**Good**: "Add dnslatency to ColumnsId enum in web/src/utils/columns.ts. Define +in config/sample-config.yaml. Update Loki query in pkg/loki/flow_query.go. Test +both modes." **Bad**: "Add DNS latency column" @@ -78,7 +86,8 @@ Be specific about file paths, existing patterns, and testing requirements. 2. Reference existing patterns (columns, filters, Loki queries) 3. i18n for UI strings, dual-mode testing 4. Check package.json before adding dependencies -5. Column workflow: columns.ts enum → sample-config.yaml → optional RecordField rendering +5. Column workflow: columns.ts enum → sample-config.yaml → optional RecordField + rendering ## Common Task Templates @@ -111,13 +120,18 @@ FlowCollector CRD field changed in operator: ## Repository-Specific Context ### Frontend Architecture -- **Custom Hooks**: Logic extracted into focused hooks in `web/src/utils/*-hook.ts` (capabilities, URL sync, fetching, theme, storage, etc.) -- **Context**: `NetflowContext` in `web/src/model/netflow-context.ts` shares config/capabilities across components +- **Custom Hooks**: Logic extracted into focused hooks in + `web/src/utils/*-hook.ts` (capabilities, URL sync, fetching, theme, storage, + etc.) +- **Context**: `NetflowContext` in `web/src/model/netflow-context.ts` shares + config/capabilities across components - **React Router**: v7, centralized in `web/src/utils/url.ts` ### Plugin vs Standalone Modes -- **Plugin mode**: Console integration (localhost:9001), requires Console clone for dev -- **Standalone mode**: Independent app (`make start-standalone` or `make start-standalone-mock`), build with `STANDALONE=true make images` +- **Plugin mode**: Console integration (localhost:9001), requires Console clone + for dev +- **Standalone mode**: Independent app (`make start-standalone` or + `make start-standalone-mock`), build with `STANDALONE=true make images` - FLAVOR=`enduser` limits production standalone to Network Traffic/Health tabs ### Loki Query Optimization @@ -126,9 +140,13 @@ FlowCollector CRD field changed in operator: - Mock mode: `make start-standalone-mock` or `make serve-mock` ### Frontend Configuration -- **Operator-Generated (Production)**: ConfigMap from FlowCollector CR, fetched via `/api/frontend-config` - - Source: [static-frontend-config.yaml](https://github.com/netobserv/netobserv-operator/blob/main/internal/controller/consoleplugin/config/static-frontend-config.yaml) (operator repo) + FlowCollector spec - - **Critical**: Changes to `config/sample-config.yaml` frontend section MUST be synced to operator's `static-frontend-config.yaml` +- **Operator-Generated (Production)**: ConfigMap from FlowCollector CR, fetched + via `/api/frontend-config` + - Source: + [static-frontend-config.yaml](https://github.com/netobserv/netobserv-operator/blob/main/internal/controller/consoleplugin/config/static-frontend-config.yaml) + (operator repo) + FlowCollector spec + - **Critical**: Changes to `config/sample-config.yaml` frontend section MUST + be synced to operator's `static-frontend-config.yaml` - **Development**: `config/sample-config.yaml` for local testing only ### PatternFly Components @@ -158,11 +176,36 @@ Review for: ## Testing -- **Unit**: Jest 30 + React Testing Library 16 (`web/src/**/__tests__/`), Go tests (`pkg/*_test.go`) +- **Unit**: Jest 30 + React Testing Library 16 (`web/src/**/__tests__/`), Go + tests (`pkg/*_test.go`) - **E2E**: `web/cypress/e2e/` - runs on mock data -- **Integration**: `web/cypress/integration-tests/` - requires OpenShift cluster (main branch: OCP 4.19+) +- **Integration**: `web/cypress/integration-tests/` - requires OpenShift cluster + (main branch: OCP 4.19+) - **Run Cypress**: `make cypress` or `cd web && npm run cypress:open` +## Views Feature + +Pre-configured view presets that auto-select relevant panels, columns, and +topology metrics for a specific feature in one click. + +**Adding a new view preset:** +1. Add new `ViewPresetId` to the union type in `views.ts` +2. Add `ViewPreset` entry to `viewPresets[]` array with `requiredFeature`, + `panels`, `columns`, `topologyMetricType` +3. Add i18n extraction hint comment in `view-selector.tsx` +4. Run `make i18n` +5. Add unit tests in `web/src/model/__tests__/views.spec.ts` + +**Column IDs in presets:** +- Use `ColumnsId` enum values for standard columns +- Use raw string IDs (e.g. `'XlatSrcAddr'`) for columns not in the enum (dynamic + config-only columns like Xlat* for packetTranslation) +- `ViewPreset.columns` is typed as `string[]` for this reason + +**Local dev with features enabled:** +- Enable features in `config/sample-config.yaml` under `frontend.features` +- Restart `make start-standalone-mock` to regenerate `config/config.yaml` + ## Quick Reference **Essential Commands:** @@ -180,12 +223,17 @@ make image-build image-push # Build and push image **Key Files:** - Frontend config: [web/src/model/config.ts](web/src/model/config.ts) - API routes: [web/src/api/routes.ts](web/src/api/routes.ts) -- Loki queries: [pkg/loki/flow_query.go](pkg/loki/flow_query.go), [pkg/loki/topology_query.go](pkg/loki/topology_query.go) +- Loki queries: [pkg/loki/flow_query.go](pkg/loki/flow_query.go), + [pkg/loki/topology_query.go](pkg/loki/topology_query.go) - Backend routes: [pkg/server/routes.go](pkg/server/routes.go) - Backend handlers: [pkg/handler/handlers.go](pkg/handler/handlers.go) - Table columns: [web/src/utils/columns.ts](web/src/utils/columns.ts) -- UI schema: [web/src/components/forms/config/uiSchema.ts](web/src/components/forms/config/uiSchema.ts) +- UI schema: + [web/src/components/forms/config/uiSchema.ts](web/src/components/forms/config/uiSchema.ts) - Sample config: [config/sample-config.yaml](config/sample-config.yaml) +- Views presets: [web/src/model/views.ts](web/src/model/views.ts) +- Views selector: + [web/src/components/dropdowns/view-selector.tsx](web/src/components/dropdowns/view-selector.tsx) ## AI Workflow Example @@ -215,5 +263,6 @@ Before commit: - [README.md](README.md) - Setup, build, test, deploy, run locally - [CONTRIBUTING.md](CONTRIBUTING.md) - Contribution guidelines -**Remember**: AI agents need clear context. Always review generated code, test thoroughly in both plugin and standalone modes, and follow project conventions. +**Remember**: AI agents need clear context. Always review generated code, test +thoroughly in both plugin and standalone modes, and follow project conventions. diff --git a/Makefile b/Makefile index 223b30c6d..cf001b8f4 100644 --- a/Makefile +++ b/Makefile @@ -123,13 +123,13 @@ endif start: YQ build-backend install-frontend ## Run backend and frontend $(YQ) '.server.port |= 9002 | .server.metricsPort |= 9003 | .consoleMode |= "Standalone"' ./config/sample-config.yaml > ./config/config.yaml @echo "### Starting backend on http://localhost:9002" - bash -c "trap 'fuser -k 9002/tcp' EXIT; \ + bash -c "trap 'lsof -ti tcp:9002 | xargs kill -9 2>/dev/null || true' EXIT; \ ./plugin-backend $(CMDLINE_ARGS) & cd web && npm run start" .PHONY: start-backend start-backend: YQ build-backend $(YQ) '.server.port |= 9002 | .server.metricsPort |= 9003 | .consoleMode |= "Standalone"' ./config/sample-config.yaml > ./config/config.yaml - bash -c "trap 'fuser -k 9002/tcp' EXIT; \ + bash -c "trap 'lsof -ti tcp:9002 | xargs kill -9 2>/dev/null || true' EXIT; \ ./plugin-backend $(CMDLINE_ARGS)" .PHONY: bridge diff --git a/config/sample-config.yaml b/config/sample-config.yaml index 6040b182e..a5776b659 100644 --- a/config/sample-config.yaml +++ b/config/sample-config.yaml @@ -48,15 +48,14 @@ frontend: # - heartbeat # - endConnection features: - # eBPF agent features - # - pktDrop - # - dnsTracking - # - flowRTT - # - multiNetworks - # - packetTranslation - # processor features - # - multiCluster - # - zones + - pktDrop + - dnsTracking + - flowRTT + - packetTranslation + - networkEvents + - tlsTracking + - udnMapping + # processor features: multiCluster, zones portNaming: enable: true portNames: @@ -425,7 +424,7 @@ frontend: tooltip: Network name, such as Secondary network or UDN. field: SrcK8S_NetworkName filter: src_network - default: true + default: false width: 15 feature: multiNetworks - id: DstK8S_Name @@ -566,7 +565,7 @@ frontend: tooltip: Network name, such as Secondary network or UDN. field: DstK8S_NetworkName filter: dst_network - default: true + default: false width: 15 feature: multiNetworks - id: K8S_Name @@ -687,7 +686,7 @@ frontend: tooltip: TLS version found in handshake headers field: TLSVersion filter: tls_version - default: true + default: false width: 10 feature: tlsTracking - id: TLSCipherSuite @@ -861,7 +860,7 @@ frontend: tooltip: Time elapsed between DNS request and response. field: DnsLatencyMs filter: dns_latency - default: true + default: false width: 5 feature: dnsTracking - id: DNSResponseCode @@ -870,7 +869,7 @@ frontend: tooltip: DNS RCODE name from response header. field: DnsFlagsResponseCode filter: dns_flag_response_code - default: true + default: false width: 5 feature: dnsTracking - id: DNSErrNo @@ -887,7 +886,7 @@ frontend: tooltip: TCP Smoothed Round Trip Time (SRTT) field: TimeFlowRttNs filter: time_flow_rtt - default: true + default: false width: 5 feature: flowRTT - id: NetworkEvents @@ -895,7 +894,7 @@ frontend: tooltip: Network events flow monitor field: NetworkEvents filter: network_events - default: true + default: false width: 15 feature: networkEvents - id: XlatZoneId @@ -903,7 +902,7 @@ frontend: name: Xlat zone id field: ZoneId filter: xlat_zone_id - default: true + default: false width: 5 feature: packetTranslation - id: XlatSrcAddr @@ -928,7 +927,7 @@ frontend: group: Xlat name: Xlat Src Kubernetes Object calculated: kubeObject(XlatSrcK8S_Type,XlatSrcK8S_Namespace,XlatSrcK8S_Name,1) or concat(XlatSrcAddr,':',XlatSrcPort) - default: true + default: false width: 15 feature: packetTranslation - id: XlatDstAddr @@ -953,7 +952,7 @@ frontend: group: Xlat name: Xlat Dst Kubernetes Object calculated: kubeObject(XlatDstK8S_Type,XlatDstK8S_Namespace,XlatDstK8S_Name,1) or concat(XlatDstAddr,':',XlatDstPort) - default: true + default: false width: 15 feature: packetTranslation - id: XlatK8S_Object @@ -968,7 +967,7 @@ frontend: tooltip: Status of the IPsec encryption (on egress, provided by the kernel function xfrm_output) or decryption (on ingress, via xfrm_input). field: IPSecStatus filter: ipsec_status - default: true + default: false width: 10 feature: ipsec filters: diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index dfdad64a9..874912293 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -175,6 +175,15 @@ "S": "S", "XS": "XS", "None": "None", + "All Traffic": "All Traffic", + "Packet Drops": "Packet Drops", + "DNS Latency": "DNS Latency", + "Flow RTT": "Flow RTT", + "TLS Tracking": "TLS Tracking", + "UDN Mapping": "UDN Mapping", + "Network Events": "Network Events", + "Packet Translation": "Packet Translation", + "View": "View", "Cluster metrics": "Cluster metrics", "Bandwidth": "Bandwidth", "Nodes": "Nodes", @@ -515,7 +524,6 @@ "{{n}} Port(s)": "{{n}} Port(s)", "{{n}} Protocol(s)": "{{n}} Protocol(s)", "DNS latency": "DNS latency", - "Flow RTT": "Flow RTT", "Duration": "Duration", "Collection latency": "Collection latency", "Flow per request limit reached, following metrics can be inaccurate. Narrow down your search or increase limit.": "Flow per request limit reached, following metrics can be inaccurate. Narrow down your search or increase limit.", diff --git a/web/src/components/__tests-data__/panels.ts b/web/src/components/__tests-data__/panels.ts index 1ceb917c5..9ef82922d 100644 --- a/web/src/components/__tests-data__/panels.ts +++ b/web/src/components/__tests-data__/panels.ts @@ -4,4 +4,11 @@ import { getDefaultOverviewPanels, OverviewPanel } from '../../utils/overview-pa export const CustomPanelsSample = ['Flows', 'DnsFlows']; export const SamplePanel = { id: 'top_avg_byte_rates', isSelected: true } as OverviewPanel; export const DefaultPanels = getDefaultOverviewPanels().filter(p => p.isSelected); -export const ShuffledDefaultPanels: OverviewPanel[] = _.shuffle(DefaultPanels); +// Use a fixed set for modal tests to avoid shuffle-order flakiness +export const ShuffledDefaultPanels: OverviewPanel[] = [ + { id: 'top_avg_byte_rates', isSelected: true }, + { id: 'byte_rates', isSelected: true }, + { id: 'top_sankey', isSelected: true }, + { id: 'overview', isSelected: true }, + { id: 'inbound_region', isSelected: true } +]; diff --git a/web/src/components/__tests__/netflow-traffic.spec.tsx b/web/src/components/__tests__/netflow-traffic.spec.tsx index 1a8676290..b18edd5cf 100644 --- a/web/src/components/__tests__/netflow-traffic.spec.tsx +++ b/web/src/components/__tests__/netflow-traffic.spec.tsx @@ -4,7 +4,6 @@ import * as React from 'react'; import { AlertsResult, SilencedAlert } from '../../api/alert'; import { FlowMetricsResult, GenericMetricsResult } from '../../api/query-response'; import { getConfig, getFlowGenericMetrics, getFlowMetrics, getFlowRecords, getRole } from '../../api/routes'; -import { FlowQuery } from '../../model/flow-query'; import { FullConfigResultSample, SimpleConfigResultSample } from '../__tests-data__/config'; import { extensionsMock } from '../__tests-data__/extensions'; import { FlowsResultSample } from '../__tests-data__/flows'; @@ -39,18 +38,6 @@ const getFlowsMock = getFlowRecords as jest.Mock; const getMetricsMock = getFlowMetrics as jest.Mock; const getGenericMetricsMock = getFlowGenericMetrics as jest.Mock; -const defaultQuery = { - aggregateBy: 'namespace', - filters: '', - groups: undefined, - limit: 5, - packetLoss: 'all', - rateInterval: '30s', - recordType: 'flowLog', - dataSource: 'auto', - step: '15s', - timeRange: 300 -} as FlowQuery; describe('', () => { beforeAll(() => { @@ -80,41 +67,13 @@ describe('', () => { it('should load default metrics on button click', async () => { const { container } = render(); - const expectedMetricsQueries: FlowQuery[] = [ - { ...defaultQuery, function: 'rate', type: 'Bytes' }, - { ...defaultQuery, function: 'rate', type: 'Packets' }, - { ...defaultQuery, function: 'rate', aggregateBy: 'app', type: 'Bytes' }, - { ...defaultQuery, function: 'rate', aggregateBy: 'app', type: 'Packets' }, - { ...defaultQuery, function: 'rate', type: 'PktDropPackets' }, - { ...defaultQuery, function: 'rate', aggregateBy: 'app', type: 'PktDropPackets' }, - { ...defaultQuery, function: 'avg', type: 'DnsLatencyMs' }, - { ...defaultQuery, function: 'p90', type: 'DnsLatencyMs' }, - { ...defaultQuery, function: 'avg', aggregateBy: 'app', type: 'DnsLatencyMs' }, - { ...defaultQuery, function: 'p90', aggregateBy: 'app', type: 'DnsLatencyMs' }, - { ...defaultQuery, function: 'avg', type: 'TimeFlowRttNs' }, - { ...defaultQuery, function: 'min', type: 'TimeFlowRttNs' }, - { ...defaultQuery, function: 'p90', type: 'TimeFlowRttNs' }, - { ...defaultQuery, function: 'avg', aggregateBy: 'app', type: 'TimeFlowRttNs' }, - { ...defaultQuery, function: 'min', aggregateBy: 'app', type: 'TimeFlowRttNs' }, - { ...defaultQuery, function: 'p90', aggregateBy: 'app', type: 'TimeFlowRttNs' } - ]; - const expectedGenericMetricsQueries: FlowQuery[] = [ - { ...defaultQuery, function: 'rate', type: 'PktDropPackets', aggregateBy: 'PktDropLatestState' }, - { ...defaultQuery, function: 'rate', type: 'PktDropPackets', aggregateBy: 'PktDropLatestDropCause' }, - { ...defaultQuery, function: 'count', type: 'DnsFlows', aggregateBy: 'DnsName' }, - { ...defaultQuery, function: 'count', type: 'DnsFlows', aggregateBy: 'DnsFlagsResponseCode' }, - { ...defaultQuery, function: 'count', type: 'DnsFlows', aggregateBy: 'app' } - ]; - await waitFor(() => { expect(getConfigMock).toHaveBeenCalledTimes(1); expect(getRoleMock).toHaveBeenCalledTimes(1); expect(getFlowsMock).toHaveBeenCalledTimes(0); - expect(getMetricsMock).toHaveBeenCalledTimes(expectedMetricsQueries.length); - expectedMetricsQueries.forEach((q, i) => - expect(getMetricsMock).toHaveBeenNthCalledWith(i + 1, q, defaultQuery.timeRange) - ); - expect(getGenericMetricsMock).toHaveBeenCalledTimes(expectedGenericMetricsQueries.length); + // "All Traffic" shows only base panels — only Bytes/Packets metrics fetched + expect(getMetricsMock).toHaveBeenCalledTimes(2); + expect(getGenericMetricsMock).toHaveBeenCalledTimes(0); }); await act(async () => { @@ -125,8 +84,8 @@ describe('', () => { expect(getConfigMock).toHaveBeenCalledTimes(1); expect(getRoleMock).toHaveBeenCalledTimes(1); expect(getFlowsMock).toHaveBeenCalledTimes(0); - expect(getMetricsMock).toHaveBeenCalledTimes(expectedMetricsQueries.length * 2); - expect(getGenericMetricsMock).toHaveBeenCalledTimes(expectedGenericMetricsQueries.length * 2); + expect(getMetricsMock).toHaveBeenCalledTimes(4); + expect(getGenericMetricsMock).toHaveBeenCalledTimes(0); }); }); diff --git a/web/src/components/dropdowns/view-selector.tsx b/web/src/components/dropdowns/view-selector.tsx new file mode 100644 index 000000000..6e7327415 --- /dev/null +++ b/web/src/components/dropdowns/view-selector.tsx @@ -0,0 +1,62 @@ +import { Badge, MenuToggle, MenuToggleElement, Select, SelectOption } from '@patternfly/react-core'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ViewPreset, ViewPresetId } from '../../model/views'; +import { useOutsideClickEvent } from '../../utils/outside-hook'; + +// i18n extraction hints for dynamic view labels +// t('All Traffic') t('Packet Drops') t('DNS Latency') t('Flow RTT') t('TLS Tracking') t('UDN Mapping') t('Network Events') t('Packet Translation') + +export interface ViewSelectorProps { + activeView: ViewPresetId; + setActiveView: (view: ViewPresetId) => void; + availableViews: ViewPreset[]; +} + +export const ViewSelector: React.FC = ({ activeView, setActiveView, availableViews }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const ref = useOutsideClickEvent(() => setOpen(false)); + const [isOpen, setOpen] = React.useState(false); + + const onSelect = (_: unknown, value: string | number | undefined) => { + if (value && value !== activeView) { + setActiveView(value as ViewPresetId); + } + setOpen(false); + }; + + const activeLabel = availableViews.find(v => v.id === activeView)?.label ?? t('All Traffic'); + const isNonDefault = activeView !== 'all'; + + return ( +
+ +
+ ); +}; diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index e0e2e7aad..4076d5446 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -19,6 +19,7 @@ import { } from '../model/flow-query'; import { FetchCallbacks, NetflowContext, NetflowContextValue } from '../model/netflow-context'; import { getGroupsForScope } from '../model/scope'; +import { getViewPreset, ViewPresetId } from '../model/views'; import { DefaultOptions, GraphElementPeer, TopologyOptions } from '../model/topology'; import { Column, ColumnSizeMap } from '../utils/columns'; import { useConfigValidation } from '../utils/config-validation-hook'; @@ -28,6 +29,7 @@ import { useFullScreen } from '../utils/fullscreen-hook'; import { useK8sModelsWithColors } from '../utils/k8s-models-hook'; import { defaultArraySelectionOptions, + localStorageActiveViewKey, localStorageColsKey, localStorageColsSizesKey, localStorageDisabledFiltersKey, @@ -73,6 +75,7 @@ import { rateMetricFunctions, timeMetricFunctions } from './dropdowns/metric-fun import { limitValues, topValues } from './dropdowns/query-options-panel'; import { RefreshDropdown } from './dropdowns/refresh-dropdown'; import TimeRangeDropdown from './dropdowns/time-range-dropdown'; +import { ViewSelector } from './dropdowns/view-selector'; import { TruncateLength } from './dropdowns/truncate-dropdown'; import GuidedTourPopover, { GuidedTourHandle } from './guided-tour/guided-tour'; import Modals from './modals/modals'; @@ -149,6 +152,7 @@ export const NetflowTraffic: React.FC = ({ ); const [columns, setColumns] = useLocalStorage(localStorageColsKey, [], defaultArraySelectionOptions); const [_columnSizes, setColumnSizes] = useLocalStorage(localStorageColsSizesKey, {}); + const [activeView, setActiveView] = useLocalStorage(localStorageActiveViewKey, 'all'); // Display state const [isViewOptionOverflowMenuOpen, setViewOptionOverflowMenuOpen] = React.useState(false); @@ -188,6 +192,7 @@ export const NetflowTraffic: React.FC = ({ dataSource, columns, panels, + activeView, metricScope, topologyOptions, topologyMetricType, @@ -293,8 +298,27 @@ export const NetflowTraffic: React.FC = ({ }); const resetDefaultFilters = React.useCallback(() => { + setActiveView('all'); updateTableFilters({ match: filters.match, list: caps.defaultFilters }); - }, [filters.match, caps.defaultFilters, updateTableFilters]); + }, [filters.match, caps.defaultFilters, updateTableFilters, setActiveView]); + + const applyView = React.useCallback( + (viewId: ViewPresetId) => { + setActiveView(viewId); + if (viewId === 'all') { + // Don't reset panels/columns — restore user's custom localStorage selection + return; + } + const preset = getViewPreset(viewId); + if (!preset) { + return; + } + if (preset.topologyMetricType) { + updateTopologyMetricType(preset.topologyMetricType); + } + }, + [setActiveView, updateTopologyMetricType] + ); const setFiltersFromURL = React.useCallback(() => { if (forcedFilters === null) { @@ -340,7 +364,9 @@ export const NetflowTraffic: React.FC = ({ setTopologyMetricType, setColumns, setPanels, - setFiltersFromURL + setFiltersFromURL, + activeView, + setActiveView }); // Sync state to URL params @@ -357,6 +383,7 @@ export const NetflowTraffic: React.FC = ({ packetLoss, recordType, dataSource, + activeView, setQueryParams, setTRModalOpen }); @@ -404,6 +431,22 @@ export const NetflowTraffic: React.FC = ({ const actions = () => { return ( + {caps.availableViews.length > 1 && ( + + + + {t('View')} + + + + + + + )} diff --git a/web/src/model/__tests__/views.spec.ts b/web/src/model/__tests__/views.spec.ts new file mode 100644 index 000000000..e38065754 --- /dev/null +++ b/web/src/model/__tests__/views.spec.ts @@ -0,0 +1,106 @@ +import { Feature } from '../config'; +import { getAvailableViews, getViewPreset, viewPresets, ViewPresetId } from '../views'; + +describe('viewPresets', () => { + it('always includes "all" as first preset', () => { + expect(viewPresets[0].id).toBe('all'); + }); + + it('"all" preset has no requiredFeature', () => { + const allPreset = viewPresets.find(v => v.id === 'all'); + expect(allPreset?.requiredFeature).toBeUndefined(); + }); + + it('feature presets have requiredFeature set', () => { + const featurePresets = viewPresets.filter(v => v.id !== 'all'); + featurePresets.forEach(p => { + expect(p.requiredFeature).toBeDefined(); + }); + }); +}); + +describe('getAvailableViews', () => { + it('returns only "all" when no features enabled', () => { + const views = getAvailableViews([]); + expect(views).toHaveLength(1); + expect(views[0].id).toBe('all'); + }); + + it('includes pktdrop view when pktDrop feature enabled', () => { + const views = getAvailableViews(['pktDrop'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('all'); + expect(ids).toContain('pktdrop'); + }); + + it('includes dns view when dnsTracking feature enabled', () => { + const views = getAvailableViews(['dnsTracking'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('dns'); + }); + + it('includes rtt view when flowRTT feature enabled', () => { + const views = getAvailableViews(['flowRTT'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('rtt'); + }); + + it('includes tls view when tlsTracking feature enabled', () => { + const views = getAvailableViews(['tlsTracking'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('tls'); + }); + + it('includes udn view when udnMapping feature enabled', () => { + const views = getAvailableViews(['udnMapping'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('udn'); + }); + + it('includes networkEvents view when networkEvents feature enabled', () => { + const views = getAvailableViews(['networkEvents'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('networkEvents'); + }); + + it('includes packetTranslation view when packetTranslation feature enabled', () => { + const views = getAvailableViews(['packetTranslation'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('packetTranslation'); + }); + + it('includes multiple views when multiple features enabled', () => { + const views = getAvailableViews(['pktDrop', 'dnsTracking', 'flowRTT'] as Feature[]); + const ids = views.map(v => v.id); + expect(ids).toContain('all'); + expect(ids).toContain('pktdrop'); + expect(ids).toContain('dns'); + expect(ids).toContain('rtt'); + expect(ids).not.toContain('tls'); + }); +}); + +describe('getViewPreset', () => { + it('returns preset for known id', () => { + const preset = getViewPreset('dns'); + expect(preset).toBeDefined(); + expect(preset?.id).toBe('dns'); + expect(preset?.topologyMetricType).toBe('DnsLatencyMs'); + }); + + it('returns undefined for unknown id', () => { + const preset = getViewPreset('unknown' as ViewPresetId); + expect(preset).toBeUndefined(); + }); + + it('"all" preset has no panels or columns (uses localStorage defaults)', () => { + const preset = getViewPreset('all'); + expect(preset?.panels).toBeUndefined(); + expect(preset?.columns).toBeUndefined(); + }); + + it('pktdrop preset has topologyMetricType set', () => { + const preset = getViewPreset('pktdrop'); + expect(preset?.topologyMetricType).toBe('PktDropPackets'); + }); +}); diff --git a/web/src/model/netflow-context.ts b/web/src/model/netflow-context.ts index 4074d0397..101cb52d1 100644 --- a/web/src/model/netflow-context.ts +++ b/web/src/model/netflow-context.ts @@ -40,7 +40,8 @@ const defaultCaps: ConfigCapabilities = { quickFilters: [], defaultFilters: [], flowQuery: {} as ConfigCapabilities['flowQuery'], - fetchFunctions: {} as ConfigCapabilities['fetchFunctions'] + fetchFunctions: {} as ConfigCapabilities['fetchFunctions'], + availableViews: [] }; const defaultFetchCallbacks: FetchCallbacks = { diff --git a/web/src/model/views.ts b/web/src/model/views.ts new file mode 100644 index 000000000..9fa1ba7e1 --- /dev/null +++ b/web/src/model/views.ts @@ -0,0 +1,155 @@ +import { Feature } from './config'; +import { ColumnsId } from '../utils/columns'; +import { OverviewPanelId } from '../utils/overview-panels'; +import { MetricType } from './flow-query'; + +export type ViewPresetId = + | 'all' + | 'pktdrop' + | 'dns' + | 'rtt' + | 'tls' + | 'udn' + | 'networkEvents' + | 'packetTranslation'; + +export interface ViewPreset { + id: ViewPresetId; + label: string; // i18n key + requiredFeature?: Feature; + panels?: OverviewPanelId[]; // panels to select in overview; undefined means use localStorage defaults + columns?: string[]; // column IDs to select in table; string to support both ColumnsId enum and dynamic string IDs + topologyMetricType?: MetricType; // default metric for topology +} + +// Common base columns included in every feature view +const baseColumns: ColumnsId[] = [ + ColumnsId.starttime, + ColumnsId.srcnamespace, + ColumnsId.srcname, + ColumnsId.dstnamespace, + ColumnsId.dstname, + ColumnsId.proto, + ColumnsId.srcport, + ColumnsId.dstport +]; + +export const viewPresets: ViewPreset[] = [ + { + id: 'all', + label: 'All Traffic' + // no requiredFeature, no panels/columns override — uses localStorage defaults + }, + { + id: 'pktdrop', + label: 'Packet Drops', + requiredFeature: 'pktDrop', + panels: [ + 'top_avg_dropped_packet_rates', + 'dropped_packet_rates', + 'state_dropped_packet_rates', + 'cause_dropped_packet_rates', + 'top_avg_dropped_byte_rates', + 'dropped_byte_rates' + ], + columns: [ + ...baseColumns, + ColumnsId.bytes, + ColumnsId.packets, + ColumnsId.dropbytes, + ColumnsId.droppackets, + ColumnsId.dropstate, + ColumnsId.dropcause, + ColumnsId.dropflags + ], + topologyMetricType: 'PktDropPackets' + }, + { + id: 'dns', + label: 'DNS Latency', + requiredFeature: 'dnsTracking', + panels: [ + 'top_avg_dns_latency', + 'top_p90_dns_latency', + 'top_p99_dns_latency', + 'top_max_dns_latency', + 'name_dns_latency_flows', + 'rcode_dns_latency_flows' + ], + columns: [ + ...baseColumns, + ColumnsId.dnsid, + ColumnsId.dnslatency, + ColumnsId.dnsresponsecode, + ColumnsId.dnserrno + ], + topologyMetricType: 'DnsLatencyMs' + }, + { + id: 'rtt', + label: 'Flow RTT', + requiredFeature: 'flowRTT', + panels: ['top_avg_rtt', 'top_p90_rtt', 'top_p99_rtt', 'top_max_rtt', 'bottom_min_rtt'], + columns: [...baseColumns, ColumnsId.bytes, ColumnsId.packets, ColumnsId.rttTime], + topologyMetricType: 'TimeFlowRttNs' + }, + { + id: 'tls', + label: 'TLS Tracking', + requiredFeature: 'tlsTracking', + panels: ['tls_usage_global', 'tls_per_version', 'tls_per_group', 'tls_per_cipher_suite'], + columns: [...baseColumns, 'TLSVersion', 'TLSCipherSuite', 'TLSGroup', ColumnsId.tlstypes], + topologyMetricType: 'TlsFlows' + }, + { + id: 'udn', + label: 'UDN Mapping', + requiredFeature: 'udnMapping', + panels: ['top_sankey', 'top_avg_byte_rates', 'byte_rates'], + columns: [...baseColumns, ColumnsId.udns, ColumnsId.bytes, ColumnsId.packets] + }, + { + id: 'networkEvents', + label: 'Network Events', + requiredFeature: 'networkEvents', + panels: [ + 'top_avg_dropped_packet_rates', + 'dropped_packet_rates', + 'state_dropped_packet_rates', + 'cause_dropped_packet_rates' + ], + columns: [...baseColumns, ColumnsId.bytes, ColumnsId.packets, ColumnsId.dropstate, ColumnsId.dropcause] + }, + { + id: 'packetTranslation', + label: 'Packet Translation', + requiredFeature: 'packetTranslation', + panels: ['top_sankey', 'top_avg_byte_rates', 'byte_rates'], + columns: [ + ColumnsId.starttime, + ColumnsId.srcnamespace, + ColumnsId.srcname, + ColumnsId.srcaddr, + ColumnsId.srcport, + ColumnsId.dstnamespace, + ColumnsId.dstname, + ColumnsId.dstaddr, + ColumnsId.dstport, + ColumnsId.proto, + ColumnsId.bytes, + ColumnsId.packets, + 'XlatSrcAddr', + 'XlatSrcPort', + 'XlatSrcK8S_Object', + 'XlatDstAddr', + 'XlatDstPort', + 'XlatDstK8S_Object', + 'XlatZoneId' + ] + } +]; + +export const getViewPreset = (id: ViewPresetId): ViewPreset | undefined => viewPresets.find(v => v.id === id); + +export const getAvailableViews = (enabledFeatures: Feature[]): ViewPreset[] => + viewPresets.filter(v => !v.requiredFeature || enabledFeatures.includes(v.requiredFeature)); diff --git a/web/src/utils/config-validation-hook.ts b/web/src/utils/config-validation-hook.ts index 2f2504deb..dfba6200b 100644 --- a/web/src/utils/config-validation-hook.ts +++ b/web/src/utils/config-validation-hook.ts @@ -12,6 +12,7 @@ import { import { ConfigCapabilities } from './netflow-capabilities-hook'; import { getDefaultOverviewPanels, OverviewPanel } from './overview-panels'; import { defaultMetricScope, defaultMetricType, setURLFilters } from './router'; +import { ViewPresetId } from '../model/views'; type InitState = React.MutableRefObject; @@ -34,6 +35,8 @@ export function useConfigValidation(params: { setColumns: React.Dispatch>; setPanels: React.Dispatch>; setFiltersFromURL: () => void; + activeView: ViewPresetId; + setActiveView: (v: ViewPresetId) => void; }): void { const { initState, @@ -53,7 +56,9 @@ export function useConfigValidation(params: { setTopologyMetricType, setColumns, setPanels, - setFiltersFromURL + setFiltersFromURL, + activeView, + setActiveView } = params; // invalidate match filters if not set to all when filters are empty @@ -113,6 +118,17 @@ export function useConfigValidation(params: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [caps.allowedMetricTypes, topologyMetricType, setTopologyMetricType]); + // invalidate active view if its required feature is no longer enabled + React.useEffect(() => { + if (initState.current.includes('configLoaded') && activeView !== 'all') { + const isViewAvailable = caps.availableViews.some(v => v.id === activeView); + if (!isViewAvailable) { + setActiveView('all'); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [caps.availableViews, activeView]); + // select columns / panels from local storage on config change React.useEffect(() => { if (initState.current.includes('configLoaded')) { diff --git a/web/src/utils/local-storage-hook.ts b/web/src/utils/local-storage-hook.ts index 3639f77e9..2e83bfef9 100644 --- a/web/src/utils/local-storage-hook.ts +++ b/web/src/utils/local-storage-hook.ts @@ -31,6 +31,7 @@ export const localStorageOverviewDonutDimensionKey = 'netflow-traffic-overview-d export const localStorageOverviewMetricsDimensionKey = 'netflow-traffic-overview-metrics-dimension'; export const localStorageOverviewMetricsTotalDimensionKey = 'netflow-traffic-overview-metrics-total-dimension'; export const localStorageOverviewKebabKey = 'netflow-traffic-overview-kebab-map'; +export const localStorageActiveViewKey = 'netflow-traffic-active-view'; export interface ArraySelectionOptions { id: string; @@ -114,6 +115,26 @@ export function getLocalStorage(key: string, initialValue?: T, opts?: ArraySe } } +export function setLocalStorage(key: string, value: T, opts?: ArraySelectionOptions) { + try { + const item = window.localStorage.getItem(localStoragePluginKey); + const parsedItem = item ? JSON.parse(item) : {}; + if (opts) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parsedItem[key] = (value as any[]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((item: any) => item[opts.criteria]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((item: any) => item[opts.id]); + } else { + parsedItem[key] = value; + } + window.localStorage.setItem(localStoragePluginKey, JSON.stringify(parsedItem)); + } catch (error) { + console.error(error); + } +} + export function clearLocalStorage() { try { console.info('clearing local storage ' + localStoragePluginKey); diff --git a/web/src/utils/netflow-capabilities-hook.ts b/web/src/utils/netflow-capabilities-hook.ts index 821e66fd9..ae02554b2 100644 --- a/web/src/utils/netflow-capabilities-hook.ts +++ b/web/src/utils/netflow-capabilities-hook.ts @@ -13,7 +13,8 @@ import { Column, ColumnsId } from './columns'; import { ContextSingleton } from './context'; import { computeStepInterval, TimeRange } from './datetime'; import { checkFilterAvailable, getFilterDefinitions } from './filter-definitions'; -import { dnsIdMatcher, droppedIdMatcher, OverviewPanel, rttIdMatcher, tlsIdMatcher } from './overview-panels'; +import { defaultPanelIds, dnsIdMatcher, droppedIdMatcher, OverviewPanel, rttIdMatcher, tlsIdMatcher } from './overview-panels'; +import { getAvailableViews, getViewPreset, ViewPreset, ViewPresetId } from '../model/views'; export interface ConfigCapabilities { allowLoki: boolean; @@ -36,6 +37,7 @@ export interface ConfigCapabilities { defaultFilters: Filter[]; flowQuery: StructuredFlowQuery; fetchFunctions: ReturnType; + availableViews: ViewPreset[]; } export function useConfigCapabilities(params: { @@ -44,6 +46,7 @@ export function useConfigCapabilities(params: { dataSource: DataSource; columns: Column[]; panels: OverviewPanel[]; + activeView: ViewPresetId; metricScope: FlowScope; topologyOptions: TopologyOptions; topologyMetricType: MetricType; @@ -61,6 +64,7 @@ export function useConfigCapabilities(params: { dataSource, columns, panels, + activeView, metricScope, topologyOptions, topologyMetricType, @@ -121,7 +125,7 @@ export function useConfigCapabilities(params: { const allowedMetricTypes = React.useMemo(() => { let options: MetricType[] = ['Bytes', 'Packets']; - if (selectedViewId === 'topology') { + if (selectedViewId === 'topology' || activeView !== 'all') { if (isPktDrop) { options = options.concat('PktDropBytes', 'PktDropPackets'); } @@ -133,7 +137,7 @@ export function useConfigCapabilities(params: { } } return options; - }, [isDNSTracking, isFlowRTT, isPktDrop, selectedViewId]); + }, [isDNSTracking, isFlowRTT, isPktDrop, selectedViewId, activeView]); const availablePanels = React.useMemo( () => @@ -147,7 +151,17 @@ export function useConfigCapabilities(params: { [isDNSTracking, isFlowRTT, isPktDrop, isTLSTracking, panels] ); - const selectedPanels = React.useMemo(() => availablePanels.filter(panel => panel.isSelected), [availablePanels]); + const selectedPanels = React.useMemo(() => { + const preset = activeView !== 'all' ? getViewPreset(activeView) : undefined; + if (preset?.panels) { + // Feature view: show preset panels + panels user explicitly added (not config defaults) + return availablePanels.filter( + panel => preset.panels!.includes(panel.id) || (panel.isSelected && !defaultPanelIds.includes(panel.id)) + ); + } + // "All Traffic": user's manual selection + return availablePanels.filter(panel => panel.isSelected); + }, [availablePanels, activeView]); const availableColumns = React.useMemo( () => @@ -159,7 +173,23 @@ export function useConfigCapabilities(params: { [columns, config.features, isConnectionTracking] ); - const selectedColumns = React.useMemo(() => availableColumns.filter(column => column.isSelected), [availableColumns]); + // IDs of columns that are selected by default from config (default: true, no feature tag) + const defaultColumnIds = React.useMemo( + () => new Set(config.columns.filter(c => c.default && !c.feature).map(c => c.id)), + [config.columns] + ); + + const selectedColumns = React.useMemo(() => { + const preset = activeView !== 'all' ? getViewPreset(activeView) : undefined; + if (preset?.columns) { + // Feature view: show preset columns + columns user explicitly added (not config defaults) + return availableColumns.filter( + col => preset.columns!.includes(col.id as string) || (col.isSelected && !defaultColumnIds.has(col.id)) + ); + } + // "All Traffic": user's manual selection + return availableColumns.filter(column => column.isSelected); + }, [availableColumns, activeView, defaultColumnIds]); const filterDefs = React.useMemo(() => { const allFilterDefs = getFilterDefinitions(config.filters, config.columns, t); @@ -240,6 +270,8 @@ export function useConfigCapabilities(params: { return getBackAndForthFetch(filterDefs); }, [filterDefs]); + const availableViews = React.useMemo(() => getAvailableViews(config.features), [config.features]); + return { allowLoki, allowProm, @@ -260,6 +292,7 @@ export function useConfigCapabilities(params: { quickFilters, defaultFilters, flowQuery, - fetchFunctions + fetchFunctions, + availableViews }; } diff --git a/web/src/utils/overview-panels.ts b/web/src/utils/overview-panels.ts index 43ea54a5b..eebd2c54f 100644 --- a/web/src/utils/overview-panels.ts +++ b/web/src/utils/overview-panels.ts @@ -65,25 +65,13 @@ export type OverviewPanelInfo = { tooltip?: string; }; +// Base panels only — feature-specific panels (pktDrop, dns, rtt, tls) selected via feature Views export const defaultPanelIds: OverviewPanelId[] = [ 'overview', 'top_sankey', 'inbound_region', 'top_avg_byte_rates', - 'byte_rates', - 'top_avg_dropped_packet_rates', - 'dropped_packet_rates', - 'state_dropped_packet_rates', - 'cause_dropped_packet_rates', - 'top_avg_dns_latency', - 'top_p90_dns_latency', - 'name_dns_latency_flows', - 'rcode_dns_latency_flows', - 'bottom_min_rtt', // should remove from defaults? - 'top_avg_rtt', - 'top_p90_rtt', - 'tls_usage_global', - 'tls_per_version' + 'byte_rates' ]; export const getDefaultOverviewPanels = (customIds?: string[]): OverviewPanel[] => { diff --git a/web/src/utils/router.ts b/web/src/utils/router.ts index 2b9fd0096..0a1bc464f 100644 --- a/web/src/utils/router.ts +++ b/web/src/utils/router.ts @@ -196,3 +196,11 @@ export const setURLMetricType = (metricType?: MetricType, replace?: boolean) => removeURLParam(URLParam.MetricType, replace); } }; + +export const setURLView = (viewId: string, replace?: boolean) => { + if (viewId && viewId !== 'all') { + setURLParam(URLParam.View, viewId, replace); + } else { + removeURLParam(URLParam.View, replace); + } +}; diff --git a/web/src/utils/url-sync-hook.ts b/web/src/utils/url-sync-hook.ts index b577af7f6..e2ce4a774 100644 --- a/web/src/utils/url-sync-hook.ts +++ b/web/src/utils/url-sync-hook.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import { ViewId } from '../components/netflow-traffic'; import { Filters } from '../model/filters'; import { DataSource, MetricType, PacketLoss, RecordType, StatFunction } from '../model/flow-query'; +import { ViewPresetId } from '../model/views'; import { TimeRange } from './datetime'; import { setURLDatasource, @@ -12,7 +13,8 @@ import { setURLPacketLoss, setURLRange, setURLRecortType, - setURLShowDup + setURLShowDup, + setURLView } from './router'; import { getURLParams } from './url'; @@ -31,6 +33,7 @@ export function useURLSync(params: { packetLoss: PacketLoss; recordType: RecordType; dataSource: DataSource; + activeView: ViewPresetId; setQueryParams: React.Dispatch>; setTRModalOpen: React.Dispatch>; }): void { @@ -47,6 +50,7 @@ export function useURLSync(params: { packetLoss, recordType, dataSource, + activeView, setQueryParams, setTRModalOpen } = params; @@ -96,6 +100,10 @@ export function useURLSync(params: { setURLDatasource(dataSource, !initState.current.includes('configLoaded')); }, [dataSource, initState]); + React.useEffect(() => { + setURLView(activeView, !initState.current.includes('configLoaded')); + }, [activeView, initState]); + React.useEffect(() => { if (!forcedFilters) { setQueryParams(getURLParams().toString()); @@ -108,6 +116,7 @@ export function useURLSync(params: { showDuplicates, topologyMetricFunction, topologyMetricType, + activeView, setQueryParams, forcedFilters ]); diff --git a/web/src/utils/url.ts b/web/src/utils/url.ts index ca23b54f5..32da54270 100644 --- a/web/src/utils/url.ts +++ b/web/src/utils/url.ts @@ -66,7 +66,8 @@ export enum URLParam { DataSource = 'dataSource', ShowDuplicates = 'showDup', MetricFunction = 'function', - MetricType = 'type' + MetricType = 'type', + View = 'view' } export type URLParams = { [k in URLParam]?: unknown }; From a747a3fc96670fc0fb11d2fb033ec125de991007 Mon Sep 17 00:00:00 2001 From: memodi Date: Wed, 29 Jul 2026 18:25:46 -0400 Subject: [PATCH 2/3] linter fixes and review comments --- web/src/components/__tests-data__/panels.ts | 1 - .../__tests__/netflow-traffic.spec.tsx | 1 - .../components/dropdowns/view-selector.tsx | 4 ++-- web/src/components/netflow-traffic.tsx | 10 +++------ web/src/model/__tests__/views.spec.ts | 2 +- web/src/model/views.ts | 22 ++++--------------- web/src/utils/config-validation-hook.ts | 2 +- web/src/utils/netflow-capabilities-hook.ts | 11 ++++++++-- 8 files changed, 20 insertions(+), 33 deletions(-) diff --git a/web/src/components/__tests-data__/panels.ts b/web/src/components/__tests-data__/panels.ts index 9ef82922d..6ed29d72e 100644 --- a/web/src/components/__tests-data__/panels.ts +++ b/web/src/components/__tests-data__/panels.ts @@ -1,4 +1,3 @@ -import * as _ from 'lodash'; import { getDefaultOverviewPanels, OverviewPanel } from '../../utils/overview-panels'; export const CustomPanelsSample = ['Flows', 'DnsFlows']; diff --git a/web/src/components/__tests__/netflow-traffic.spec.tsx b/web/src/components/__tests__/netflow-traffic.spec.tsx index b18edd5cf..9b499fd4a 100644 --- a/web/src/components/__tests__/netflow-traffic.spec.tsx +++ b/web/src/components/__tests__/netflow-traffic.spec.tsx @@ -38,7 +38,6 @@ const getFlowsMock = getFlowRecords as jest.Mock; const getMetricsMock = getFlowMetrics as jest.Mock; const getGenericMetricsMock = getFlowGenericMetrics as jest.Mock; - describe('', () => { beforeAll(() => { useResolvedExtensionsMock.mockReturnValue(extensionsMock); diff --git a/web/src/components/dropdowns/view-selector.tsx b/web/src/components/dropdowns/view-selector.tsx index 6e7327415..bab305fb4 100644 --- a/web/src/components/dropdowns/view-selector.tsx +++ b/web/src/components/dropdowns/view-selector.tsx @@ -25,7 +25,7 @@ export const ViewSelector: React.FC = ({ activeView, setActiv setOpen(false); }; - const activeLabel = availableViews.find(v => v.id === activeView)?.label ?? t('All Traffic'); + const activeLabel = availableViews.find(v => v.id === activeView)?.label ?? 'All Traffic'; const isNonDefault = activeView !== 'all'; return ( @@ -37,7 +37,7 @@ export const ViewSelector: React.FC = ({ activeView, setActiv onSelect={onSelect} selected={activeView} toggle={(toggleRef: React.Ref) => ( - setOpen(!isOpen)} isExpanded={isOpen}> + setOpen(!isOpen)} isExpanded={isOpen} data-test="view-selector-dropdown"> <> {t('View')}: {t(activeLabel)} {isNonDefault && {1}} diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index 4076d5446..a54b88cec 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -19,8 +19,8 @@ import { } from '../model/flow-query'; import { FetchCallbacks, NetflowContext, NetflowContextValue } from '../model/netflow-context'; import { getGroupsForScope } from '../model/scope'; -import { getViewPreset, ViewPresetId } from '../model/views'; import { DefaultOptions, GraphElementPeer, TopologyOptions } from '../model/topology'; +import { getViewPreset, ViewPresetId } from '../model/views'; import { Column, ColumnSizeMap } from '../utils/columns'; import { useConfigValidation } from '../utils/config-validation-hook'; import { ContextSingleton } from '../utils/context'; @@ -75,8 +75,8 @@ import { rateMetricFunctions, timeMetricFunctions } from './dropdowns/metric-fun import { limitValues, topValues } from './dropdowns/query-options-panel'; import { RefreshDropdown } from './dropdowns/refresh-dropdown'; import TimeRangeDropdown from './dropdowns/time-range-dropdown'; -import { ViewSelector } from './dropdowns/view-selector'; import { TruncateLength } from './dropdowns/truncate-dropdown'; +import { ViewSelector } from './dropdowns/view-selector'; import GuidedTourPopover, { GuidedTourHandle } from './guided-tour/guided-tour'; import Modals from './modals/modals'; import './netflow-traffic.css'; @@ -438,11 +438,7 @@ export const NetflowTraffic: React.FC = ({ {t('View')} - + diff --git a/web/src/model/__tests__/views.spec.ts b/web/src/model/__tests__/views.spec.ts index e38065754..b2a2d98d2 100644 --- a/web/src/model/__tests__/views.spec.ts +++ b/web/src/model/__tests__/views.spec.ts @@ -1,5 +1,5 @@ import { Feature } from '../config'; -import { getAvailableViews, getViewPreset, viewPresets, ViewPresetId } from '../views'; +import { getAvailableViews, getViewPreset, ViewPresetId, viewPresets } from '../views'; describe('viewPresets', () => { it('always includes "all" as first preset', () => { diff --git a/web/src/model/views.ts b/web/src/model/views.ts index 9fa1ba7e1..7cab57dd9 100644 --- a/web/src/model/views.ts +++ b/web/src/model/views.ts @@ -1,17 +1,9 @@ -import { Feature } from './config'; import { ColumnsId } from '../utils/columns'; import { OverviewPanelId } from '../utils/overview-panels'; +import { Feature } from './config'; import { MetricType } from './flow-query'; -export type ViewPresetId = - | 'all' - | 'pktdrop' - | 'dns' - | 'rtt' - | 'tls' - | 'udn' - | 'networkEvents' - | 'packetTranslation'; +export type ViewPresetId = 'all' | 'pktdrop' | 'dns' | 'rtt' | 'tls' | 'udn' | 'networkEvents' | 'packetTranslation'; export interface ViewPreset { id: ViewPresetId; @@ -76,13 +68,7 @@ export const viewPresets: ViewPreset[] = [ 'name_dns_latency_flows', 'rcode_dns_latency_flows' ], - columns: [ - ...baseColumns, - ColumnsId.dnsid, - ColumnsId.dnslatency, - ColumnsId.dnsresponsecode, - ColumnsId.dnserrno - ], + columns: [...baseColumns, ColumnsId.dnsid, ColumnsId.dnslatency, ColumnsId.dnsresponsecode, ColumnsId.dnserrno], topologyMetricType: 'DnsLatencyMs' }, { @@ -118,7 +104,7 @@ export const viewPresets: ViewPreset[] = [ 'state_dropped_packet_rates', 'cause_dropped_packet_rates' ], - columns: [...baseColumns, ColumnsId.bytes, ColumnsId.packets, ColumnsId.dropstate, ColumnsId.dropcause] + columns: [...baseColumns, ColumnsId.bytes, ColumnsId.packets, ColumnsId.dropstate, ColumnsId.dropcause, 'NetworkEvents'] }, { id: 'packetTranslation', diff --git a/web/src/utils/config-validation-hook.ts b/web/src/utils/config-validation-hook.ts index dfba6200b..cf50a751e 100644 --- a/web/src/utils/config-validation-hook.ts +++ b/web/src/utils/config-validation-hook.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import { Config } from '../model/config'; import { Filters } from '../model/filters'; import { DataSource, FlowScope, MetricType, PacketLoss, RecordType } from '../model/flow-query'; +import { ViewPresetId } from '../model/views'; import { Column, getDefaultColumns } from './columns'; import { defaultArraySelectionOptions, @@ -12,7 +13,6 @@ import { import { ConfigCapabilities } from './netflow-capabilities-hook'; import { getDefaultOverviewPanels, OverviewPanel } from './overview-panels'; import { defaultMetricScope, defaultMetricType, setURLFilters } from './router'; -import { ViewPresetId } from '../model/views'; type InitState = React.MutableRefObject; diff --git a/web/src/utils/netflow-capabilities-hook.ts b/web/src/utils/netflow-capabilities-hook.ts index ae02554b2..aeeec74f3 100644 --- a/web/src/utils/netflow-capabilities-hook.ts +++ b/web/src/utils/netflow-capabilities-hook.ts @@ -8,13 +8,20 @@ import { DataSource, FlowScope, MetricType, PacketLoss, RecordType, StructuredFl import { parseQuickFilters, QuickFilter } from '../model/quick-filters'; import { resolveGroupTypes, ScopeConfigDef } from '../model/scope'; import { TopologyOptions } from '../model/topology'; +import { getAvailableViews, getViewPreset, ViewPreset, ViewPresetId } from '../model/views'; import { getFetchFunctions as getBackAndForthFetch } from './back-and-forth'; import { Column, ColumnsId } from './columns'; import { ContextSingleton } from './context'; import { computeStepInterval, TimeRange } from './datetime'; import { checkFilterAvailable, getFilterDefinitions } from './filter-definitions'; -import { defaultPanelIds, dnsIdMatcher, droppedIdMatcher, OverviewPanel, rttIdMatcher, tlsIdMatcher } from './overview-panels'; -import { getAvailableViews, getViewPreset, ViewPreset, ViewPresetId } from '../model/views'; +import { + defaultPanelIds, + dnsIdMatcher, + droppedIdMatcher, + OverviewPanel, + rttIdMatcher, + tlsIdMatcher +} from './overview-panels'; export interface ConfigCapabilities { allowLoki: boolean; From d85634ea905252f9ca09201df8d28ffb2d69ebce Mon Sep 17 00:00:00 2001 From: memodi Date: Thu, 30 Jul 2026 10:58:43 -0400 Subject: [PATCH 3/3] formall all --- web/src/components/dropdowns/view-selector.tsx | 7 ++++++- web/src/model/views.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/web/src/components/dropdowns/view-selector.tsx b/web/src/components/dropdowns/view-selector.tsx index bab305fb4..761f09b32 100644 --- a/web/src/components/dropdowns/view-selector.tsx +++ b/web/src/components/dropdowns/view-selector.tsx @@ -37,7 +37,12 @@ export const ViewSelector: React.FC = ({ activeView, setActiv onSelect={onSelect} selected={activeView} toggle={(toggleRef: React.Ref) => ( - setOpen(!isOpen)} isExpanded={isOpen} data-test="view-selector-dropdown"> + setOpen(!isOpen)} + isExpanded={isOpen} + data-test="view-selector-dropdown" + > <> {t('View')}: {t(activeLabel)} {isNonDefault && {1}} diff --git a/web/src/model/views.ts b/web/src/model/views.ts index 7cab57dd9..f2e752458 100644 --- a/web/src/model/views.ts +++ b/web/src/model/views.ts @@ -104,7 +104,14 @@ export const viewPresets: ViewPreset[] = [ 'state_dropped_packet_rates', 'cause_dropped_packet_rates' ], - columns: [...baseColumns, ColumnsId.bytes, ColumnsId.packets, ColumnsId.dropstate, ColumnsId.dropcause, 'NetworkEvents'] + columns: [ + ...baseColumns, + ColumnsId.bytes, + ColumnsId.packets, + ColumnsId.dropstate, + ColumnsId.dropcause, + 'NetworkEvents' + ] }, { id: 'packetTranslation',