@skyhook-io/radar-app 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +2 -6
  3. package/src/api/client.ts +138 -6
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/home/HomeView.tsx +15 -3
  7. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  8. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  9. package/src/components/home/TopologyPreview.tsx +57 -11
  10. package/src/components/home/mcpToolCatalog.ts +15 -5
  11. package/src/components/resources/ResourcesView.tsx +15 -3
  12. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  13. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  14. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  16. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  17. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  18. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  19. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  20. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  21. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  22. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  23. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  24. package/src/components/resources/renderers/index.ts +1 -0
  25. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  26. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  27. package/src/components/traffic/TrafficGraph.tsx +150 -58
  28. package/src/components/traffic/TrafficView.tsx +168 -52
  29. package/src/components/traffic/TrafficWizard.tsx +13 -1
  30. package/src/components/traffic/trafficFilters.test.ts +103 -0
  31. package/src/components/traffic/trafficFilters.ts +117 -0
  32. package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
  33. package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
  34. package/src/components/workload/WorkloadView.tsx +55 -7
  35. package/src/utils/navigation.ts +44 -2
  36. package/src/utils/network-policy-navigation.test.ts +68 -0
  37. package/src/utils/topology-selection.test.ts +40 -0
  38. package/src/utils/topology-selection.ts +39 -0
@@ -0,0 +1,184 @@
1
+ import { Workflow } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section, PropertyList, Property, AlertBanner } from '@skyhook-io/k8s-ui'
4
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
5
+ import { HEALTH_BADGE_COLORS } from '@skyhook-io/k8s-ui/utils/badge-colors'
6
+ import { formatAge } from '@skyhook-io/k8s-ui/components/resources/resource-utils'
7
+ import { isForbiddenError } from '../../../api/client'
8
+ import { isKyvernoMutateExistingEnabled } from '@skyhook-io/k8s-ui/components/resources/resource-utils-kyverno-modern'
9
+ import { usePolicyQueued } from '../../../api/policy'
10
+
11
+ /** A request sitting this long is not mid-flight. Kyverno retries on every
12
+ * reconcile, so past this point a backlog grows rather than drains. */
13
+ const STALLED_MINUTES = 5
14
+
15
+ /** Enough at once to be worth raising even while it is still moving. */
16
+ const BACKLOG_SIZE = 25
17
+
18
+ /**
19
+ * The whole banner, or null when a queue is doing what a queue does. Headline
20
+ * and body are decided together — split across two places, the body went on
21
+ * diagnosing a stalled controller under a headline that had stopped claiming one.
22
+ *
23
+ * Only the first case has measured that the work stopped. The second says how
24
+ * much is waiting and nothing more: everything in it is younger than the stall
25
+ * threshold, so it is as likely to be a burst draining normally, and diagnosing
26
+ * a controller from that is a claim the evidence argues against.
27
+ */
28
+ export function queueBanner(
29
+ pending: number,
30
+ stalledMinutes: number,
31
+ oldestAge: string,
32
+ ): { title: string; message: string } | null {
33
+ if (stalledMinutes >= STALLED_MINUTES) {
34
+ return {
35
+ title: `Queued work has not moved for ${oldestAge}`,
36
+ message:
37
+ 'Requests build up when the background controller cannot keep up or cannot reach what it needs. They are retried on every reconcile, so a backlog grows rather than drains.',
38
+ }
39
+ }
40
+ if (pending >= BACKLOG_SIZE) {
41
+ return {
42
+ title: `${pending} requests are queued`,
43
+ message: `Nothing here has been waiting longer than ${STALLED_MINUTES} minutes, so this may be a burst still draining. Worth watching: if the count holds or the oldest keeps ageing, the controller is not keeping up.`,
44
+ }
45
+ }
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * Whether this policy can queue background work at all.
51
+ *
52
+ * Decides whether a denial is worth mentioning. A validate-only policy never
53
+ * queues anything, so "you can't see the queue" there is noise on the majority
54
+ * of policy pages. A policy that generates, or mutates resources that already
55
+ * exist, does queue — and for those a denial hides a backlog that looks exactly
56
+ * like a healthy policy with nothing pending.
57
+ */
58
+ export function policyQueuesWork(data: any): boolean {
59
+ if (data?.kind === 'GeneratingPolicy') return true
60
+ if (isKyvernoMutateExistingEnabled(data)) return true
61
+ return (data?.spec?.rules ?? []).some(
62
+ (r: any) => !!r?.generate || Array.isArray(r?.mutate?.targets),
63
+ )
64
+ }
65
+
66
+ /**
67
+ * Whether a queued request belongs to this policy.
68
+ *
69
+ * Exactly one form matches, never both. A namespaced Policy is recorded as
70
+ * `namespace/name` and a cluster-scoped one bare, and Kyverno permits the two
71
+ * to share a name — so accepting the bare form as a fallback hands a namespaced
72
+ * policy the backlog of a ClusterPolicy it has nothing to do with. The coverage
73
+ * lookup refuses the same fallback for the same reason.
74
+ */
75
+ export function requestBelongsTo(request: any, name: string, namespace: string): boolean {
76
+ const policy = request?.spec?.policy
77
+ if (!policy || !name) return false
78
+ return policy === (namespace ? `${namespace}/${name}` : name)
79
+ }
80
+
81
+ /**
82
+ * What this policy has queued and not finished.
83
+ *
84
+ * A generate or mutate-existing rule does its work through queued requests, and
85
+ * the way that fails in practice is a pile-up rather than one bad request:
86
+ * upstream reports describe thousands stuck in Pending, never cleaned up, taken
87
+ * up again on every reconcile. That is a count, and a count belongs next to the
88
+ * policy — the requests have their own page, but nobody watches it.
89
+ *
90
+ * Silent when the policy has queued nothing, so it never adds an empty section
91
+ * to the majority of policies that only validate.
92
+ */
93
+ export function KyvernoPolicyQueued({ data }: { data: any }) {
94
+ const name = data?.metadata?.name ?? ''
95
+ const namespace = data?.metadata?.namespace ?? ''
96
+
97
+ // Server-side, because the answer's scope is not the subject's: Kyverno keeps
98
+ // these in its own namespace, and the generic resource list would have
99
+ // answered for whatever namespaces the reader happens to be viewing.
100
+ const { data: queued, error } = usePolicyQueued(name, namespace, !!name)
101
+
102
+ const total = queued?.requests ?? 0
103
+ if (total === 0) {
104
+ // An empty list and an unreadable one are not the same answer. A denial is
105
+ // cluster-static, so disclosing it on every policy page would be noise on the
106
+ // majority that only validate and never queue anything — but staying silent
107
+ // about it on a policy that DOES queue hides a backlog behind a page that
108
+ // looks like a healthy one with nothing pending. So it is disclosed exactly
109
+ // where it can cost something.
110
+ if (!error) return null
111
+ if (isForbiddenError(error) && !policyQueuesWork(data)) return null
112
+ return (
113
+ <Section title="Queued Work" icon={Workflow}>
114
+ <LookupFailureNote errors={[error]} what="what this policy has queued" />
115
+ </Section>
116
+ )
117
+ }
118
+
119
+ const byState = queued?.byState ?? {}
120
+ const pending = byState['Pending'] ?? 0
121
+ const failed = byState['Failed'] ?? 0
122
+ const messages = queued?.messages ?? []
123
+ const oldestPending = queued?.oldestPending
124
+
125
+ const stalledMinutes = oldestPending
126
+ ? Math.floor((Date.now() - new Date(oldestPending).getTime()) / 60000)
127
+ : 0
128
+ const banner = queueBanner(pending, stalledMinutes, oldestPending ? formatAge(oldestPending) : '')
129
+
130
+ return (
131
+ <>
132
+ {banner && (
133
+ <AlertBanner variant="warning" title={banner.title} message={banner.message} />
134
+ )}
135
+ <Section title="Queued Work" icon={Workflow} defaultExpanded={pending > 0 || failed > 0}>
136
+ <PropertyList>
137
+ <Property label="Requests" value={String(total)} />
138
+ {Object.entries(byState).map(([state, n]) => (
139
+ <Property
140
+ key={state}
141
+ label={state}
142
+ value={
143
+ <span
144
+ className={clsx(
145
+ 'badge',
146
+ HEALTH_BADGE_COLORS[
147
+ (state === 'Failed'
148
+ ? 'unhealthy'
149
+ : state === 'Pending'
150
+ ? 'degraded'
151
+ : state === 'Completed'
152
+ ? 'healthy'
153
+ : 'unknown') as keyof typeof HEALTH_BADGE_COLORS
154
+ ],
155
+ )}
156
+ >
157
+ {n}
158
+ </span>
159
+ }
160
+ />
161
+ ))}
162
+ {oldestPending && (
163
+ <Property label="Oldest Pending" value={`${formatAge(oldestPending)} ago`} />
164
+ )}
165
+ </PropertyList>
166
+ {messages.length > 0 && (
167
+ <div className="mt-2 pt-2 border-t border-theme-border space-y-1">
168
+ {/* Kyverno writes why it could not complete a request into
169
+ status.message, and it is the only diagnosis this object
170
+ carries. A count without it says something is wrong and leaves
171
+ you to go and find out what. */}
172
+ {messages.map((m, i) => (
173
+ <div key={i} className="text-xs text-warning-text">{m}</div>
174
+ ))}
175
+ </div>
176
+ )}
177
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
178
+ These are deleted seconds after they complete, so this counts what is in flight right now
179
+ rather than everything this policy has ever done.
180
+ </div>
181
+ </Section>
182
+ </>
183
+ )
184
+ }
@@ -1 +1,24 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/RolloutRenderer'
1
+ import { RolloutRenderer as BaseRolloutRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RolloutRenderer'
2
+ import { useRolloutAction, useRolloutCapabilities, type RolloutAction } from '../../../api/client'
3
+
4
+ interface RolloutRendererProps {
5
+ data: any
6
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
7
+ }
8
+
9
+ export function RolloutRenderer({ data, onNavigate }: RolloutRendererProps) {
10
+ const namespace = data?.metadata?.namespace ?? ''
11
+ const name = data?.metadata?.name ?? ''
12
+ const { data: capabilities } = useRolloutCapabilities(namespace, name)
13
+ const action = useRolloutAction()
14
+
15
+ return (
16
+ <BaseRolloutRenderer
17
+ data={data}
18
+ onNavigate={onNavigate}
19
+ capabilities={capabilities}
20
+ onAction={(next: RolloutAction) => action.mutate({ action: next, namespace, name })}
21
+ pendingAction={action.isPending ? action.variables?.action ?? null : null}
22
+ />
23
+ )
24
+ }
@@ -1 +1,44 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBSLRenderer'
1
+ import { VeleroBSLRenderer as BaseVeleroBSLRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBSLRenderer'
2
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { useVeleroStoredBackups } from '../../../api/policy'
5
+
6
+ /**
7
+ * Host wrapper adding the reverse lookup the package renderer cannot do: which
8
+ * Backups this storage location holds.
9
+ *
10
+ * Without it the page states a phase and stops. "Unavailable" is a fact about a
11
+ * bucket; what an operator came to find out is what it costs them, and that is
12
+ * the list of backups they cannot restore from until it recovers — every one of
13
+ * which Velero still reports as Completed.
14
+ */
15
+ export function VeleroBSLRenderer({
16
+ data,
17
+ onNavigate,
18
+ }: {
19
+ data: any
20
+ onNavigate?: (ref: ResourceRef) => void
21
+ }) {
22
+ const namespace = data?.metadata?.namespace ?? ''
23
+ const name = data?.metadata?.name ?? ''
24
+ const stored = useVeleroStoredBackups(namespace, name, !!namespace && !!name)
25
+
26
+ return (
27
+ <BaseVeleroBSLRenderer
28
+ data={data}
29
+ // Undefined while unresolved: an empty list would say this location holds
30
+ // nothing, which is a different answer from not having looked yet.
31
+ storedBackups={stored.isLoading || stored.error ? undefined : (stored.data?.backups ?? [])}
32
+ storedTotal={stored.data?.stored}
33
+ restorableTotal={stored.data?.restorable}
34
+ expiredTotal={stored.data?.expired}
35
+ listTruncated={stored.data?.truncated}
36
+ lookupNote={
37
+ stored.error ? (
38
+ <LookupFailureNote errors={[stored.error]} what="which backups are stored here" />
39
+ ) : undefined
40
+ }
41
+ onNavigate={onNavigate}
42
+ />
43
+ )
44
+ }
@@ -1 +1,75 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBackupRenderer'
1
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
2
+ import { VeleroBackupRenderer as BaseVeleroBackupRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBackupRenderer'
3
+ import { resolveBackupStorageLocation } from '@skyhook-io/k8s-ui/components/resources/resource-utils-velero'
4
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
5
+ import { useResources } from '../../../api/client'
6
+ import { useVeleroRunMessages } from '../../../api/policy'
7
+
8
+ /**
9
+ * Host wrapper adding the two things a Backup cannot answer from its own status:
10
+ * whether the storage location holding it is reachable, and what the errors and
11
+ * warnings it counted actually say.
12
+ *
13
+ * The backup's own status says Completed and goes on saying it after the bucket
14
+ * behind it goes Unavailable. This is the page someone opens to decide whether
15
+ * they can restore to this point, so the answer belongs here and not one screen
16
+ * away.
17
+ *
18
+ * Namespace is explicit — storage locations live alongside the backups that name
19
+ * them, in Velero's own namespace. Omitting it would inherit the reader's
20
+ * namespace view filter, which is a browsing preference and not the scope of
21
+ * this question.
22
+ */
23
+ export function VeleroBackupRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
24
+ const messages = useRunMessages('backups', data)
25
+ const namespace = data?.metadata?.namespace ?? ''
26
+
27
+ const locations = useResources<any>('backupstoragelocations', namespace, 'velero.io', {
28
+ enabled: !!namespace,
29
+ })
30
+
31
+ // Everything below is gated on the lookup having answered. A location we have
32
+ // not read is not a healthy one, and neither is a name we have not resolved:
33
+ // an unset spec.storageLocation means whichever location carries spec.default,
34
+ // so resolving it against a list that has not loaded produces the literal
35
+ // "default", which is wrong on any install that renamed it — and wrong
36
+ // permanently when the list errors rather than for a moment.
37
+ const answered = !locations.isLoading && !locations.error && locations.data !== undefined
38
+ const resolved = answered ? resolveBackupStorageLocation(data, locations.data) : undefined
39
+
40
+ const match = answered
41
+ ? (locations.data ?? []).find((l: any) => l?.metadata?.name === resolved)
42
+ : undefined
43
+ const phase = match?.status?.phase
44
+
45
+ // The location the backup names is gone. Velero restores from the location
46
+ // recorded on the backup, so this is not restorable — and it is invisible
47
+ // otherwise, because a location that does not exist has no phase to report.
48
+ const missing = answered && !!resolved && match === undefined
49
+
50
+ return (
51
+ <BaseVeleroBackupRenderer
52
+ data={data}
53
+ storageLocationPhase={phase}
54
+ storageLocationName={resolved}
55
+ storageLocationMissing={missing}
56
+ messages={messages}
57
+ onNavigate={onNavigate}
58
+ />
59
+ )
60
+ }
61
+
62
+ // The messages behind the counts. Nothing is fetched until the operator asks:
63
+ // reading them makes Velero create a DownloadRequest and pulls an object out of
64
+ // storage, which is not work to do on every drawer open.
65
+ function useRunMessages(kind: 'backups' | 'restores', data: any) {
66
+ const namespace = data?.metadata?.namespace ?? ''
67
+ const name = data?.metadata?.name ?? ''
68
+ const fetcher = useVeleroRunMessages(kind, namespace, name)
69
+ return {
70
+ messages: fetcher.data,
71
+ loading: fetcher.isPending,
72
+ lookupNote: fetcher.error ? <LookupFailureNote errors={[fetcher.error]} what="the messages behind these counts" /> : undefined,
73
+ onFetch: namespace && name ? () => fetcher.mutate() : undefined,
74
+ }
75
+ }
@@ -1 +1,35 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroRestoreRenderer'
1
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
2
+ import { VeleroRestoreRenderer as BaseVeleroRestoreRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroRestoreRenderer'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { useVeleroRunMessages } from '../../../api/policy'
5
+
6
+ /**
7
+ * Host wrapper adding the messages behind the run's error and warning counts.
8
+ *
9
+ * The counts are on the Restore; the text is not. It lives in a results file in
10
+ * object storage that only Velero's controller can hand out a link to — so the
11
+ * page showed a number an operator could worry about but not act on. A stuck
12
+ * restore is the more urgent of the two kinds, because someone is waiting on
13
+ * their data.
14
+ *
15
+ * Fetched on click, not on open: it creates a DownloadRequest and pulls an
16
+ * object out of storage. Same bargain as the network trace's probes.
17
+ */
18
+ export function VeleroRestoreRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
19
+ const namespace = data?.metadata?.namespace ?? ''
20
+ const name = data?.metadata?.name ?? ''
21
+ const fetcher = useVeleroRunMessages('restores', namespace, name)
22
+
23
+ return (
24
+ <BaseVeleroRestoreRenderer
25
+ data={data}
26
+ onNavigate={onNavigate}
27
+ messages={{
28
+ messages: fetcher.data,
29
+ loading: fetcher.isPending,
30
+ lookupNote: fetcher.error ? <LookupFailureNote errors={[fetcher.error]} what="the messages behind these counts" /> : undefined,
31
+ onFetch: namespace && name ? () => fetcher.mutate() : undefined,
32
+ }}
33
+ />
34
+ )
35
+ }
@@ -11,6 +11,7 @@ export { CronWorkflowRenderer } from './CronWorkflowRenderer'
11
11
  export { HPARenderer } from './HPARenderer'
12
12
  export { NodeRenderer } from './NodeRenderer'
13
13
  export { PVCRenderer } from './PVCRenderer'
14
+ export { KyvernoPolicyCoverage } from './KyvernoPolicyCoverage'
14
15
  export { RolloutRenderer } from './RolloutRenderer'
15
16
  export { CertificateRenderer } from './CertificateRenderer'
16
17
  export { WorkflowRenderer } from './WorkflowRenderer'
@@ -17,15 +17,7 @@ import type { AddonMode } from './TrafficView'
17
17
  import { getNamespaceColor } from '../../utils/traffic-colors'
18
18
  import { Tooltip } from '../ui/Tooltip'
19
19
  import { Input } from '@skyhook-io/k8s-ui'
20
-
21
- // Connection threshold options
22
- const CONNECTION_THRESHOLDS = [
23
- { value: 0, label: 'All traffic' },
24
- { value: 100, label: '100+ connections' },
25
- { value: 1000, label: '1K+ connections' },
26
- { value: 10000, label: '10K+ connections' },
27
- { value: 100000, label: '100K+ connections' },
28
- ]
20
+ import { volumeThresholds } from './trafficFilters'
29
21
 
30
22
  // Time range options
31
23
  const TIME_RANGES = [
@@ -63,7 +55,19 @@ interface TrafficFilterSidebarProps {
63
55
  setTimeRange: (v: string) => void
64
56
 
65
57
  // L7 filters (Hubble-only)
66
- isHubble?: boolean
58
+ /** Whether the flows carry any L7 detail at all. Not "is this Hubble": Beyla and
59
+ * Istio report L7 too, and gating on the source name hid these filters from them. */
60
+ showL7Filters?: boolean
61
+ /** Status buckets that can actually match. A source reporting no status
62
+ * distribution still reports an error rate, so 5xx can be offered on the strength
63
+ * of that while the others would match nothing. */
64
+ availableStatusRanges?: string[]
65
+ availableVerdicts?: string[]
66
+ hasDNSQueries?: boolean
67
+ availableHTTPMethods?: string[]
68
+ /** The active source measures rates rather than counting events, which changes
69
+ * both the unit and the useful scale of the volume filter. */
70
+ isRateBased?: boolean
67
71
  l7Protocol: string // 'all' | 'HTTP' | 'DNS' | 'TCP'
68
72
  setL7Protocol: (v: string) => void
69
73
  l7Methods: Set<string>
@@ -154,7 +158,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
154
158
  setDetectServices,
155
159
  timeRange,
156
160
  setTimeRange,
157
- isHubble,
161
+ showL7Filters,
162
+ availableStatusRanges = [],
163
+ availableVerdicts = [],
164
+ hasDNSQueries,
165
+ availableHTTPMethods = [],
166
+ isRateBased,
158
167
  l7Protocol,
159
168
  setL7Protocol,
160
169
  l7Methods,
@@ -209,7 +218,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
209
218
  onChange={(e) => setMinConnections(Number(e.target.value))}
210
219
  className="flex-1 bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border focus:outline-none focus:ring-1 focus:ring-blue-500"
211
220
  >
212
- {CONNECTION_THRESHOLDS.map(({ value, label }) => (
221
+ {volumeThresholds(isRateBased).map(({ value, label }) => (
213
222
  <option key={value} value={value}>{label}</option>
214
223
  ))}
215
224
  </select>
@@ -316,8 +325,10 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
316
325
  </div>
317
326
  </div>
318
327
 
319
- {/* L7 Filters (Hubble only) */}
320
- {isHubble && (
328
+ {/* L7 filters, shown whenever the flows carry L7 detail. Every control below
329
+ is gated on data that exists, so the panel never offers one that cannot
330
+ return anything. */}
331
+ {showL7Filters && (
321
332
  <div className="space-y-2 px-3 py-2 border-t border-theme-border">
322
333
  <div className="flex items-center gap-1.5">
323
334
  <Filter className="w-3 h-3 text-theme-text-tertiary" />
@@ -346,12 +357,13 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
346
357
  </div>
347
358
 
348
359
  {/* HTTP sub-filters (visible when protocol is All or HTTP) */}
349
- {(l7Protocol === 'all' || l7Protocol === 'HTTP') && (
360
+ {(l7Protocol === 'all' || l7Protocol === 'HTTP') && (availableHTTPMethods.length > 0 || availableStatusRanges.length > 0) && (
350
361
  <>
362
+ {availableHTTPMethods.length > 0 && (
351
363
  <div>
352
364
  <div className="text-[10px] text-theme-text-tertiary mb-1">HTTP Method</div>
353
365
  <div className="flex flex-wrap gap-1">
354
- {['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(method => (
366
+ {availableHTTPMethods.map(method => (
355
367
  <button
356
368
  key={method}
357
369
  onClick={() => onToggleL7Method(method)}
@@ -367,7 +379,9 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
367
379
  ))}
368
380
  </div>
369
381
  </div>
382
+ )}
370
383
 
384
+ {availableStatusRanges.length > 0 && (
371
385
  <div>
372
386
  <div className="text-[10px] text-theme-text-tertiary mb-1">Status Code</div>
373
387
  <div className="flex flex-wrap gap-1">
@@ -376,7 +390,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
376
390
  { label: '3xx', active: SEVERITY_BADGE.info },
377
391
  { label: '4xx', active: SEVERITY_BADGE.warning },
378
392
  { label: '5xx', active: SEVERITY_BADGE.error },
379
- ] as const).map(({ label, active }) => (
393
+ ] as const).filter(({ label }) => availableStatusRanges.includes(label)).map(({ label, active }) => (
380
394
  <button
381
395
  key={label}
382
396
  onClick={() => onToggleL7StatusRange(label)}
@@ -392,11 +406,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
392
406
  ))}
393
407
  </div>
394
408
  </div>
409
+ )}
395
410
  </>
396
411
  )}
397
412
 
398
413
  {/* DNS sub-filter (visible when protocol is All or DNS) */}
399
- {(l7Protocol === 'all' || l7Protocol === 'DNS') && (
414
+ {(l7Protocol === 'all' || l7Protocol === 'DNS') && hasDNSQueries && (
400
415
  <div>
401
416
  <div className="text-[10px] text-theme-text-tertiary mb-1">DNS Query</div>
402
417
  <Input
@@ -408,7 +423,8 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
408
423
  </div>
409
424
  )}
410
425
 
411
- {/* Verdict (always visible applies to all protocols) */}
426
+ {/* Verdict, limited to the verdicts the data actually contains. */}
427
+ {availableVerdicts.length > 0 && (
412
428
  <div>
413
429
  <div className="text-[10px] text-theme-text-tertiary mb-1">Verdict</div>
414
430
  <div className="flex flex-wrap gap-1">
@@ -416,7 +432,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
416
432
  { label: 'forwarded', active: SEVERITY_BADGE.success },
417
433
  { label: 'dropped', active: SEVERITY_BADGE.error },
418
434
  { label: 'error', active: SEVERITY_BADGE.warning },
419
- ] as const).map(({ label, active }) => (
435
+ ] as const).filter(({ label }) => availableVerdicts.includes(label)).map(({ label, active }) => (
420
436
  <button
421
437
  key={label}
422
438
  onClick={() => onToggleL7Verdict(label)}
@@ -432,6 +448,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
432
448
  ))}
433
449
  </div>
434
450
  </div>
451
+ )}
435
452
  </div>
436
453
  )}
437
454
 
@@ -181,10 +181,24 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
181
181
  </span>
182
182
  </Tooltip>
183
183
 
184
- {/* Destination */}
185
- <Tooltip content={flow.destination.namespace ? `${flow.destination.namespace}/${flow.destination.name}` : flow.destination.name} wrapperClassName="min-w-0">
184
+ {/* Destination. An unoriented conversation has no caller and no
185
+ callee the two ends are ordered arbitrarily — so it is marked
186
+ rather than presented as a direction the source established. */}
187
+ <Tooltip
188
+ content={
189
+ flow.directionUnknown
190
+ ? 'Direction unknown: this source could not determine which end opened the conversation'
191
+ : flow.destination.namespace
192
+ ? `${flow.destination.namespace}/${flow.destination.name}`
193
+ : flow.destination.name
194
+ }
195
+ wrapperClassName="min-w-0"
196
+ >
186
197
  <span className="truncate text-theme-text-primary">
187
198
  {flow.destination.name}
199
+ {flow.directionUnknown && (
200
+ <span className="ml-1 text-theme-text-tertiary">(direction unknown)</span>
201
+ )}
188
202
  </span>
189
203
  </Tooltip>
190
204