@skyhook-io/k8s-ui 1.7.12 → 1.7.14

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 +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/audit/index.ts +1 -1
  10. package/src/components/checks/ChecksView.tsx +269 -142
  11. package/src/components/checks/index.ts +14 -1
  12. package/src/components/issues/IssuesView.tsx +56 -17
  13. package/src/components/issues/index.ts +2 -2
  14. package/src/components/issues/issues.test.ts +7 -0
  15. package/src/components/issues/types.ts +15 -5
  16. package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
  17. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  18. package/src/components/shared/DetailShell.tsx +14 -7
  19. package/src/components/shared/EditableYamlView.tsx +37 -17
  20. package/src/components/shared/ResourceActionsBar.tsx +6 -6
  21. package/src/components/timeline/TimelineList.tsx +3 -32
  22. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  23. package/src/components/topology/K8sResourceNode.tsx +26 -5
  24. package/src/components/topology/TopologyGraph.tsx +102 -3
  25. package/src/components/topology/layout.ts +36 -11
  26. package/src/components/ui/CenteredEmpty.tsx +27 -0
  27. package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -2
  28. package/src/components/ui/SearchBox.tsx +85 -0
  29. package/src/components/ui/drawer-components.tsx +8 -0
  30. package/src/components/ui/index.ts +1 -0
  31. package/src/components/workload/WorkloadView.tsx +170 -36
  32. package/src/components/workload/index.ts +1 -1
  33. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  34. package/src/index.ts +4 -0
  35. package/src/utils/applications.test.ts +207 -0
  36. package/src/utils/applications.ts +674 -0
  37. package/src/utils/format.ts +11 -0
  38. package/src/utils/index.ts +2 -0
  39. package/src/utils/topology-neighborhood.test.ts +185 -0
  40. package/src/utils/topology-neighborhood.ts +262 -0
  41. package/src/utils/workload-colors.ts +36 -0
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
2
  import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
3
3
  import { ClusterName, EmptyState } from '../ui';
4
4
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
@@ -90,25 +90,48 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
90
90
  );
91
91
  }
92
92
 
93
- function IssueRow({
94
- issue,
95
- clusterLabel,
96
- open,
97
- onToggle,
98
- resourceHref,
99
- onResourceClick,
100
- }: {
93
+ export interface IssueRowSlotContext {
94
+ issue: Issue;
95
+ open: boolean;
96
+ }
97
+
98
+ export interface IssueRowProps {
101
99
  issue: Issue;
102
100
  clusterLabel?: (issue: Issue) => string | undefined;
103
101
  open: boolean;
104
102
  onToggle: () => void;
105
103
  resourceHref?: (ref: IssueResourceRef) => string;
106
104
  onResourceClick?: (ref: IssueResourceRef) => void;
107
- }) {
105
+ as?: 'li' | 'div';
106
+ className?: string;
107
+ dimmed?: boolean;
108
+ renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
109
+ renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
110
+ renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
111
+ ResourceLinkIcon?: ComponentType<{ className?: string }>;
112
+ }
113
+
114
+ export function IssueRow({
115
+ issue,
116
+ clusterLabel,
117
+ open,
118
+ onToggle,
119
+ resourceHref,
120
+ onResourceClick,
121
+ as = 'li',
122
+ className,
123
+ dimmed,
124
+ renderBadges,
125
+ renderMeta,
126
+ renderActions,
127
+ ResourceLinkIcon = ExternalLink,
128
+ }: IssueRowProps) {
108
129
  const cluster = clusterLabel?.(issue);
109
130
  const affected = affectedSummary(issue.affected);
110
131
  const { headline } = issueMessageParts(issue);
111
132
  const [renderDetails, setRenderDetails] = useState(open);
133
+ const Container = as;
134
+ const slotCtx = { issue, open };
112
135
 
113
136
  useEffect(() => {
114
137
  if (open) {
@@ -122,8 +145,12 @@ function IssueRow({
122
145
  }, [open, renderDetails]);
123
146
 
124
147
  return (
125
- <li
126
- className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm"
148
+ <Container
149
+ className={[
150
+ 'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
151
+ dimmed ? 'opacity-60' : '',
152
+ className ?? '',
153
+ ].filter(Boolean).join(' ')}
127
154
  style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
128
155
  >
129
156
  {/* The whole header is the single toggle target — chevron is just the
@@ -149,6 +176,7 @@ function IssueRow({
149
176
  <div className="flex min-w-0 items-baseline gap-2">
150
177
  <span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
151
178
  <span className={`badge-sm shrink-0 self-center text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
179
+ {renderBadges?.(slotCtx)}
152
180
  {/* The detector reason/message rides the title row so the most
153
181
  useful triage signal is visible without expanding — it fills
154
182
  the otherwise-empty band between the title and the severity
@@ -180,6 +208,7 @@ function IssueRow({
180
208
  <span className="shrink-0 tabular-nums">{affected}</span>
181
209
  </>
182
210
  ) : null}
211
+ {renderMeta?.(slotCtx)}
183
212
  </div>
184
213
  </div>
185
214
 
@@ -203,6 +232,7 @@ function IssueRow({
203
232
  <span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
204
233
  {ISSUE_SEVERITY_LABEL[issue.severity]}
205
234
  </span>
235
+ {renderActions?.(slotCtx)}
206
236
  </div>
207
237
 
208
238
  {renderDetails ? (
@@ -218,16 +248,16 @@ function IssueRow({
218
248
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
219
249
  <div className="flex flex-col gap-4">
220
250
  <Diagnosis issue={issue} />
221
- <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
251
+ <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
222
252
  <div className="border-t border-theme-border/70 pt-3">
223
- <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
253
+ <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
224
254
  </div>
225
255
  </div>
226
256
  </div>
227
257
  </div>
228
258
  </div>
229
259
  ) : null}
230
- </li>
260
+ </Container>
231
261
  );
232
262
  }
233
263
 
@@ -316,10 +346,12 @@ function DiagnosticContext({
316
346
  issue,
317
347
  resourceHref,
318
348
  onResourceClick,
349
+ ResourceLinkIcon,
319
350
  }: {
320
351
  issue: Issue;
321
352
  resourceHref?: (ref: IssueResourceRef) => string;
322
353
  onResourceClick?: (ref: IssueResourceRef) => void;
354
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
323
355
  }) {
324
356
  const ctx = issue.diagnostic_context;
325
357
  const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
@@ -347,6 +379,7 @@ function DiagnosticContext({
347
379
  refForLink={memberRef(issue, related.ref)}
348
380
  resourceHref={resourceHref}
349
381
  onResourceClick={onResourceClick}
382
+ ResourceLinkIcon={ResourceLinkIcon}
350
383
  />
351
384
  ))}
352
385
  </ul>
@@ -359,6 +392,7 @@ function DiagnosticContext({
359
392
  refForLink={memberRef(issue, ref)}
360
393
  resourceHref={resourceHref}
361
394
  onResourceClick={onResourceClick}
395
+ ResourceLinkIcon={ResourceLinkIcon}
362
396
  />
363
397
  ))}
364
398
  </ul>
@@ -424,10 +458,12 @@ function AffectedResources({
424
458
  issue,
425
459
  resourceHref,
426
460
  onResourceClick,
461
+ ResourceLinkIcon,
427
462
  }: {
428
463
  issue: Issue;
429
464
  resourceHref?: (ref: IssueResourceRef) => string;
430
465
  onResourceClick?: (ref: IssueResourceRef) => void;
466
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
431
467
  }) {
432
468
  const members = issue.members ?? [];
433
469
  // count is the backend fan-out size (members, subject excluded — see
@@ -439,7 +475,7 @@ function AffectedResources({
439
475
  first deep-link; members (the folded pods) follow. ResourceLine emits
440
476
  an <li>, so it needs a list parent of its own. */}
441
477
  <ul className="flex flex-col gap-px">
442
- <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
478
+ <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
443
479
  </ul>
444
480
  {members.length > 0 && (
445
481
  <>
@@ -453,6 +489,7 @@ function AffectedResources({
453
489
  refForLink={memberRef(issue, m)}
454
490
  resourceHref={resourceHref}
455
491
  onResourceClick={onResourceClick}
492
+ ResourceLinkIcon={ResourceLinkIcon}
456
493
  />
457
494
  ))}
458
495
  </ul>
@@ -472,11 +509,13 @@ function ResourceLine({
472
509
  refForLink,
473
510
  resourceHref,
474
511
  onResourceClick,
512
+ ResourceLinkIcon,
475
513
  }: {
476
514
  label?: string;
477
515
  refForLink: IssueResourceRef;
478
516
  resourceHref?: (ref: IssueResourceRef) => string;
479
517
  onResourceClick?: (ref: IssueResourceRef) => void;
518
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
480
519
  }) {
481
520
  const r = refForLink;
482
521
  const linkable = !!(onResourceClick || resourceHref);
@@ -488,7 +527,7 @@ function ResourceLine({
488
527
  {r.namespace ? `${r.namespace} / ` : ''}
489
528
  {r.name}
490
529
  </span>
491
- {linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
530
+ {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
492
531
  </>
493
532
  );
494
533
  const cls = 'group/r flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
@@ -2,8 +2,8 @@
2
2
  // module-internal and don't collide at the top-level barrel with the Checks
3
3
  // queue's identically-named helpers when both land. Issue-prefixed public
4
4
  // names are safe to surface.
5
- export { IssuesView } from './IssuesView';
6
- export type { IssuesViewProps } from './IssuesView';
5
+ export { IssueRow, IssuesView } from './IssuesView';
6
+ export type { IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
7
7
  export {
8
8
  ISSUE_SEVERITIES,
9
9
  ISSUE_SEVERITY_RANK,
@@ -28,6 +28,13 @@ describe('compareIssues', () => {
28
28
  expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
29
29
  })
30
30
 
31
+ it('orders direct startup blockers before generic problem rows at same severity', () => {
32
+ const generic = mk({ id: 'generic', source: 'problem', first_seen: '2026-05-01T00:00:00Z' })
33
+ const blocker = mk({ id: 'blocker', source: 'scheduling', first_seen: '2026-01-01T00:00:00Z' })
34
+ const missing = mk({ id: 'missing', source: 'missing_ref', first_seen: '2026-03-01T00:00:00Z' })
35
+ expect([generic, blocker, missing].sort(compareIssues).map((i) => i.id)).toEqual(['blocker', 'missing', 'generic'])
36
+ })
37
+
31
38
  it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
32
39
  // Two same-severity rows, same first_seen — order is the deterministic name tiebreak.
33
40
  const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
@@ -24,6 +24,13 @@ export const ISSUE_SEVERITY_RANK: Record<IssueSeverity, number> = {
24
24
  warning: 1,
25
25
  };
26
26
 
27
+ const ISSUE_SOURCE_RANK: Record<string, number> = {
28
+ scheduling: 4,
29
+ missing_ref: 3,
30
+ condition: 2,
31
+ problem: 1,
32
+ };
33
+
27
34
  export function isIssueSeverity(s: string): s is IssueSeverity {
28
35
  return s === 'critical' || s === 'warning';
29
36
  }
@@ -209,15 +216,18 @@ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResource
209
216
 
210
217
  /**
211
218
  * compareIssues is the queue's stable sort order (extracted from IssuesView so
212
- * it can be unit-tested). Severity first (critical before warning), then ONSET
213
- * — first_seen DESC, deliberately NOT last_seen: last_seen bumps to compose-time
214
- * on every poll, so sorting by it would reshuffle same-severity rows on each
215
- * refetch. The remaining keys (cluster → namespace → name → id) are a fully
216
- * deterministic tiebreak so the order never churns under auto-refresh.
219
+ * it can be unit-tested). Severity first (critical before warning), then
220
+ * direct-blocker source priority, then ONSET — first_seen DESC, deliberately
221
+ * NOT last_seen: last_seen bumps to compose-time on every poll, so sorting by
222
+ * it would reshuffle same-severity rows on each refetch. The remaining keys
223
+ * (cluster → namespace → name → id) are a fully deterministic tiebreak so the
224
+ * order never churns under auto-refresh.
217
225
  */
218
226
  export function compareIssues(a: Issue, b: Issue): number {
219
227
  const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
220
228
  if (r !== 0) return r;
229
+ const sr = (ISSUE_SOURCE_RANK[b.source] ?? 0) - (ISSUE_SOURCE_RANK[a.source] ?? 0);
230
+ if (sr !== 0) return sr;
221
231
  const fa = a.first_seen ?? '';
222
232
  const fb = b.first_seen ?? '';
223
233
  if (fa !== fb) return fb.localeCompare(fa);
@@ -90,18 +90,21 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
90
90
  setFetchError(null)
91
91
  try {
92
92
  const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds })
93
+ // Older backends marshal empty results as null rather than [].
94
+ const resultPods = result.pods ?? []
95
+ const resultLogs = result.logs ?? []
93
96
 
94
- setPods(result.pods)
97
+ setPods(resultPods)
95
98
 
96
- if (!podsInitialized.current && result.pods.length > 0) {
99
+ if (!podsInitialized.current && resultPods.length > 0) {
97
100
  podsInitialized.current = true
98
- setSelectedPods(new Set(result.pods.map(p => p.name)))
101
+ setSelectedPods(new Set(resultPods.map(p => p.name)))
99
102
  }
100
103
 
101
104
  const indexByPod = new Map<string, number>()
102
- result.pods.forEach((pod, i) => indexByPod.set(pod.name, i))
105
+ resultPods.forEach((pod, i) => indexByPod.set(pod.name, i))
103
106
 
104
- set(result.logs.map(log => ({
107
+ set(resultLogs.map(log => ({
105
108
  timestamp: log.timestamp,
106
109
  content: log.content,
107
110
  container: log.container,
@@ -174,12 +174,13 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
174
174
 
175
175
  return (
176
176
  <>
177
- {/* Scaling in progress banner */}
177
+ {/* Scaling in progress banner — amber: replicas short of desired is an
178
+ attention state, not an info note (it may be a stuck rollout). */}
178
179
  {(scaledTo !== null || progressMessage) && !hasProblems && (
179
- <div className="mb-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
180
+ <div className="mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
180
181
  <div className="flex items-center gap-2">
181
- <Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />
182
- <div className="text-sm text-blue-300">
182
+ <Loader2 className="w-4 h-4 text-amber-500 animate-spin shrink-0" />
183
+ <div className="text-sm text-amber-700 dark:text-amber-300">
183
184
  {progressMessage || `Scaling to ${scaledTo} replicas...`}
184
185
  </div>
185
186
  </div>
@@ -30,6 +30,8 @@ export interface DetailShellProps<TId extends string = string> {
30
30
  onTabChange: (id: TId) => void
31
31
  tabStripEnd?: ReactNode
32
32
  overlay?: ReactNode
33
+ /** Hide breadcrumb/identity/header actions when a host page already owns that chrome. */
34
+ compactHeader?: boolean
33
35
  children: ReactNode
34
36
  }
35
37
 
@@ -44,6 +46,7 @@ export function DetailShell<TId extends string = string>({
44
46
  onTabChange,
45
47
  tabStripEnd,
46
48
  overlay,
49
+ compactHeader = false,
47
50
  children,
48
51
  }: DetailShellProps<TId>) {
49
52
  const visibleTabs = tabs.filter((t) => !t.hidden)
@@ -52,15 +55,19 @@ export function DetailShell<TId extends string = string>({
52
55
  <div className="flex flex-col h-full w-full bg-theme-surface">
53
56
  {/* Header */}
54
57
  <div className="shrink-0 border-b border-theme-border bg-theme-surface">
55
- {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
56
- <div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
57
- {nav}
58
- <div className="flex-1 min-w-0">{identity}</div>
59
- {headerActions}
60
- </div>
58
+ {!compactHeader && (
59
+ <>
60
+ {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
61
+ <div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
62
+ {nav}
63
+ <div className="flex-1 min-w-0">{identity}</div>
64
+ {headerActions}
65
+ </div>
66
+ </>
67
+ )}
61
68
 
62
69
  {/* Tabs (left) + scope controls / actions (right) */}
63
- <div className="px-6 flex items-center border-t border-theme-border">
70
+ <div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
64
71
  <div className="flex gap-1" role="tablist">
65
72
  {visibleTabs.map((t) => (
66
73
  <DetailShellTabButton key={t.id} active={activeTab === t.id} onClick={() => onTabChange(t.id)}>
@@ -66,19 +66,27 @@ function formatSaveError(error: string): { summary: string; details?: string } {
66
66
  const errorPart = parts[1]?.trim() || ''
67
67
 
68
68
  if (errorPart.includes('Forbidden:')) {
69
- const forbiddenMatch = errorPart.match(/([^:]+):\s*Forbidden:\s*([^.{]+)/)
70
- if (forbiddenMatch) {
69
+ const forbiddenAt = errorPart.indexOf(': Forbidden:')
70
+ if (forbiddenAt > 0) {
71
+ const target = errorPart.slice(0, forbiddenAt).trim()
72
+ const messageStart = forbiddenAt + ': Forbidden:'.length
73
+ const dotAt = errorPart.indexOf('.', messageStart)
74
+ const braceAt = errorPart.indexOf('{', messageStart)
75
+ const endCandidates = [dotAt, braceAt].filter((i) => i >= 0)
76
+ const messageEnd = endCandidates.length ? Math.min(...endCandidates) : errorPart.length
77
+ const message = errorPart.slice(messageStart, messageEnd).trim()
71
78
  return {
72
- summary: `Cannot update ${forbiddenMatch[1]}: ${forbiddenMatch[2].trim()}`,
79
+ summary: `Cannot update ${target}: ${message}`,
73
80
  details: error.length > 200 ? error : undefined
74
81
  }
75
82
  }
76
83
  }
77
84
 
78
- const summaryMatch = errorPart.match(/^([^{]+)/)
79
- if (summaryMatch) {
85
+ const braceAt = errorPart.indexOf('{')
86
+ const summary = (braceAt >= 0 ? errorPart.slice(0, braceAt) : errorPart).trim()
87
+ if (summary) {
80
88
  return {
81
- summary: summaryMatch[1].trim(),
89
+ summary,
82
90
  details: error.length > 200 ? error : undefined
83
91
  }
84
92
  }
@@ -111,6 +119,8 @@ interface EditableYamlViewProps {
111
119
  data: any
112
120
  onCopy: (text: string) => void
113
121
  copied: boolean
122
+ /** Hide edit affordances when the host surface is read-only. */
123
+ readOnly?: boolean
114
124
  /** Called after a successful save so the parent can refetch */
115
125
  onSaved?: () => void
116
126
  /** Save handler — injected by the platform wrapper */
@@ -128,13 +138,13 @@ interface EditableYamlViewProps {
128
138
  onDownload?: (content: string, mime: string, filename: string) => void
129
139
  }
130
140
 
131
- export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
141
+ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = false, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
132
142
  const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
133
143
 
134
144
  // Restore draft from sessionStorage (e.g., after session-expiry redirect).
135
145
  // All sessionStorage calls are wrapped in try-catch — storage can throw
136
146
  // QuotaExceededError or be blocked by browser security policies.
137
- const savedDraft = useRef(safeSessionGet(draftKey))
147
+ const savedDraft = useRef(readOnly ? null : safeSessionGet(draftKey))
138
148
  const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
139
149
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
140
150
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
@@ -153,12 +163,19 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
153
163
 
154
164
  // Autosave draft to sessionStorage while editing (best-effort)
155
165
  useEffect(() => {
166
+ if (readOnly) {
167
+ setIsEditing(false)
168
+ setEditedYaml('')
169
+ setYamlErrors([])
170
+ safeSessionRemove(draftKey)
171
+ return
172
+ }
156
173
  if (isEditing && editedYaml) {
157
174
  safeSessionSet(draftKey, editedYaml)
158
175
  } else {
159
176
  safeSessionRemove(draftKey)
160
177
  }
161
- }, [isEditing, editedYaml, draftKey])
178
+ }, [isEditing, editedYaml, draftKey, readOnly])
162
179
 
163
180
  const handleDownload = useCallback(() => {
164
181
  const yaml = resourceToYaml(data)
@@ -170,10 +187,11 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
170
187
  }, [data, resource.kind, resource.name, onDownload])
171
188
 
172
189
  const handleStartEdit = useCallback(() => {
190
+ if (readOnly) return
173
191
  setEditedYaml(resourceToYaml(data))
174
192
  setYamlErrors([])
175
193
  setIsEditing(true)
176
- }, [data])
194
+ }, [data, readOnly])
177
195
 
178
196
  const handleCancelEdit = useCallback(() => {
179
197
  setIsEditing(false)
@@ -345,13 +363,15 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
345
363
  <div className="flex items-center justify-between mb-2">
346
364
  <span className="text-sm font-medium text-theme-text-secondary">YAML</span>
347
365
  <div className="flex items-center gap-2">
348
- <button
349
- onClick={handleStartEdit}
350
- className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
351
- >
352
- <Pencil className="w-3.5 h-3.5" />
353
- Edit
354
- </button>
366
+ {!readOnly && (
367
+ <button
368
+ onClick={handleStartEdit}
369
+ className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
370
+ >
371
+ <Pencil className="w-3.5 h-3.5" />
372
+ Edit
373
+ </button>
374
+ )}
355
375
  <button
356
376
  onClick={() => onCopy(yamlContent)}
357
377
  className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
@@ -23,7 +23,7 @@ import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDele
23
23
  import { ConfirmDialog } from '../ui/ConfirmDialog'
24
24
  import { DialogPortal } from '../ui/DialogPortal'
25
25
  import type { SelectedResource, WorkloadRevision } from '../../types'
26
- import { formatKindName } from '../ui/drawer-components'
26
+ import { displayKindName } from '../ui/drawer-components'
27
27
  import { getDefaultContainerName } from '../resources/resource-utils'
28
28
 
29
29
  // ============================================================================
@@ -517,20 +517,20 @@ export function ResourceActionsBar({
517
517
  <Tooltip
518
518
  content={
519
519
  onCompareTo && onCompareAcrossClusters
520
- ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
520
+ ? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
521
521
  : onCompareAcrossClusters
522
522
  ? `Compare across clusters`
523
- : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
523
+ : `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
524
524
  }
525
525
  >
526
526
  <button
527
527
  onClick={onCompareTo ?? onCompareAcrossClusters}
528
528
  aria-label={
529
529
  onCompareTo && onCompareAcrossClusters
530
- ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
530
+ ? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
531
531
  : onCompareAcrossClusters
532
532
  ? `Compare across clusters`
533
- : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
533
+ : `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
534
534
  }
535
535
  className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-theme-text-primary hover:bg-theme-elevated transition-colors flex items-center"
536
536
  >
@@ -577,7 +577,7 @@ export function ResourceActionsBar({
577
577
  onClose={() => setShowDeleteConfirm(false)}
578
578
  onConfirm={handleDeleteConfirm}
579
579
  resourceName={resource.name}
580
- resourceKind={formatKindName(resource.kind)}
580
+ resourceKind={displayKindName(resource.kind, data?.kind)}
581
581
  namespaceName={resource.namespace}
582
582
  isLoading={isDeleting ?? false}
583
583
  cascadeDependents={cascadeDependents}
@@ -1,11 +1,11 @@
1
- import { useState, useMemo, useRef, useEffect } from 'react'
1
+ import { useState, useMemo, useEffect } from 'react'
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
3
  import { PaneLoader } from '../ui/PaneLoader'
4
+ import { SearchBox } from '../ui/SearchBox'
4
5
  import {
5
6
  AlertCircle,
6
7
  CheckCircle,
7
8
  Clock,
8
- Search,
9
9
  RefreshCw,
10
10
  ChevronRight,
11
11
  Filter,
@@ -24,7 +24,6 @@ import { ResourceRefBadge } from '../ui/drawer-components'
24
24
  import type { NavigateToResource } from '../../utils/navigation'
25
25
  import { kindToPlural, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
26
26
  import { pluralize } from '../../utils/pluralize'
27
- import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
28
27
 
29
28
  /** Format resource age (e.g., "3d", "5h", "10m") */
30
29
  function formatResourceAge(createdAt: string): string {
@@ -87,29 +86,11 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
87
86
  const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
88
87
  const [kindFilter, setKindFilter] = useState<string>('')
89
88
  const [expandedItem, setExpandedItem] = useState<string | null>(null)
90
- const searchInputRef = useRef<HTMLInputElement>(null)
91
89
 
92
90
  useEffect(() => {
93
91
  onQueryChange?.({ timeRange, kind: kindFilter || undefined })
94
92
  }, [timeRange, kindFilter, onQueryChange])
95
93
 
96
- // Keyboard shortcut: / to focus search
97
- useRegisterShortcut({
98
- id: 'timeline-list-search',
99
- keys: '/',
100
- description: 'Focus search',
101
- category: 'Search',
102
- scope: 'timeline',
103
- handler: () => searchInputRef.current?.focus(),
104
- })
105
- useRegisterShortcut({
106
- id: 'timeline-list-escape',
107
- keys: 'Escape',
108
- description: 'Blur search',
109
- category: 'Search',
110
- scope: 'timeline',
111
- handler: () => searchInputRef.current?.blur(),
112
- })
113
94
 
114
95
  const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(onRefresh ?? (() => {}))
115
96
 
@@ -278,17 +259,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
278
259
  {/* Toolbar */}
279
260
  <div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 flex-wrap">
280
261
  {/* Search */}
281
- <div className="flex-1 relative min-w-[200px]">
282
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
283
- <input
284
- ref={searchInputRef}
285
- type="text"
286
- placeholder="Search... (press /)"
287
- value={searchTerm}
288
- onChange={(e) => setSearchTerm(e.target.value)}
289
- className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
290
- />
291
- </div>
262
+ <SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="timeline-list-search" className="flex-1 min-w-[200px] max-w-md" />
292
263
 
293
264
  {/* Activity type filter */}
294
265
  <div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
@@ -7,7 +7,6 @@ import {
7
7
  ZoomIn,
8
8
  ZoomOut,
9
9
  ChevronRight,
10
- Search,
11
10
  X,
12
11
  List,
13
12
  GanttChart,
@@ -27,6 +26,7 @@ import type { TimelineEvent, Topology } from '../../types'
27
26
  import type { NavigateToResource } from '../../utils/navigation'
28
27
  import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
29
28
  import { PaneLoader } from '../ui/PaneLoader'
29
+ import { SearchBox } from '../ui/SearchBox'
30
30
  import { pluralize } from '../../utils/pluralize'
31
31
  import { gitOpsRouteForKind } from '../../utils/gitops-route'
32
32
  import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
@@ -194,7 +194,6 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
194
194
  onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
195
195
  }, [onNavigatePath, onResourceClick])
196
196
  const containerRef = useRef<HTMLDivElement>(null)
197
- const searchInputRef = useRef<HTMLInputElement>(null)
198
197
  const [zoom, setZoom] = useState(1)
199
198
  const [panOffset, setPanOffset] = useState(0)
200
199
  const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null)
@@ -241,23 +240,14 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
241
240
  }, [events, hasAutoZoomed])
242
241
 
243
242
  // Keyboard shortcuts
244
- useRegisterShortcut({
245
- id: 'swimlane-search',
246
- keys: '/',
247
- description: 'Focus search',
248
- category: 'Search',
249
- scope: 'timeline',
250
- handler: () => searchInputRef.current?.focus(),
251
- })
252
243
  useRegisterShortcut({
253
244
  id: 'swimlane-escape',
254
245
  keys: 'Escape',
255
- description: 'Close detail / blur search',
246
+ description: 'Close event detail',
256
247
  category: 'Timeline',
257
248
  scope: 'timeline',
258
249
  handler: () => {
259
250
  if (selectedEvent) setSelectedEvent(null)
260
- else searchInputRef.current?.blur()
261
251
  },
262
252
  })
263
253
 
@@ -467,25 +457,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
467
457
  <div className="flex items-center justify-between px-4 py-2">
468
458
  <div className="flex items-center gap-4">
469
459
  {/* Search */}
470
- <div className="relative">
471
- <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
472
- <input
473
- ref={searchInputRef}
474
- type="text"
475
- value={searchTerm}
476
- onChange={(e) => setSearchTerm(e.target.value)}
477
- placeholder="Search... (press /)"
478
- className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
479
- />
480
- {searchTerm && (
481
- <button
482
- onClick={() => setSearchTerm('')}
483
- className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-primary"
484
- >
485
- <X className="w-4 h-4" />
486
- </button>
487
- )}
488
- </div>
460
+ <SearchBox value={searchTerm} onChange={setSearchTerm} scope="timeline" shortcutId="swimlane-search" className="w-80" />
489
461
  {/* Zoom controls */}
490
462
  <div className="flex items-center gap-2">
491
463
  <button