@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.
Files changed (41) hide show
  1. package/package.json +6 -2
  2. package/src/components/dock/BottomDock.tsx +14 -15
  3. package/src/components/dock/DockContext.tsx +40 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +241 -0
  5. package/src/components/dock/NodeTerminalTab.tsx +129 -0
  6. package/src/components/dock/TerminalTab.tsx +1 -1
  7. package/src/components/dock/index.ts +2 -0
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
  9. package/src/components/logs/LogCore.tsx +30 -4
  10. package/src/components/logs/StructuredLogLine.tsx +168 -0
  11. package/src/components/logs/index.ts +1 -1
  12. package/src/components/logs/useLogBuffer.ts +16 -10
  13. package/src/components/resources/ResourcesView.tsx +136 -24
  14. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
  15. package/src/components/resources/renderers/PodRenderer.tsx +164 -8
  16. package/src/components/resources/renderers/contour-cells.tsx +48 -0
  17. package/src/components/resources/renderers/index.ts +2 -0
  18. package/src/components/resources/renderers/trivy-shared.tsx +9 -25
  19. package/src/components/resources/resource-utils-contour.ts +34 -0
  20. package/src/components/resources/resource-utils.ts +91 -0
  21. package/src/components/shared/ResourceActionsBar.tsx +135 -2
  22. package/src/components/shared/ResourceRendererDispatch.tsx +23 -5
  23. package/src/components/timeline/shared.tsx +8 -10
  24. package/src/components/topology/K8sResourceNode.tsx +1 -0
  25. package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
  26. package/src/components/topology/layout.ts +1 -0
  27. package/src/components/topology/topology.css +1 -0
  28. package/src/components/ui/CodeViewer.tsx +1 -2
  29. package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
  30. package/src/components/ui/Toast.tsx +5 -5
  31. package/src/components/ui/drawer-components.tsx +28 -22
  32. package/src/components/workload/WorkloadView.tsx +11 -3
  33. package/src/theme/index.ts +3 -0
  34. package/src/theme/tailwind-theme.css +61 -0
  35. package/src/theme/variables.css +121 -0
  36. package/src/types/core.ts +12 -0
  37. package/src/utils/animation.ts +1 -1
  38. package/src/utils/badge-colors.ts +158 -123
  39. package/src/utils/log-format.ts +127 -35
  40. package/src/utils/resource-hierarchy.ts +5 -0
  41. 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 { JsonLogLine } from './JsonLogLine'
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
- <JsonLogLine content={entry.content} level={entry.level} wordWrap={wordWrap} />
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 { JsonLogLine } from './JsonLogLine'
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 `severity` field.
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: Omit<LogEntry, 'id' | 'level' | 'isJson'>) => void
62
- appendBatch: (entries: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => void
63
- set: (entries: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => void
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<Omit<LogEntry, 'id' | 'level' | 'isJson'>[]>([])
74
+ const pendingRef = useRef<RawLogEntry[]>([])
71
75
  const rafRef = useRef<number | null>(null)
72
76
 
73
- const enrichEntry = useCallback((raw: Omit<LogEntry, 'id' | 'level' | 'isJson'>): LogEntry => {
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: isJsonContent(raw.content),
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: Omit<LogEntry, 'id' | 'level' | 'isJson'>) => {
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: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => {
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: Omit<LogEntry, 'id' | 'level' | 'isJson'>[]) => {
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: 'ready', label: 'Ready', width: 'w-16' },
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 'ready':
2445
- // For pods, use ready/total ratio
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 0
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 'ready': {
4287
- const { ready, total } = getPodReadiness(resource)
4288
- const allReady = ready === total && total > 0
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
- <span className={clsx('text-sm font-medium', color)}>
4299
- {ready}/{total}
4300
- </span>
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': {