@skyhook-io/k8s-ui 1.1.1 → 1.2.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.
- package/package.json +6 -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 +136 -24
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
- package/src/components/resources/renderers/PodRenderer.tsx +164 -8
- 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 +135 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -5
- 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/layout.ts +1 -0
- package/src/components/topology/topology.css +1 -0
- package/src/components/ui/CodeViewer.tsx +1 -2
- 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/workload/WorkloadView.tsx +11 -3
- 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 +12 -0
- 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/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)
|
|
@@ -32,9 +32,9 @@ import type { NavigateToResource } from '../../utils/navigation'
|
|
|
32
32
|
import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
|
|
33
33
|
import {
|
|
34
34
|
getPodStatus,
|
|
35
|
-
getPodReadiness,
|
|
36
35
|
getPodRestarts,
|
|
37
36
|
getPodProblems,
|
|
37
|
+
getContainerSquareStates,
|
|
38
38
|
getWorkloadImages,
|
|
39
39
|
getWorkloadConditions,
|
|
40
40
|
getReplicaSetOwner,
|
|
@@ -114,6 +114,7 @@ import {
|
|
|
114
114
|
getServiceAccountSecretCount,
|
|
115
115
|
getRoleRuleCount,
|
|
116
116
|
formatAge,
|
|
117
|
+
formatDuration,
|
|
117
118
|
truncate,
|
|
118
119
|
getCellFilterValue,
|
|
119
120
|
parseColumnFilters,
|
|
@@ -136,6 +137,7 @@ import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCel
|
|
|
136
137
|
import { VirtualServiceCell, DestinationRuleCell, IstioGatewayCell, ServiceEntryCell, PeerAuthenticationCell, AuthorizationPolicyCell } from './renderers/istio-cells'
|
|
137
138
|
import { KnativeServiceCell, ConfigurationCell as KnativeConfigurationCell, RevisionCell as KnativeRevisionCell, RouteCell as KnativeRouteCell, BrokerCell, TriggerCell, EventTypeCell, PingSourceCell, ApiServerSourceCell, ContainerSourceCell, SinkBindingCell, ChannelCell, InMemoryChannelCell, SubscriptionCell, SequenceCell, ParallelCell, DomainMappingCell, ServerlessServiceCell, KnativeIngressCell, KnativeCertificateCell } from './renderers/knative-cells'
|
|
138
139
|
import { IngressRouteCell, MiddlewareCell, TraefikServiceCell, ServersTransportCell, TLSOptionCell } from './renderers/traefik-cells'
|
|
140
|
+
import { HTTPProxyCell } from './renderers/contour-cells'
|
|
139
141
|
import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
140
142
|
|
|
141
143
|
// Pod problem filter options (special multi-select, not a single column value)
|
|
@@ -242,7 +244,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
242
244
|
pods: [
|
|
243
245
|
{ key: 'name', label: 'Name' },
|
|
244
246
|
{ key: 'namespace', label: 'Namespace', width: 'w-48' },
|
|
245
|
-
{ key: '
|
|
247
|
+
{ key: 'containers', label: 'Containers', width: 'w-28' },
|
|
246
248
|
{ key: 'status', label: 'Status', width: 'w-40' },
|
|
247
249
|
{ key: 'cpu', label: 'CPU', width: 'w-40', tooltip: 'CPU usage / limit (marker = request)' },
|
|
248
250
|
{ key: 'memory', label: 'Memory', width: 'w-40', tooltip: 'Memory usage / limit (marker = request)' },
|
|
@@ -1318,6 +1320,17 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
1318
1320
|
{ key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
|
|
1319
1321
|
{ key: 'age', label: 'Age', width: 'w-16 shrink-0' },
|
|
1320
1322
|
],
|
|
1323
|
+
// Contour
|
|
1324
|
+
httpproxies: [
|
|
1325
|
+
{ key: 'name', label: 'Name', width: 'min-w-40' },
|
|
1326
|
+
{ key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
|
|
1327
|
+
{ key: 'fqdn', label: 'FQDN', width: 'min-w-44' },
|
|
1328
|
+
{ key: 'routes', label: 'Routes', width: 'w-20 shrink-0' },
|
|
1329
|
+
{ key: 'includes', label: 'Includes', width: 'w-24 shrink-0' },
|
|
1330
|
+
{ key: 'tls', label: 'TLS', width: 'w-14 shrink-0' },
|
|
1331
|
+
{ key: 'status', label: 'Status', width: 'w-24 shrink-0' },
|
|
1332
|
+
{ key: 'age', label: 'Age', width: 'w-16 shrink-0' },
|
|
1333
|
+
],
|
|
1321
1334
|
}
|
|
1322
1335
|
|
|
1323
1336
|
// Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
|
|
@@ -1344,6 +1357,8 @@ function normalizeKindToPlural(kind: string, group?: string): string {
|
|
|
1344
1357
|
if (!lower.endsWith('s') && KNOWN_COLUMNS[lower + 's']) return lower + 's'
|
|
1345
1358
|
// Try 'es' for kinds ending in s/sh/ch/x/z (e.g., "ingress" → "ingresses")
|
|
1346
1359
|
if (KNOWN_COLUMNS[lower + 'es']) return lower + 'es'
|
|
1360
|
+
// Try 'ies' for kinds ending in y (e.g., "httpproxy" → "httpproxies")
|
|
1361
|
+
if (lower.endsWith('y') && KNOWN_COLUMNS[lower.slice(0, -1) + 'ies']) return lower.slice(0, -1) + 'ies'
|
|
1347
1362
|
return lower
|
|
1348
1363
|
}
|
|
1349
1364
|
|
|
@@ -2441,13 +2456,15 @@ export function ResourcesView({
|
|
|
2441
2456
|
return meta.creationTimestamp ? new Date(meta.creationTimestamp).getTime() : 0
|
|
2442
2457
|
case 'status':
|
|
2443
2458
|
return status.phase || ''
|
|
2444
|
-
case '
|
|
2445
|
-
//
|
|
2459
|
+
case 'containers':
|
|
2460
|
+
// Pod containers column — sort by readiness ratio
|
|
2446
2461
|
if (status.containerStatuses) {
|
|
2447
2462
|
const ready = status.containerStatuses.filter((c: any) => c.ready).length
|
|
2448
2463
|
const total = status.containerStatuses.length
|
|
2449
2464
|
return total > 0 ? ready / total : 0
|
|
2450
2465
|
}
|
|
2466
|
+
return 0
|
|
2467
|
+
case 'ready':
|
|
2451
2468
|
// For DaemonSets, use numberReady/desiredNumberScheduled
|
|
2452
2469
|
if (kindLower === 'daemonsets') {
|
|
2453
2470
|
const desired = status.desiredNumberScheduled ?? 0
|
|
@@ -2629,13 +2646,13 @@ export function ResourcesView({
|
|
|
2629
2646
|
const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
|
|
2630
2647
|
|
|
2631
2648
|
if (kindLower === 'pods') {
|
|
2632
|
-
// Completed pods at bottom
|
|
2649
|
+
// Completed pods at bottom, then sort by name for stability across refreshes
|
|
2633
2650
|
result = [...result].sort((a: any, b: any) => {
|
|
2634
2651
|
const aCompleted = a.status?.phase === 'Succeeded'
|
|
2635
2652
|
const bCompleted = b.status?.phase === 'Succeeded'
|
|
2636
2653
|
if (aCompleted && !bCompleted) return 1
|
|
2637
2654
|
if (!aCompleted && bCompleted) return -1
|
|
2638
|
-
return
|
|
2655
|
+
return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
|
|
2639
2656
|
})
|
|
2640
2657
|
} else if (kindLower === 'daemonsets') {
|
|
2641
2658
|
// DaemonSets with 0 desired (empty/inactive) at bottom, then sort by ready desc
|
|
@@ -2659,11 +2676,12 @@ export function ResourcesView({
|
|
|
2659
2676
|
return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
|
|
2660
2677
|
})
|
|
2661
2678
|
} else if (kindLower === 'events') {
|
|
2662
|
-
// Events: most recently seen first
|
|
2679
|
+
// Events: most recently seen first, name tiebreaker for same-timestamp stability
|
|
2663
2680
|
result = [...result].sort((a: any, b: any) => {
|
|
2664
2681
|
const aTime = new Date(a.lastTimestamp || a.metadata?.creationTimestamp || 0).getTime()
|
|
2665
2682
|
const bTime = new Date(b.lastTimestamp || b.metadata?.creationTimestamp || 0).getTime()
|
|
2666
|
-
return bTime - aTime
|
|
2683
|
+
if (bTime !== aTime) return bTime - aTime
|
|
2684
|
+
return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
|
|
2667
2685
|
})
|
|
2668
2686
|
} else if (['deployments', 'statefulsets', 'replicasets'].includes(kindLower)) {
|
|
2669
2687
|
// Workloads: unhealthy first, scaled-to-zero at bottom
|
|
@@ -2686,6 +2704,11 @@ export function ResourcesView({
|
|
|
2686
2704
|
// Finally sort by name
|
|
2687
2705
|
return (a.metadata?.name || '').localeCompare(b.metadata?.name || '')
|
|
2688
2706
|
})
|
|
2707
|
+
} else {
|
|
2708
|
+
// All other kinds: sort by name for stability across refreshes
|
|
2709
|
+
result = [...result].sort((a: any, b: any) =>
|
|
2710
|
+
(a.metadata?.name || '').localeCompare(b.metadata?.name || '')
|
|
2711
|
+
)
|
|
2689
2712
|
}
|
|
2690
2713
|
}
|
|
2691
2714
|
|
|
@@ -4209,6 +4232,9 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion }
|
|
|
4209
4232
|
return <ServersTransportCell resource={resource} column={column} />
|
|
4210
4233
|
case 'tlsoptions':
|
|
4211
4234
|
return <TLSOptionCell resource={resource} column={column} />
|
|
4235
|
+
// Contour
|
|
4236
|
+
case 'httpproxies':
|
|
4237
|
+
return <HTTPProxyCell resource={resource} column={column} />
|
|
4212
4238
|
default:
|
|
4213
4239
|
// Generic cell for CRDs and unknown resources
|
|
4214
4240
|
return <GenericCell resource={resource} column={column} />
|
|
@@ -4277,27 +4303,113 @@ function GenericCell({ resource, column }: { resource: any; column: string }) {
|
|
|
4277
4303
|
// ============================================================================
|
|
4278
4304
|
|
|
4279
4305
|
function PodCell({ resource, column }: { resource: any; column: string }) {
|
|
4280
|
-
const phase = resource.status?.phase
|
|
4281
|
-
const isCompleted = phase === 'Succeeded'
|
|
4282
4306
|
const metrics = useContext(MetricsContext)
|
|
4283
4307
|
const { onNavigate: navigate } = useContext(ResourcesViewDataContext)
|
|
4284
4308
|
|
|
4285
4309
|
switch (column) {
|
|
4286
|
-
case '
|
|
4287
|
-
const
|
|
4288
|
-
const
|
|
4289
|
-
// Completed pods (Succeeded) show neutral color, not red
|
|
4290
|
-
const color = isCompleted
|
|
4291
|
-
? 'text-theme-text-secondary'
|
|
4292
|
-
: allReady
|
|
4293
|
-
? 'text-green-400'
|
|
4294
|
-
: ready > 0
|
|
4295
|
-
? 'text-yellow-400'
|
|
4296
|
-
: 'text-red-400'
|
|
4310
|
+
case 'containers': {
|
|
4311
|
+
const squares = getContainerSquareStates(resource)
|
|
4312
|
+
const hasInit = squares.some(s => s.isInit)
|
|
4297
4313
|
return (
|
|
4298
|
-
<
|
|
4299
|
-
{
|
|
4300
|
-
|
|
4314
|
+
<div className="flex items-center gap-1">
|
|
4315
|
+
{squares.map((sq, i) => {
|
|
4316
|
+
const showSeparator = hasInit && i > 0 && sq.isInit !== squares[i - 1].isInit
|
|
4317
|
+
const bgClass =
|
|
4318
|
+
sq.status === 'ready' ? 'bg-green-500' :
|
|
4319
|
+
sq.status === 'completed' ? 'bg-theme-text-tertiary/30 border border-theme-text-tertiary' :
|
|
4320
|
+
sq.status === 'running' ? 'bg-yellow-500' :
|
|
4321
|
+
sq.status === 'waiting' ? 'bg-red-500' :
|
|
4322
|
+
sq.status === 'terminated' ? 'bg-red-500' :
|
|
4323
|
+
'bg-theme-text-tertiary/30 border border-dashed border-theme-text-tertiary'
|
|
4324
|
+
const ringClass = sq.restarts > 0 ? 'ring-2 ring-orange-400' : ''
|
|
4325
|
+
const dotColor =
|
|
4326
|
+
sq.status === 'ready' ? 'bg-green-500' :
|
|
4327
|
+
sq.status === 'completed' ? 'bg-theme-text-tertiary' :
|
|
4328
|
+
sq.status === 'running' ? 'bg-yellow-500' :
|
|
4329
|
+
sq.status === 'waiting' || sq.status === 'terminated' ? 'bg-red-500' :
|
|
4330
|
+
'bg-theme-text-tertiary'
|
|
4331
|
+
// Relative time helper
|
|
4332
|
+
const timeAgo = (dateStr?: string) => {
|
|
4333
|
+
if (!dateStr) return null
|
|
4334
|
+
const ms = Date.now() - new Date(dateStr).getTime()
|
|
4335
|
+
if (isNaN(ms) || ms < 0) return null
|
|
4336
|
+
if (ms < 60000) return 'just now'
|
|
4337
|
+
return formatDuration(ms) + ' ago'
|
|
4338
|
+
}
|
|
4339
|
+
const runDuration = (start?: string, end?: string) => {
|
|
4340
|
+
if (!start || !end) return null
|
|
4341
|
+
const ms = new Date(end).getTime() - new Date(start).getTime()
|
|
4342
|
+
return ms > 0 ? formatDuration(ms, true) : null
|
|
4343
|
+
}
|
|
4344
|
+
const stateLabel =
|
|
4345
|
+
sq.status === 'ready' ? 'Running' :
|
|
4346
|
+
sq.status === 'completed' ? 'Completed' :
|
|
4347
|
+
sq.status === 'running' ? 'Running (not ready)' :
|
|
4348
|
+
sq.status === 'waiting' ? 'Waiting' :
|
|
4349
|
+
sq.status === 'terminated' ? 'Terminated' : 'Unknown'
|
|
4350
|
+
const uptime = sq.status === 'ready' || sq.status === 'running' ? timeAgo(sq.startedAt) : null
|
|
4351
|
+
const duration = (sq.status === 'completed' || sq.status === 'terminated') ? runDuration(sq.startedAt, sq.finishedAt) : null
|
|
4352
|
+
const lt = sq.lastTermination
|
|
4353
|
+
const restartRecencyMs = lt?.finishedAt ? Date.now() - new Date(lt.finishedAt).getTime() : null
|
|
4354
|
+
const restartColor = restartRecencyMs !== null && restartRecencyMs < 600000 ? 'text-red-400' : restartRecencyMs !== null && restartRecencyMs < 3600000 ? 'text-yellow-400' : 'text-orange-400'
|
|
4355
|
+
const tooltipContent = (
|
|
4356
|
+
<div className="whitespace-normal space-y-1">
|
|
4357
|
+
{/* Header: dot + name + state */}
|
|
4358
|
+
<div className="flex items-center gap-1.5">
|
|
4359
|
+
<div className={clsx('w-2 h-2 rounded-full shrink-0', dotColor)} />
|
|
4360
|
+
<span className="font-medium">{sq.isInit ? <span className="text-theme-text-tertiary font-normal">init · </span> : ''}{sq.name}</span>
|
|
4361
|
+
<span className="text-theme-text-tertiary">·</span>
|
|
4362
|
+
<span className="text-theme-text-secondary">{stateLabel}</span>
|
|
4363
|
+
</div>
|
|
4364
|
+
{/* Reason (when different from state label) */}
|
|
4365
|
+
{sq.reason && sq.reason !== stateLabel && (
|
|
4366
|
+
<div className={clsx(
|
|
4367
|
+
'font-medium',
|
|
4368
|
+
(sq.status === 'waiting' || sq.status === 'terminated') ? 'text-red-400' : 'text-theme-text-secondary'
|
|
4369
|
+
)}>{sq.reason}</div>
|
|
4370
|
+
)}
|
|
4371
|
+
{/* Message — truncated for tooltip */}
|
|
4372
|
+
{sq.message && (
|
|
4373
|
+
<div className="text-theme-text-secondary text-[11px] leading-tight">
|
|
4374
|
+
{sq.message.length > 120 ? sq.message.slice(0, 120) + '...' : sq.message}
|
|
4375
|
+
</div>
|
|
4376
|
+
)}
|
|
4377
|
+
{/* Exit code + uptime/duration on same line */}
|
|
4378
|
+
{(sq.exitCode !== undefined && sq.status !== 'ready' && sq.status !== 'running') || uptime || duration ? (
|
|
4379
|
+
<div className="text-theme-text-tertiary flex items-center gap-1.5">
|
|
4380
|
+
{sq.exitCode !== undefined && sq.status !== 'ready' && sq.status !== 'running' && (
|
|
4381
|
+
<span className={sq.exitCode !== 0 ? 'text-red-400' : ''}>exit {sq.exitCode}</span>
|
|
4382
|
+
)}
|
|
4383
|
+
{uptime && <span>up {uptime.replace(' ago', '')}</span>}
|
|
4384
|
+
{duration && <span>ran {duration}</span>}
|
|
4385
|
+
</div>
|
|
4386
|
+
) : null}
|
|
4387
|
+
{/* Restarts + last crash info */}
|
|
4388
|
+
{sq.restarts > 0 && (
|
|
4389
|
+
<div className={clsx('border-t border-theme-border/50 pt-1 space-y-0.5', restartColor)}>
|
|
4390
|
+
<div className="flex items-center gap-1.5">
|
|
4391
|
+
<span>{sq.restarts} restart{sq.restarts !== 1 ? 's' : ''}</span>
|
|
4392
|
+
{lt?.finishedAt && <span className="text-theme-text-tertiary">· last {timeAgo(lt.finishedAt)}</span>}
|
|
4393
|
+
</div>
|
|
4394
|
+
{lt?.reason && (
|
|
4395
|
+
<div className="text-theme-text-tertiary">
|
|
4396
|
+
{lt.reason}{lt.exitCode !== undefined && lt.exitCode !== 0 ? ` (exit ${lt.exitCode})` : ''}
|
|
4397
|
+
</div>
|
|
4398
|
+
)}
|
|
4399
|
+
</div>
|
|
4400
|
+
)}
|
|
4401
|
+
</div>
|
|
4402
|
+
)
|
|
4403
|
+
return (
|
|
4404
|
+
<React.Fragment key={i}>
|
|
4405
|
+
{showSeparator && <div className="w-px h-3 bg-theme-text-tertiary/40 mx-0.5" />}
|
|
4406
|
+
<Tooltip content={tooltipContent} className="whitespace-normal max-w-xs">
|
|
4407
|
+
<div className={clsx('w-2.5 h-2.5 rounded-sm', bgClass, ringClass)} />
|
|
4408
|
+
</Tooltip>
|
|
4409
|
+
</React.Fragment>
|
|
4410
|
+
)
|
|
4411
|
+
})}
|
|
4412
|
+
</div>
|
|
4301
4413
|
)
|
|
4302
4414
|
}
|
|
4303
4415
|
case 'status': {
|