@skyhook-io/k8s-ui 1.5.0 → 1.5.2

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.
@@ -1,13 +1,15 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { ChevronRight, ChevronDown } from 'lucide-react'
2
+ import { ChevronRight, ChevronDown, Filter } from 'lucide-react'
3
3
  import type { LogLevel } from './useLogBuffer'
4
4
  import {
5
5
  getLevelColor,
6
- highlightJson,
7
6
  unescapeJsonStrings,
8
7
  parseLogfmt,
9
8
  SYNTAX_COLOR_KEY,
10
9
  SYNTAX_COLOR_STRING,
10
+ SYNTAX_COLOR_NUMBER,
11
+ SYNTAX_COLOR_BOOLEAN,
12
+ SYNTAX_COLOR_NULL,
11
13
  } from '../../utils/log-format'
12
14
  import { SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
13
15
 
@@ -17,9 +19,15 @@ interface StructuredLogLineProps {
17
19
  wordWrap: boolean
18
20
  isLogfmt?: boolean
19
21
  defaultExpanded?: boolean
22
+ /**
23
+ * When provided, field values in the expanded view become filterable — hovering
24
+ * shows a chip that, on click, calls onFilterValue(value) so the parent can
25
+ * add the value to the log search/filter.
26
+ */
27
+ onFilterValue?: (value: string) => void
20
28
  }
21
29
 
22
- export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded }: StructuredLogLineProps) {
30
+ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded, onFilterValue }: StructuredLogLineProps) {
23
31
  // null = user hasn't toggled this line; defers to defaultExpanded (global toggle)
24
32
  const [localExpanded, setLocalExpanded] = useState<boolean | null>(null)
25
33
  const expanded = localExpanded ?? defaultExpanded ?? false
@@ -75,11 +83,12 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
75
83
  </span>
76
84
  <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
77
85
  {isLogfmt ? (
78
- <ExpandedLogfmt obj={parsed} />
86
+ <ExpandedLogfmt obj={parsed} onFilterValue={onFilterValue} />
79
87
  ) : (
80
- <span dangerouslySetInnerHTML={{
81
- __html: highlightJson(unescapeJsonStrings(JSON.stringify(parsed, null, 2)))
82
- }} />
88
+ <JsonExpanded
89
+ text={unescapeJsonStrings(JSON.stringify(parsed, null, 2))}
90
+ onFilterValue={onFilterValue}
91
+ />
83
92
  )}
84
93
  </span>
85
94
  </>
@@ -88,6 +97,77 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
88
97
  )
89
98
  }
90
99
 
100
+ /**
101
+ * Render a primitive log-field value with an optional filter chip that appears on hover.
102
+ * Clicking the chip calls onFilter(value) so the caller can push it into log search.
103
+ */
104
+ function FilterableValue({
105
+ value, onFilter, color,
106
+ }: { value: string; onFilter?: (v: string) => void; color?: string }) {
107
+ if (!onFilter) {
108
+ return <span style={color ? { color } : undefined}>{value}</span>
109
+ }
110
+ return (
111
+ <span className="group/flt inline-flex items-baseline align-baseline gap-0.5 rounded hover:bg-theme-surface/60">
112
+ <span style={color ? { color } : undefined}>{value}</span>
113
+ <button
114
+ type="button"
115
+ onClick={(e) => { e.stopPropagation(); onFilter(value) }}
116
+ className="opacity-0 group-hover/flt:opacity-100 transition-opacity text-theme-text-tertiary hover:text-theme-text-primary px-0.5"
117
+ title={`Filter to lines containing "${value}"`}
118
+ aria-label={`Filter to lines containing ${value}`}
119
+ >
120
+ <Filter className="w-3 h-3 inline" />
121
+ </button>
122
+ </span>
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Render pretty-printed JSON with filterable primitive values while preserving
128
+ * the layout that JSON.stringify(..., null, 2) produces. Tokenizes the string
129
+ * and emits React nodes so the hover chip can be wired per value.
130
+ */
131
+ function JsonExpanded({ text, onFilterValue }: { text: string; onFilterValue?: (v: string) => void }) {
132
+ const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/g
133
+ const nodes: React.ReactNode[] = []
134
+ let lastIndex = 0
135
+ let match: RegExpExecArray | null
136
+ let idx = 0
137
+ while ((match = tokenRe.exec(text)) !== null) {
138
+ if (match.index > lastIndex) {
139
+ nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex, match.index)}</span>)
140
+ }
141
+ const [, key, str, num, bool, nil] = match
142
+ if (key !== undefined) {
143
+ nodes.push(<span key={`k${idx++}`} style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>)
144
+ nodes.push(<span key={`c${idx++}`}>:</span>)
145
+ } else if (str !== undefined) {
146
+ // Unescape the quoted string for the filter value (users expect to filter on
147
+ // the displayed string, not JSON-escaped bytes).
148
+ const inner = str.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
149
+ nodes.push(
150
+ <FilterableValue key={`s${idx++}`} value={inner} onFilter={onFilterValue} color={SYNTAX_COLOR_STRING} />
151
+ )
152
+ } else if (num !== undefined) {
153
+ nodes.push(
154
+ <FilterableValue key={`n${idx++}`} value={num} onFilter={onFilterValue} color={SYNTAX_COLOR_NUMBER} />
155
+ )
156
+ } else if (bool !== undefined) {
157
+ nodes.push(
158
+ <FilterableValue key={`b${idx++}`} value={bool} onFilter={onFilterValue} color={SYNTAX_COLOR_BOOLEAN} />
159
+ )
160
+ } else if (nil !== undefined) {
161
+ nodes.push(<span key={`z${idx++}`} style={{ color: SYNTAX_COLOR_NULL }}>{nil}</span>)
162
+ }
163
+ lastIndex = tokenRe.lastIndex
164
+ }
165
+ if (lastIndex < text.length) {
166
+ nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex)}</span>)
167
+ }
168
+ return <>{nodes}</>
169
+ }
170
+
91
171
  function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
92
172
  const lvl = obj.level ?? obj.severity ?? obj.lvl ?? nestedField(obj, 'log', 'level')
93
173
  const msg = obj.msg ?? obj.message
@@ -117,16 +197,19 @@ function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
117
197
  )
118
198
  }
119
199
 
120
- function ExpandedLogfmt({ obj }: { obj: Record<string, unknown> }) {
200
+ function ExpandedLogfmt({ obj, onFilterValue }: { obj: Record<string, unknown>; onFilterValue?: (v: string) => void }) {
121
201
  return (
122
202
  <>
123
- {Object.entries(obj).map(([key, val]) => (
124
- <div key={key}>
125
- <span style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>
126
- <span className="text-theme-text-tertiary">=</span>
127
- <span style={{ color: SYNTAX_COLOR_STRING }}>{String(val)}</span>
128
- </div>
129
- ))}
203
+ {Object.entries(obj).map(([key, val]) => {
204
+ const str = String(val)
205
+ return (
206
+ <div key={key}>
207
+ <span style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>
208
+ <span className="text-theme-text-tertiary">=</span>
209
+ <FilterableValue value={str} onFilter={onFilterValue} color={SYNTAX_COLOR_STRING} />
210
+ </div>
211
+ )
212
+ })}
130
213
  </>
131
214
  )
132
215
  }
@@ -8,10 +8,12 @@ interface UseLogSearchReturn {
8
8
  setQuery: (q: string) => void
9
9
  isRegex: boolean
10
10
  toggleRegex: () => void
11
+ setIsRegex: (v: boolean) => void
11
12
  isCaseSensitive: boolean
12
13
  toggleCaseSensitive: () => void
13
14
  isFilterMode: boolean
14
15
  toggleFilterMode: () => void
16
+ setFilterMode: (v: boolean) => void
15
17
  matchCount: number
16
18
  currentMatch: number
17
19
  /** Indices into the entries array that match */
@@ -121,6 +123,7 @@ export function useLogSearch(
121
123
  const toggleRegex = useCallback(() => setIsRegex(p => !p), [])
122
124
  const toggleCaseSensitive = useCallback(() => setIsCaseSensitive(p => !p), [])
123
125
  const toggleFilterMode = useCallback(() => setIsFilterMode(p => !p), [])
126
+ const setFilterMode = useCallback((v: boolean) => setIsFilterMode(v), [])
124
127
 
125
128
  const open = useCallback(() => setIsOpen(true), [])
126
129
  const close = useCallback(() => {
@@ -133,10 +136,12 @@ export function useLogSearch(
133
136
  setQuery,
134
137
  isRegex,
135
138
  toggleRegex,
139
+ setIsRegex,
136
140
  isCaseSensitive,
137
141
  toggleCaseSensitive,
138
142
  isFilterMode,
139
143
  toggleFilterMode,
144
+ setFilterMode,
140
145
  matchCount: matchIndices.length,
141
146
  currentMatch,
142
147
  matchIndices,