@skyhook-io/k8s-ui 1.15.0 → 1.16.1

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 (37) hide show
  1. package/package.json +3 -3
  2. package/src/components/audit/AuditFindingsTable.tsx +1 -1
  3. package/src/components/checks/ChecksView.tsx +8 -8
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
  5. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  8. package/src/components/issues/IssuesView.tsx +2 -2
  9. package/src/components/logs/LogCore.tsx +131 -97
  10. package/src/components/logs/StructuredLogLine.tsx +18 -28
  11. package/src/components/logs/log-palette.ts +4 -0
  12. package/src/components/logs/useLogBuffer.ts +8 -39
  13. package/src/components/logs/useLogSearch.ts +52 -24
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  16. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  17. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  18. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  19. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  20. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  21. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  22. package/src/components/shared/ResourceActionsBar.tsx +14 -9
  23. package/src/components/ui/FilterPill.tsx +1 -1
  24. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  25. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  26. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  27. package/src/components/ui/ResourceBar.tsx +12 -6
  28. package/src/components/ui/SelectMenu.tsx +62 -19
  29. package/src/components/ui/index.ts +8 -0
  30. package/src/components/workload/WorkloadView.tsx +109 -3
  31. package/src/types/core.ts +25 -4
  32. package/src/types/fetch-error.ts +33 -0
  33. package/src/utils/log-format.ts +1 -1
  34. package/src/utils/log-level.test.ts +269 -0
  35. package/src/utils/log-level.ts +301 -0
  36. package/src/utils/resource-hierarchy.test.ts +69 -0
  37. package/src/utils/resource-hierarchy.ts +87 -52
@@ -7,6 +7,8 @@ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../ut
7
7
  export interface SelectMenuOption {
8
8
  value: string
9
9
  label: string
10
+ description?: string
11
+ disabled?: boolean
10
12
  }
11
13
 
12
14
  export function SelectMenu({
@@ -17,6 +19,8 @@ export function SelectMenu({
17
19
  ariaDescribedBy,
18
20
  className,
19
21
  searchPlaceholder,
22
+ placeholder,
23
+ variant = 'default',
20
24
  disabled = false,
21
25
  id,
22
26
  }: {
@@ -27,6 +31,8 @@ export function SelectMenu({
27
31
  ariaDescribedBy?: string
28
32
  className?: string
29
33
  searchPlaceholder?: string
34
+ placeholder?: string
35
+ variant?: 'default' | 'text'
30
36
  disabled?: boolean
31
37
  id?: string
32
38
  }) {
@@ -40,15 +46,33 @@ export function SelectMenu({
40
46
  const searchInputRef = useRef<HTMLInputElement>(null)
41
47
  const listRef = useRef<HTMLDivElement>(null)
42
48
  const pointerDownInsideRef = useRef(false)
43
- const selected = options.find((option) => option.value === value) ?? options[0]
49
+ // A keyboard open lands on the selected option, as a native select does; a
50
+ // pointer open keeps focus on the trigger.
51
+ const focusOptionOnOpenRef = useRef(false)
52
+ const selected = options.find((option) => option.value === value) ?? (placeholder ? undefined : options[0])
44
53
  const filteredOptions = useMemo(() => {
45
54
  const normalized = query.trim().toLowerCase()
46
55
  if (!normalized) return options
47
- return options.filter((option) => option.label.toLowerCase().includes(normalized))
56
+ return options.filter((option) => `${option.label} ${option.description ?? ''}`.toLowerCase().includes(normalized))
48
57
  }, [options, query])
49
58
  const selectedIsVisible = filteredOptions.some((option) => option.value === value)
50
59
 
60
+ const focusTabbableOption = () => {
61
+ listRef.current?.querySelector<HTMLElement>('[role="option"][tabindex="0"]')?.focus()
62
+ }
63
+
64
+ const openMenu = () => {
65
+ setHighlightedIndex(
66
+ Math.max(
67
+ options.findIndex((option) => option.value === value),
68
+ 0,
69
+ ),
70
+ )
71
+ setOpen(true)
72
+ }
73
+
51
74
  const selectOption = (nextValue: string) => {
75
+ if (options.find((option) => option.value === nextValue)?.disabled) return
52
76
  onChange(nextValue)
53
77
  setOpen(false)
54
78
  triggerRef.current?.focus()
@@ -81,7 +105,12 @@ export function SelectMenu({
81
105
  useEffect(() => {
82
106
  if (!open || !shouldRender) return
83
107
  if (!searchPlaceholder) {
84
- triggerRef.current?.focus()
108
+ if (focusOptionOnOpenRef.current) {
109
+ focusOptionOnOpenRef.current = false
110
+ focusTabbableOption()
111
+ } else {
112
+ triggerRef.current?.focus()
113
+ }
85
114
  return
86
115
  }
87
116
  listRef.current
@@ -130,28 +159,38 @@ export function SelectMenu({
130
159
  aria-expanded={open}
131
160
  aria-controls={open ? listboxId : undefined}
132
161
  onClick={() => {
133
- if (!open) {
134
- setHighlightedIndex(
135
- Math.max(
136
- options.findIndex((option) => option.value === value),
137
- 0,
138
- ),
139
- )
162
+ if (open) setOpen(false)
163
+ else openMenu()
164
+ }}
165
+ onKeyDown={(event) => {
166
+ if (searchPlaceholder) return
167
+ const arrow = event.key === 'ArrowDown' || event.key === 'ArrowUp'
168
+ if (!arrow && (open || (event.key !== 'Enter' && event.key !== ' '))) return
169
+ event.preventDefault()
170
+ if (open) {
171
+ focusTabbableOption()
172
+ return
140
173
  }
141
- setOpen(!open)
174
+ focusOptionOnOpenRef.current = true
175
+ openMenu()
142
176
  }}
143
177
  disabled={disabled}
144
- className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-theme-elevated"
178
+ className={clsx(
179
+ 'flex items-center gap-1 text-xs disabled:cursor-not-allowed disabled:opacity-50',
180
+ variant === 'text'
181
+ ? 'text-accent-text hover:underline'
182
+ : 'h-8 w-full justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-theme-text-primary transition-colors hover:bg-theme-hover disabled:hover:bg-theme-elevated',
183
+ )}
145
184
  >
146
- <span className="truncate">{selected?.label}</span>
147
- <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
185
+ <span className="truncate">{selected?.label ?? placeholder}</span>
186
+ <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 transition-transform', variant !== 'text' && 'text-theme-text-tertiary', open && 'rotate-180')} />
148
187
  </button>
149
188
  {shouldRender && (
150
189
  <div
151
190
  inert={!open}
152
191
  className={clsx(
153
192
  'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
154
- searchPlaceholder ? 'left-0 right-0 origin-top-left' : 'right-0 origin-top-right',
193
+ variant === 'text' ? 'left-0 w-72 max-w-[calc(100vw-4rem)] origin-top-left' : searchPlaceholder ? 'left-0 right-0 origin-top-left' : 'right-0 origin-top-right',
155
194
  TRANSITION_MENU,
156
195
  isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
157
196
  !open && 'pointer-events-none',
@@ -177,8 +216,7 @@ export function SelectMenu({
177
216
  onKeyDown={(event) => {
178
217
  if (event.key === 'Enter' && filteredOptions.length > 0) {
179
218
  event.preventDefault()
180
- const nextIndex = Math.min(highlightedIndex, filteredOptions.length - 1)
181
- selectOption(filteredOptions[nextIndex].value)
219
+ selectOption(filteredOptions[Math.min(highlightedIndex, filteredOptions.length - 1)].value)
182
220
  } else if (event.key === 'ArrowDown') {
183
221
  event.preventDefault()
184
222
  const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
@@ -244,19 +282,24 @@ export function SelectMenu({
244
282
  type="button"
245
283
  role="option"
246
284
  aria-selected={active}
285
+ aria-disabled={option.disabled || undefined}
247
286
  tabIndex={
248
287
  searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
249
288
  }
250
289
  onClick={() => selectOption(option.value)}
251
290
  onFocus={() => setHighlightedIndex(index)}
252
291
  className={clsx(
253
- 'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary',
292
+ 'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors',
293
+ option.disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-theme-hover hover:text-theme-text-primary',
254
294
  searchPlaceholder && index === highlightedIndex && 'bg-theme-hover text-theme-text-primary',
255
295
  !searchPlaceholder && 'whitespace-nowrap'
256
296
  )}
257
297
  >
258
298
  <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
259
- <span className={clsx(searchPlaceholder && 'truncate')}>{option.label}</span>
299
+ <span className={clsx('min-w-0', searchPlaceholder && !option.description && 'truncate')}>
300
+ <span className={clsx(option.description && 'block break-words font-medium')}>{option.label}</span>
301
+ {option.description && <span className="block break-all text-theme-text-tertiary">{option.description}</span>}
302
+ </span>
260
303
  </button>
261
304
  )
262
305
  })}
@@ -74,3 +74,11 @@ export {
74
74
  export type { CardSectionTone } from "./CardSection";
75
75
  export { MultiSelectPicker } from "./MultiSelectPicker";
76
76
  export type { MultiSelectPickerProps } from "./MultiSelectPicker";
77
+ export {
78
+ RadarUpgradeNote,
79
+ RadarUpgradeAction,
80
+ RadarUpgradeContext,
81
+ radarUpgradeDetail,
82
+ radarUpgradeHeadline,
83
+ } from "./RadarUpgradeNote";
84
+ export type { RadarUpgradeActions } from "./RadarUpgradeNote";
@@ -56,7 +56,7 @@ import { ResourceRendererDispatch, getResourceStatus, diagnoseHealthHint, type D
56
56
  import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
57
57
  import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
58
58
  import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
59
- import { getKindColorOutline, displayKindName, OperationalIssuesShownContext, ResourceRefBadge } from '../ui/drawer-components'
59
+ import { AlertBanner, getKindColorOutline, displayKindName, OperationalIssuesShownContext, ResourceRefBadge } from '../ui/drawer-components'
60
60
  import { Badge, type BadgeSeverity } from '../ui/Badge'
61
61
  import { Tooltip } from '../ui/Tooltip'
62
62
  import { midTruncate } from '../../utils/format'
@@ -181,8 +181,23 @@ interface WorkloadViewProps {
181
181
  refetch?: () => void
182
182
 
183
183
  // ── Timeline data ────────────────────────────────────────────────────────
184
- /** All timeline events for this resource's namespace */
184
+ /** Timeline events the history lanes are built from: this workload's own
185
+ * history, or events for its namespace from a host that has no scoped query. */
185
186
  allEvents?: TimelineEvent[]
187
+ /** Older history exists beyond allEvents. */
188
+ historyTruncated?: boolean
189
+ /** Loads the next older page of history; omit when the host can't page. */
190
+ onLoadOlderHistory?: () => void
191
+ loadingOlderHistory?: boolean
192
+ /** The last Load older attempt failed. */
193
+ olderHistoryError?: Error | null
194
+ /** allEvents holds only this workload's own history (what it runs and the
195
+ * resources attached to it), not its whole namespace. */
196
+ historyScoped?: boolean
197
+ /** Some related resources' history isn't included: there were more than the host follows. */
198
+ historyIncomplete?: boolean
199
+ historyError?: Error | null
200
+ onRetryHistory?: () => void
186
201
  /** Persisted lifecycle events reconstructed for resources related to this workload. */
187
202
  relatedTimelineEvents?: TimelineEvent[]
188
203
  /** Whether timeline events are loading */
@@ -384,6 +399,14 @@ export function WorkloadView({
384
399
  refetch: refetchProp,
385
400
  // Timeline
386
401
  allEvents,
402
+ historyTruncated = false,
403
+ onLoadOlderHistory,
404
+ loadingOlderHistory = false,
405
+ olderHistoryError = null,
406
+ historyScoped = false,
407
+ historyIncomplete = false,
408
+ historyError = null,
409
+ onRetryHistory,
387
410
  relatedTimelineEvents = [],
388
411
  eventsLoading = false,
389
412
  topology,
@@ -501,9 +524,10 @@ export function WorkloadView({
501
524
  events: allEvents,
502
525
  topology,
503
526
  rootResource: { kind, group, namespace, name },
527
+ eventsScopedToRoot: historyScoped,
504
528
  groupByApp: true,
505
529
  })
506
- }, [allEvents, topology, kind, group, namespace, name])
530
+ }, [allEvents, topology, kind, group, namespace, name, historyScoped])
507
531
 
508
532
  // Topology tab — the seeded neighborhood around this one workload (its
509
533
  // ownership core + attached Services/config/policies), not the whole namespace.
@@ -1145,6 +1169,14 @@ export function WorkloadView({
1145
1169
  <EventsTab
1146
1170
  events={resourceEvents}
1147
1171
  isLoading={eventsLoading}
1172
+ truncated={historyTruncated}
1173
+ onLoadOlder={onLoadOlderHistory}
1174
+ loadingOlder={loadingOlderHistory}
1175
+ olderError={olderHistoryError}
1176
+ scoped={historyScoped}
1177
+ incomplete={historyIncomplete}
1178
+ error={historyError}
1179
+ onRetry={onRetryHistory}
1148
1180
  selectedEventId={selectedEventId}
1149
1181
  onSelectEvent={setSelectedEventId}
1150
1182
  topology={topology}
@@ -1399,6 +1431,14 @@ function OwnershipHeading({
1399
1431
  function EventsTab({
1400
1432
  events,
1401
1433
  isLoading,
1434
+ truncated,
1435
+ onLoadOlder,
1436
+ loadingOlder,
1437
+ olderError,
1438
+ scoped,
1439
+ incomplete,
1440
+ error,
1441
+ onRetry,
1402
1442
  selectedEventId,
1403
1443
  onSelectEvent,
1404
1444
  topology,
@@ -1406,6 +1446,14 @@ function EventsTab({
1406
1446
  }: {
1407
1447
  events: TimelineEvent[]
1408
1448
  isLoading: boolean
1449
+ truncated?: boolean
1450
+ onLoadOlder?: () => void
1451
+ loadingOlder?: boolean
1452
+ olderError?: Error | null
1453
+ scoped?: boolean
1454
+ incomplete?: boolean
1455
+ error?: Error | null
1456
+ onRetry?: () => void
1409
1457
  selectedEventId: string | null
1410
1458
  onSelectEvent: (id: string | null) => void
1411
1459
  topology?: Topology
@@ -1500,8 +1548,66 @@ function EventsTab({
1500
1548
  )
1501
1549
  }
1502
1550
 
1551
+ if (error && events.length === 0) {
1552
+ return (
1553
+ <div className="p-4">
1554
+ <AlertBanner variant="error" title="Couldn't load this workload's history" message={error.message}>
1555
+ {onRetry && (
1556
+ <button
1557
+ type="button"
1558
+ onClick={onRetry}
1559
+ className="mt-2 rounded border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
1560
+ >
1561
+ Try again
1562
+ </button>
1563
+ )}
1564
+ </AlertBanner>
1565
+ </div>
1566
+ )
1567
+ }
1568
+
1503
1569
  return (
1504
1570
  <div className="h-full flex flex-col overflow-hidden">
1571
+ {error && (
1572
+ <div className="shrink-0 flex flex-wrap items-center gap-x-2 border-b border-theme-border bg-theme-base px-4 py-1.5 text-xs" role="alert">
1573
+ <span className="text-[var(--color-error)]" title={error.message}>
1574
+ Couldn't load this workload's history. Showing only what loaded.
1575
+ </span>
1576
+ {onRetry && (
1577
+ <button
1578
+ type="button"
1579
+ onClick={onRetry}
1580
+ className="underline decoration-theme-border underline-offset-2 text-theme-text-secondary hover:text-theme-text-primary"
1581
+ >
1582
+ Try again
1583
+ </button>
1584
+ )}
1585
+ </div>
1586
+ )}
1587
+ {(scoped || truncated || incomplete) && (
1588
+ <div className="shrink-0 flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-theme-border bg-theme-base px-4 py-1.5 text-xs text-theme-text-secondary" role="note">
1589
+ {scoped && (
1590
+ <span className="text-theme-text-tertiary">
1591
+ This workload, what it runs, and the Services, Ingresses, config and scalers attached to it.
1592
+ </span>
1593
+ )}
1594
+ {incomplete && <span>This workload has more past runs and related resources than Radar follows, so some are left out.</span>}
1595
+ {truncated && <span>Showing the most recent events. Older history exists.</span>}
1596
+ {truncated && olderError && (
1597
+ <span className="text-[var(--color-error)]" title={olderError.message}>Couldn't load older events.</span>
1598
+ )}
1599
+ {truncated && onLoadOlder && (
1600
+ <button
1601
+ type="button"
1602
+ onClick={onLoadOlder}
1603
+ disabled={loadingOlder}
1604
+ className="underline decoration-theme-border underline-offset-2 hover:text-theme-text-primary disabled:opacity-60"
1605
+ >
1606
+ {loadingOlder ? 'Loading…' : olderError ? 'Try again' : 'Load older'}
1607
+ </button>
1608
+ )}
1609
+ </div>
1610
+ )}
1505
1611
  {/* Swimlane — the shared TimelineSwimlanes widget (kind chips, top axis with
1506
1612
  ticks + Now line, event clustering), flat + compact for a single subject.
1507
1613
  Its drawer is suppressed (compact); the list below is the detail surface. */}
package/src/types/core.ts CHANGED
@@ -163,6 +163,10 @@ export interface FeatureCapabilities {
163
163
  yamlReview?: boolean
164
164
  yamlSchemas?: boolean
165
165
  workloadImages?: boolean
166
+ resourceIssues?: boolean
167
+ podEnvironment?: boolean
168
+ policyResource?: boolean
169
+ workloadHistory?: boolean
166
170
  }
167
171
 
168
172
  // DeploymentMode is the closed set of topologies Radar can run in.
@@ -1367,7 +1371,15 @@ export interface AggregatedFlow {
1367
1371
  l7Protocol?: string // HTTP, gRPC, DNS
1368
1372
  requestCount?: number
1369
1373
  errorCount?: number
1374
+ /** Per-second rates from a metric-based source, unrounded. requestCount and
1375
+ * errorCount carry the same figures rounded with a floor of one, so their
1376
+ * ratio is meaningless at low rates. */
1377
+ requestRate?: number
1378
+ errorRate?: number
1370
1379
  avgLatencyMs?: number
1380
+ /** How many measured responses the latency figures come from (sources that
1381
+ * report individual responses). Weights averages when edges are combined. */
1382
+ latencySamples?: number
1371
1383
  latencyP50Ms?: number
1372
1384
  latencyP95Ms?: number
1373
1385
  latencyP99Ms?: number
@@ -1434,12 +1446,21 @@ export interface TrafficFlowsResponse {
1434
1446
  timestamp: string
1435
1447
  flows: TrafficFlow[]
1436
1448
  aggregated: AggregatedFlow[]
1449
+ /** L7 responses run caller → callee on the server's port, like their request.
1450
+ * Absent from a Radar that sent them server → client. */
1451
+ l7ResponsesCallerOriented?: boolean
1452
+ /** When the flows start being complete: some node returned its full
1453
+ * nodeFlowLimit, so older traffic in the window did not fit. */
1454
+ coveredSince?: string
1455
+ nodeFlowLimit?: number
1437
1456
  warning?: string // Non-fatal warning (e.g., query errors)
1438
1457
  /** 'transient' (or absent) means the condition may clear on its own and a
1439
- * retry is worthwhile. 'partial' means the flows are correct but incomplete
1440
- * for a reason retrying cannot change, so show the warning next to them and
1441
- * do not refetch. */
1442
- warningKind?: 'transient' | 'partial'
1458
+ * retry is worthwhile. 'partial' means the flows are correct but some of
1459
+ * their values are missing or wrong, so show the warning next to them and do
1460
+ * not refetch. 'incomplete' means the fetch worked but could not see
1461
+ * everything — events lost, nodes unreachable — so flows may be missing; show
1462
+ * it, with or without flows, and do not retry at once. */
1463
+ warningKind?: 'transient' | 'partial' | 'incomplete'
1443
1464
  }
1444
1465
 
1445
1466
  // Wizard state for traffic setup
@@ -18,3 +18,36 @@ export function isFetchError(error: unknown): error is FetchErrorShape {
18
18
  export function isForbiddenError(error: unknown): boolean {
19
19
  return isFetchError(error) && error.status === 403
20
20
  }
21
+
22
+ // RadarUpgradeRequirement describes a feature the connected Radar is too old to
23
+ // serve. An embedding host (Radar Hub) runs a newer frontend against whatever
24
+ // Radar is installed in the cluster, so "this endpoint does not exist yet" is an
25
+ // expected state that deserves an upgrade prompt, not a red error.
26
+ export interface RadarUpgradeRequirement {
27
+ /** What the user is missing, phrased to start a sentence ("Policy results"). */
28
+ feature: string
29
+ /**
30
+ * First Radar release that serves the feature, e.g. "v1.10.0". Absent when
31
+ * it isn't known yet (a feature still awaiting its release); the note then
32
+ * asks for the latest Radar instead.
33
+ */
34
+ minimumVersion?: string
35
+ /** The connected Radar's version, when known. */
36
+ currentVersion?: string
37
+ /** Newest Radar release, when known. */
38
+ latestVersion?: string
39
+ }
40
+
41
+ /**
42
+ * Reads the upgrade requirement off an error, duck-typed like FetchErrorShape so
43
+ * the host's error class (web/'s or Radar Hub's) never has to be imported here.
44
+ */
45
+ export function getRadarUpgradeRequirement(error: unknown): RadarUpgradeRequirement | null {
46
+ if (typeof error !== 'object' || error === null) return null
47
+ const requirement = (error as { radarUpgrade?: unknown }).radarUpgrade
48
+ if (typeof requirement !== 'object' || requirement === null) return null
49
+ const r = requirement as Record<string, unknown>
50
+ if (typeof r.feature !== 'string') return null
51
+ if (r.minimumVersion !== undefined && typeof r.minimumVersion !== 'string') return null
52
+ return requirement as RadarUpgradeRequirement
53
+ }
@@ -3,7 +3,7 @@
3
3
  * Shared between LogsViewer and WorkloadLogsViewer components.
4
4
  */
5
5
 
6
- import type { LogLevel } from '../components/logs/useLogBuffer'
6
+ import type { LogLevel } from './log-level'
7
7
 
8
8
  export type TimestampFormat =
9
9
  | 'time-local'