@skyhook-io/k8s-ui 1.1.0 → 1.2.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.
- package/package.json +8 -2
- package/src/components/dock/BottomDock.tsx +14 -15
- package/src/components/dock/DockContext.tsx +40 -1
- package/src/components/dock/LocalTerminalTab.tsx +241 -0
- package/src/components/dock/NodeTerminalTab.tsx +129 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
- package/src/components/logs/LogCore.tsx +30 -4
- package/src/components/logs/StructuredLogLine.tsx +168 -0
- package/src/components/logs/index.ts +1 -1
- package/src/components/logs/useLogBuffer.ts +16 -10
- package/src/components/resources/ResourcesView.tsx +186 -67
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
- package/src/components/resources/renderers/HPARenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.tsx +164 -8
- package/src/components/resources/renderers/VPARenderer.tsx +2 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
- package/src/components/resources/renderers/contour-cells.tsx +48 -0
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/renderers/trivy-shared.tsx +9 -25
- package/src/components/resources/resource-utils-contour.ts +34 -0
- package/src/components/resources/resource-utils.ts +91 -0
- package/src/components/shared/ResourceActionsBar.tsx +276 -144
- package/src/components/shared/ResourceRendererDispatch.tsx +59 -5
- package/src/components/shared/index.ts +1 -1
- package/src/components/timeline/shared.tsx +8 -10
- package/src/components/topology/K8sResourceNode.tsx +1 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
- package/src/components/topology/TopologyGraph.tsx +2 -17
- package/src/components/topology/layout.ts +1 -0
- package/src/components/topology/topology.css +1 -0
- package/src/components/ui/CodeViewer.tsx +1 -2
- package/src/components/ui/ConfirmDialog.tsx +85 -130
- package/src/components/ui/DialogPortal.tsx +82 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
- package/src/components/ui/Toast.tsx +5 -5
- package/src/components/ui/drawer-components.tsx +28 -22
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +30 -2
- package/src/theme/index.ts +3 -0
- package/src/theme/tailwind-theme.css +61 -0
- package/src/theme/variables.css +121 -0
- package/src/types/core.ts +13 -2
- package/src/utils/animation.ts +1 -1
- package/src/utils/badge-colors.ts +158 -123
- package/src/utils/log-format.ts +127 -35
- package/src/utils/navigation.test.ts +142 -0
- package/src/utils/navigation.ts +52 -47
- package/src/utils/resource-hierarchy.ts +5 -0
- package/src/utils/resource-icons.ts +3 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useRef, useCallback, useState, useMemo, useEffect, type ReactNode } from 'react'
|
|
2
2
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
|
3
|
-
import { Play, Square, Download, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter } from 'lucide-react'
|
|
3
|
+
import { Play, Square, Download, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, Braces } from 'lucide-react'
|
|
4
4
|
import type { LogEntry, LogLevel } from './useLogBuffer'
|
|
5
5
|
import { useLogSearch } from './useLogSearch'
|
|
6
|
-
import {
|
|
6
|
+
import { StructuredLogLine } from './StructuredLogLine'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
8
|
import {
|
|
9
9
|
formatLogTimestamp,
|
|
@@ -65,6 +65,7 @@ export function LogCore({
|
|
|
65
65
|
new Set(['error', 'warn', 'info', 'debug'])
|
|
66
66
|
)
|
|
67
67
|
const [showDownloadMenu, setShowDownloadMenu] = useState(false)
|
|
68
|
+
const [expandAllStructured, setExpandAllStructured] = useState(false)
|
|
68
69
|
|
|
69
70
|
// Level-filtered entries
|
|
70
71
|
// 'unknown' logs are shown when all 4 known levels are enabled (no active filtering)
|
|
@@ -83,6 +84,8 @@ export function LogCore({
|
|
|
83
84
|
return counts
|
|
84
85
|
}, [entries])
|
|
85
86
|
|
|
87
|
+
const hasStructuredEntries = useMemo(() => entries.some(e => e.isJson || e.isLogfmt), [entries])
|
|
88
|
+
|
|
86
89
|
// Search
|
|
87
90
|
const search = useLogSearch(levelFilteredEntries, virtuosoRef)
|
|
88
91
|
|
|
@@ -225,6 +228,20 @@ export function LogCore({
|
|
|
225
228
|
|
|
226
229
|
<div className="flex-1" />
|
|
227
230
|
|
|
231
|
+
{/* Expand all structured logs toggle */}
|
|
232
|
+
{hasStructuredEntries && (
|
|
233
|
+
<Tooltip content={expandAllStructured ? 'Collapse all structured' : 'Expand all structured'} delay={TIP_DELAY} position="bottom">
|
|
234
|
+
<button
|
|
235
|
+
onClick={() => setExpandAllStructured(prev => !prev)}
|
|
236
|
+
className={`p-1.5 rounded transition-colors ${
|
|
237
|
+
expandAllStructured ? 'bg-blue-600/50 text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
|
|
238
|
+
}`}
|
|
239
|
+
>
|
|
240
|
+
<Braces className="w-4 h-4" />
|
|
241
|
+
</button>
|
|
242
|
+
</Tooltip>
|
|
243
|
+
)}
|
|
244
|
+
|
|
228
245
|
{/* Timestamp toggle */}
|
|
229
246
|
<Tooltip content={showTimestamps ? 'Hide timestamps' : 'Show timestamps'} delay={TIP_DELAY} position="bottom">
|
|
230
247
|
<button
|
|
@@ -438,6 +455,7 @@ export function LogCore({
|
|
|
438
455
|
showTimestamp={showTimestamps}
|
|
439
456
|
isCurrentMatch={entry.id === currentHighlightId}
|
|
440
457
|
wordWrap={wordWrap}
|
|
458
|
+
defaultExpanded={expandAllStructured}
|
|
441
459
|
/>
|
|
442
460
|
)}
|
|
443
461
|
className="h-full font-mono text-xs"
|
|
@@ -483,6 +501,7 @@ function LogLine({
|
|
|
483
501
|
showTimestamp,
|
|
484
502
|
isCurrentMatch,
|
|
485
503
|
wordWrap,
|
|
504
|
+
defaultExpanded,
|
|
486
505
|
}: {
|
|
487
506
|
entry: LogEntry
|
|
488
507
|
searchQuery: string
|
|
@@ -492,6 +511,7 @@ function LogLine({
|
|
|
492
511
|
showTimestamp: boolean
|
|
493
512
|
isCurrentMatch: boolean
|
|
494
513
|
wordWrap: boolean
|
|
514
|
+
defaultExpanded: boolean
|
|
495
515
|
}) {
|
|
496
516
|
const levelColor = getLevelColor(entry.level)
|
|
497
517
|
|
|
@@ -506,9 +526,15 @@ function LogLine({
|
|
|
506
526
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
507
527
|
/>
|
|
508
528
|
)
|
|
509
|
-
} else if (entry.isJson) {
|
|
529
|
+
} else if (entry.isJson || entry.isLogfmt) {
|
|
510
530
|
contentElement = (
|
|
511
|
-
<
|
|
531
|
+
<StructuredLogLine
|
|
532
|
+
content={entry.content}
|
|
533
|
+
level={entry.level}
|
|
534
|
+
wordWrap={wordWrap}
|
|
535
|
+
isLogfmt={entry.isLogfmt}
|
|
536
|
+
defaultExpanded={defaultExpanded}
|
|
537
|
+
/>
|
|
512
538
|
)
|
|
513
539
|
} else {
|
|
514
540
|
const html = ansiToHtml(entry.content)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react'
|
|
2
|
+
import { ChevronRight, ChevronDown } from 'lucide-react'
|
|
3
|
+
import type { LogLevel } from './useLogBuffer'
|
|
4
|
+
import {
|
|
5
|
+
getLevelColor,
|
|
6
|
+
highlightJson,
|
|
7
|
+
unescapeJsonStrings,
|
|
8
|
+
parseLogfmt,
|
|
9
|
+
SYNTAX_COLOR_KEY,
|
|
10
|
+
SYNTAX_COLOR_STRING,
|
|
11
|
+
} from '../../utils/log-format'
|
|
12
|
+
import { SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
|
|
13
|
+
|
|
14
|
+
interface StructuredLogLineProps {
|
|
15
|
+
content: string
|
|
16
|
+
level: LogLevel
|
|
17
|
+
wordWrap: boolean
|
|
18
|
+
isLogfmt?: boolean
|
|
19
|
+
defaultExpanded?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded }: StructuredLogLineProps) {
|
|
23
|
+
// null = user hasn't toggled this line; defers to defaultExpanded (global toggle)
|
|
24
|
+
const [localExpanded, setLocalExpanded] = useState<boolean | null>(null)
|
|
25
|
+
const expanded = localExpanded ?? defaultExpanded ?? false
|
|
26
|
+
|
|
27
|
+
const parsed = useMemo(() => {
|
|
28
|
+
try {
|
|
29
|
+
if (isLogfmt) {
|
|
30
|
+
return parseLogfmt(content)
|
|
31
|
+
}
|
|
32
|
+
return JSON.parse(content.trim())
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}, [content, isLogfmt])
|
|
37
|
+
|
|
38
|
+
if (!parsed) {
|
|
39
|
+
return (
|
|
40
|
+
<span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${getLevelColor(level)}`}>
|
|
41
|
+
{content}
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fieldCount = Object.keys(parsed).length
|
|
47
|
+
|
|
48
|
+
const toggle = () => setLocalExpanded(!expanded)
|
|
49
|
+
const chevron = expanded
|
|
50
|
+
? <ChevronDown className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
|
|
51
|
+
: <ChevronRight className="w-3 h-3 shrink-0 text-theme-text-tertiary" />
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<span>
|
|
55
|
+
{!expanded ? (
|
|
56
|
+
// Collapsed: entire summary line is clickable
|
|
57
|
+
<span
|
|
58
|
+
onClick={toggle}
|
|
59
|
+
className={`cursor-pointer hover:bg-theme-surface/50 rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
|
|
60
|
+
>
|
|
61
|
+
<span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
|
|
62
|
+
<SummaryLine obj={parsed} />
|
|
63
|
+
<span className="text-theme-text-tertiary ml-1">{`{${fieldCount} fields}`}</span>
|
|
64
|
+
</span>
|
|
65
|
+
) : (
|
|
66
|
+
// Expanded: summary header is clickable to collapse, JSON content is selectable
|
|
67
|
+
<>
|
|
68
|
+
<span
|
|
69
|
+
onClick={toggle}
|
|
70
|
+
className="cursor-pointer hover:bg-theme-surface/50 rounded px-0.5 -ml-0.5"
|
|
71
|
+
>
|
|
72
|
+
<span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
|
|
73
|
+
<SummaryLine obj={parsed} />
|
|
74
|
+
<span className="text-theme-text-tertiary ml-1">{`{${fieldCount} fields}`}</span>
|
|
75
|
+
</span>
|
|
76
|
+
<span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
|
|
77
|
+
{isLogfmt ? (
|
|
78
|
+
<ExpandedLogfmt obj={parsed} />
|
|
79
|
+
) : (
|
|
80
|
+
<span dangerouslySetInnerHTML={{
|
|
81
|
+
__html: highlightJson(unescapeJsonStrings(JSON.stringify(parsed, null, 2)))
|
|
82
|
+
}} />
|
|
83
|
+
)}
|
|
84
|
+
</span>
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
|
|
92
|
+
const lvl = obj.level ?? obj.severity ?? obj.lvl ?? nestedField(obj, 'log', 'level')
|
|
93
|
+
const msg = obj.msg ?? obj.message
|
|
94
|
+
const rawErr = obj.error ?? obj.err
|
|
95
|
+
const err = typeof rawErr === 'string'
|
|
96
|
+
? rawErr
|
|
97
|
+
: nestedField(obj, 'error', 'message') ?? nestedField(obj, 'err', 'message')
|
|
98
|
+
const caller = obj.caller ?? obj.source
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
{lvl != null && (
|
|
103
|
+
<span className={`${getLevelBadgeColor(lvl)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
|
|
104
|
+
{formatLevel(lvl)}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
{typeof msg === 'string' && (
|
|
108
|
+
<span className="text-theme-text-primary">{msg}</span>
|
|
109
|
+
)}
|
|
110
|
+
{typeof err === 'string' && (
|
|
111
|
+
<span className="text-red-400 ml-2">error={err}</span>
|
|
112
|
+
)}
|
|
113
|
+
{typeof caller === 'string' && (
|
|
114
|
+
<span className="text-theme-text-disabled ml-2">{caller}</span>
|
|
115
|
+
)}
|
|
116
|
+
</>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ExpandedLogfmt({ obj }: { obj: Record<string, unknown> }) {
|
|
121
|
+
return (
|
|
122
|
+
<>
|
|
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
|
+
))}
|
|
130
|
+
</>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function nestedField(obj: Record<string, unknown>, parent: string, child: string): unknown {
|
|
135
|
+
const p = obj[parent]
|
|
136
|
+
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
|
137
|
+
return (p as Record<string, unknown>)[child]
|
|
138
|
+
}
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatLevel(lvl: unknown): string {
|
|
143
|
+
if (typeof lvl === 'number') {
|
|
144
|
+
if (lvl >= 50) return 'ERR'
|
|
145
|
+
if (lvl >= 40) return 'WARN'
|
|
146
|
+
if (lvl >= 30) return 'INFO'
|
|
147
|
+
return 'DBG'
|
|
148
|
+
}
|
|
149
|
+
return String(lvl).toUpperCase()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getLevelBadgeColor(lvl: unknown): string {
|
|
153
|
+
let normalized: string
|
|
154
|
+
if (typeof lvl === 'number') {
|
|
155
|
+
// Pino/bunyan numeric levels: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
|
|
156
|
+
if (lvl >= 50) normalized = 'error'
|
|
157
|
+
else if (lvl >= 40) normalized = 'warn'
|
|
158
|
+
else if (lvl >= 30) normalized = 'info'
|
|
159
|
+
else normalized = 'debug'
|
|
160
|
+
} else {
|
|
161
|
+
normalized = String(lvl).toLowerCase()
|
|
162
|
+
}
|
|
163
|
+
if (/^(error|err|fatal|panic|critical|crit)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.error
|
|
164
|
+
if (/^(warn|warning)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.warning
|
|
165
|
+
if (/^(info|information|notice)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.info
|
|
166
|
+
if (/^(debug|dbg|trace|verbose)$/.test(normalized)) return SEVERITY_BADGE_BORDERED.debug
|
|
167
|
+
return SEVERITY_BADGE_BORDERED.neutral
|
|
168
|
+
}
|
|
@@ -5,7 +5,7 @@ export type { LogLevel, LogEntry } from './useLogBuffer'
|
|
|
5
5
|
export { useLogSearch } from './useLogSearch'
|
|
6
6
|
export { useLogStream } from './useLogStream'
|
|
7
7
|
export type { LogStreamHandlers } from './useLogStream'
|
|
8
|
-
export {
|
|
8
|
+
export { StructuredLogLine } from './StructuredLogLine'
|
|
9
9
|
export { ContainerSelect, LogRangeSelect } from './LogToolbarSelects'
|
|
10
10
|
export { LogsViewer } from './LogsViewer'
|
|
11
11
|
export type { LogsViewerProps, LogsFetchParams } from './LogsViewer'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useCallback } from 'react'
|
|
2
|
+
import { isLogfmt } from '../../utils/log-format'
|
|
2
3
|
|
|
3
4
|
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'unknown'
|
|
4
5
|
|
|
@@ -11,13 +12,14 @@ export interface LogEntry {
|
|
|
11
12
|
podColor?: string
|
|
12
13
|
level: LogLevel
|
|
13
14
|
isJson: boolean
|
|
15
|
+
isLogfmt: boolean
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const MAX_BUFFER_SIZE = 10_000
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Detect log level from content using word-boundary matching.
|
|
20
|
-
* For JSON logs, prefer the `level` or `
|
|
22
|
+
* For JSON logs, prefer the `level`, `severity`, or `lvl` field.
|
|
21
23
|
*/
|
|
22
24
|
export function detectLogLevel(content: string): LogLevel {
|
|
23
25
|
// Fast path for JSON: check level/severity field
|
|
@@ -56,26 +58,30 @@ function isJsonContent(content: string): boolean {
|
|
|
56
58
|
return trimmed[0] === '{' && trimmed[trimmed.length - 1] === '}'
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'isJson' | 'isLogfmt'>
|
|
62
|
+
|
|
59
63
|
interface UseLogBufferReturn {
|
|
60
64
|
entries: LogEntry[]
|
|
61
|
-
append: (entry:
|
|
62
|
-
appendBatch: (entries:
|
|
63
|
-
set: (entries:
|
|
65
|
+
append: (entry: RawLogEntry) => void
|
|
66
|
+
appendBatch: (entries: RawLogEntry[]) => void
|
|
67
|
+
set: (entries: RawLogEntry[]) => void
|
|
64
68
|
clear: () => void
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
export function useLogBuffer(): UseLogBufferReturn {
|
|
68
72
|
const [entries, setEntries] = useState<LogEntry[]>([])
|
|
69
73
|
const idCounter = useRef(0)
|
|
70
|
-
const pendingRef = useRef<
|
|
74
|
+
const pendingRef = useRef<RawLogEntry[]>([])
|
|
71
75
|
const rafRef = useRef<number | null>(null)
|
|
72
76
|
|
|
73
|
-
const enrichEntry = useCallback((raw:
|
|
77
|
+
const enrichEntry = useCallback((raw: RawLogEntry): LogEntry => {
|
|
78
|
+
const isJ = isJsonContent(raw.content)
|
|
74
79
|
return {
|
|
75
80
|
...raw,
|
|
76
81
|
id: idCounter.current++,
|
|
77
82
|
level: detectLogLevel(raw.content),
|
|
78
|
-
isJson:
|
|
83
|
+
isJson: isJ,
|
|
84
|
+
isLogfmt: !isJ && isLogfmt(raw.content),
|
|
79
85
|
}
|
|
80
86
|
}, [])
|
|
81
87
|
|
|
@@ -95,21 +101,21 @@ export function useLogBuffer(): UseLogBufferReturn {
|
|
|
95
101
|
})
|
|
96
102
|
}, [enrichEntry])
|
|
97
103
|
|
|
98
|
-
const append = useCallback((entry:
|
|
104
|
+
const append = useCallback((entry: RawLogEntry) => {
|
|
99
105
|
pendingRef.current.push(entry)
|
|
100
106
|
if (rafRef.current === null) {
|
|
101
107
|
rafRef.current = requestAnimationFrame(flushPending)
|
|
102
108
|
}
|
|
103
109
|
}, [flushPending])
|
|
104
110
|
|
|
105
|
-
const appendBatch = useCallback((batch:
|
|
111
|
+
const appendBatch = useCallback((batch: RawLogEntry[]) => {
|
|
106
112
|
pendingRef.current.push(...batch)
|
|
107
113
|
if (rafRef.current === null) {
|
|
108
114
|
rafRef.current = requestAnimationFrame(flushPending)
|
|
109
115
|
}
|
|
110
116
|
}, [flushPending])
|
|
111
117
|
|
|
112
|
-
const set = useCallback((rawEntries:
|
|
118
|
+
const set = useCallback((rawEntries: RawLogEntry[]) => {
|
|
113
119
|
// Cancel any pending RAF
|
|
114
120
|
if (rafRef.current !== null) {
|
|
115
121
|
cancelAnimationFrame(rafRef.current)
|