@skyhook-io/k8s-ui 1.7.10 → 1.7.12

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/components/dock/TerminalTab.tsx +4 -2
  3. package/src/components/gitops/GitOpsTableView.tsx +22 -2
  4. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  5. package/src/components/issues/IssuesView.tsx +169 -17
  6. package/src/components/issues/index.ts +1 -1
  7. package/src/components/issues/issues.test.ts +4 -4
  8. package/src/components/issues/severity.ts +5 -0
  9. package/src/components/issues/types.ts +74 -0
  10. package/src/components/logs/LogCore.tsx +13 -2
  11. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  12. package/src/components/logs/LogsViewer.tsx +66 -10
  13. package/src/components/logs/WorkloadLogsViewer.tsx +60 -8
  14. package/src/components/logs/useLogStream.ts +41 -3
  15. package/src/components/resources/ResourcesView.tsx +550 -52
  16. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  17. package/src/components/resources/get-default-container-name.test.ts +31 -0
  18. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  19. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  20. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  21. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  24. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  25. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  26. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  27. package/src/components/resources/renderers/index.ts +8 -0
  28. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  29. package/src/components/resources/resource-utils-dra.ts +90 -0
  30. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  31. package/src/components/resources/resource-utils.ts +62 -4
  32. package/src/components/shared/CreateResourceDialog.tsx +32 -3
  33. package/src/components/shared/EditableYamlView.tsx +31 -2
  34. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  35. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  36. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  37. package/src/components/ui/ConfirmDialog.tsx +1 -1
  38. package/src/components/ui/drawer-components.tsx +23 -1
  39. package/src/types/core.ts +1 -0
  40. package/src/utils/api-resources.ts +21 -0
  41. package/src/utils/custom-columns.test.ts +111 -0
  42. package/src/utils/custom-columns.ts +49 -0
  43. package/src/utils/extended-resources.test.ts +152 -0
  44. package/src/utils/extended-resources.ts +121 -0
  45. package/src/utils/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.10",
3
+ "version": "1.7.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -285,8 +285,10 @@ export function TerminalTab({
285
285
  <>
286
286
  <div className="text-amber-400 mb-2 text-sm">Shell not available</div>
287
287
  <div className="text-xs text-theme-text-tertiary mb-4 max-w-md">
288
- This container doesn&apos;t have a shell (/bin/sh). This is common with distroless
289
- or minimal container images. You can create a debug container to troubleshoot.
288
+ Container <span className="font-mono text-theme-text-secondary">{selectedContainer}</span> doesn&apos;t
289
+ have a shell (/bin/sh). This is common with distroless or minimal container images
290
+ {containers.length > 1 ? ' — try another container above, or' : '. You can'} create a
291
+ debug container to troubleshoot.
290
292
  </div>
291
293
  <div className="flex gap-2">
292
294
  {createDebugContainerRef.current && (
@@ -201,6 +201,14 @@ export interface GitOpsTableViewProps {
201
201
  // detected" copy. Hub passes "No GitOps resources across the fleet".
202
202
  emptyStateTitle?: string
203
203
  emptyStateBody?: string
204
+ /**
205
+ * Which side the filter rail sits on. Default 'left' (OSS Radar, which
206
+ * has no app sidebar). A host with its own left navigation rail (the
207
+ * Cloud hub) passes 'right' so the GitOps filters don't stack a second
208
+ * column against that nav and instead match the host's other faceted
209
+ * pages. Mobile stacking (filters above the table) is unaffected.
210
+ */
211
+ filtersSide?: 'left' | 'right'
204
212
  /**
205
213
  * Global namespace pick from the host's NamespaceSwitcher. Used to
206
214
  * surface "viewing in namespace: X" context and to power the Clear
@@ -249,6 +257,7 @@ export function GitOpsTableView({
249
257
  onClearNamespaces,
250
258
  onRowAction,
251
259
  pendingRowActions,
260
+ filtersSide = 'left',
252
261
  }: GitOpsTableViewProps) {
253
262
  const searchInputRef = useRef<HTMLInputElement>(null)
254
263
  const [mode, setMode] = useState<GitOpsMode>('applications')
@@ -492,8 +501,13 @@ export function GitOpsTableView({
492
501
  ]
493
502
 
494
503
  return (
495
- <div className="flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col">
504
+ <div
505
+ className={`flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col ${
506
+ filtersSide === 'right' ? 'lg:flex-row-reverse' : ''
507
+ }`}
508
+ >
496
509
  <GitOpsFilterSidebar
510
+ side={filtersSide}
497
511
  mode={mode}
498
512
  onModeChange={setMode}
499
513
  modeCounts={modeCounts}
@@ -707,6 +721,7 @@ export function GitOpsTableView({
707
721
  // =============================================================================
708
722
 
709
723
  function GitOpsFilterSidebar({
724
+ side,
710
725
  mode,
711
726
  onModeChange,
712
727
  modeCounts,
@@ -729,6 +744,7 @@ function GitOpsFilterSidebar({
729
744
  onToggleNamespace,
730
745
  onClear,
731
746
  }: {
747
+ side: 'left' | 'right'
732
748
  mode: GitOpsMode
733
749
  onModeChange: (mode: GitOpsMode) => void
734
750
  modeCounts: Record<GitOpsMode, number>
@@ -752,7 +768,11 @@ function GitOpsFilterSidebar({
752
768
  onClear: () => void
753
769
  }) {
754
770
  return (
755
- <aside className="flex w-72 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b max-lg:border-r-0">
771
+ <aside
772
+ className={`flex w-72 shrink-0 flex-col overflow-hidden border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b ${
773
+ side === 'right' ? 'border-l max-lg:border-l-0' : 'border-r max-lg:border-r-0'
774
+ }`}
775
+ >
756
776
  <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
757
777
  <span className="text-sm font-medium text-theme-text-secondary">GitOps Filters</span>
758
778
  <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
@@ -626,6 +626,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
626
626
  <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
627
627
  </div>
628
628
  <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
629
+ {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
629
630
  {issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
630
631
  </div>
631
632
  {actionable && ref && (
@@ -49,7 +49,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
49
49
  // queue stays scannable and you never lose your place to a wall of expansions.
50
50
  const [openId, setOpenId] = useState<string | null>(null);
51
51
 
52
- // Stable order keyed on severity → onset → identity (see compareIssues), so
52
+ // Stable order keyed on severity → observed age → identity (see compareIssues), so
53
53
  // the queue doesn't reshuffle under the host's auto-refresh.
54
54
  const sorted = useMemo(() => [...issues].sort(compareIssues), [issues]);
55
55
 
@@ -183,9 +183,10 @@ function IssueRow({
183
183
  </div>
184
184
  </div>
185
185
 
186
- {/* Onset age (first_seen, fixed for the issue's life) is the chronic-vs-
187
- acute signal "broken 2m" reads very differently from "broken 5d".
188
- Keyed on first_seen, not last_seen, so it doesn't churn on refresh. */}
186
+ {/* Age chip: chronic-vs-acute signal. When issue_timing is
187
+ started_at_resource_creation, swap the raw age for "since deploy"
188
+ the raw age is misleading (it reads as urgency, but this issue has
189
+ been present since the resource was first created). */}
189
190
  {issue.first_seen ? (
190
191
  <time
191
192
  dateTime={issue.first_seen}
@@ -193,7 +194,9 @@ function IssueRow({
193
194
  className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
194
195
  >
195
196
  <Clock className="h-3 w-3" aria-hidden />
196
- {formatCompactAge(issue.first_seen)}
197
+ {issue.issue_timing === 'started_at_resource_creation'
198
+ ? 'since deploy'
199
+ : formatCompactAge(issue.first_seen)}
197
200
  </time>
198
201
  ) : null}
199
202
 
@@ -215,6 +218,7 @@ function IssueRow({
215
218
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
216
219
  <div className="flex flex-col gap-4">
217
220
  <Diagnosis issue={issue} />
221
+ <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
218
222
  <div className="border-t border-theme-border/70 pt-3">
219
223
  <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
220
224
  </div>
@@ -237,33 +241,181 @@ function Diagnosis({ issue }: { issue: Issue }) {
237
241
  .join(' · ')
238
242
  : null;
239
243
  const { headline, detail } = issueMessageParts(issue);
244
+ // When the issue carries a parsed plain-English cause (GitOps controller
245
+ // errors), lead with it — the raw controller message is long and buries the
246
+ // useful part, and the detector reason ("OperationFailed") just repeats the
247
+ // category chip. The raw message is kept below as de-emphasized detail.
248
+ const rawMessage = [headline, detail].filter(Boolean).join(' ');
240
249
  return (
241
250
  <section className="flex flex-col gap-1">
242
251
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
243
- <p className="text-sm leading-relaxed text-theme-text-primary">
244
- <span className="font-medium">{issue.reason}</span>
245
- {headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
246
- </p>
247
- {/* Raw source string kept as secondary detail only when we showed a
248
- normalized headline above (e.g. the verbose containerd image-pull
249
- error) — so the precise message is never lost, just de-emphasized. */}
250
- {detail ? <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p> : null}
252
+ {issue.cause ? (
253
+ <p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
254
+ ) : (
255
+ <p className="text-sm leading-relaxed text-theme-text-primary">
256
+ <span className="font-medium">{issue.reason}</span>
257
+ {headline ? <span className="text-theme-text-secondary"> {headline}</span> : null}
258
+ </p>
259
+ )}
260
+ {/* For non-cause issues whose message was normalized to a short headline
261
+ (e.g. image-pull), keep the precise raw kubelet/containerd detail. The
262
+ cause branch shows its own raw block below. */}
263
+ {!issue.cause && detail ? (
264
+ <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p>
265
+ ) : null}
266
+ {issue.action ? (
267
+ <p className="text-sm leading-relaxed text-theme-text-secondary">
268
+ <span className="font-medium text-theme-text-primary">Next step: </span>
269
+ {issue.action}
270
+ </p>
271
+ ) : null}
272
+ {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
273
+ <p className="text-xs text-theme-text-tertiary">
274
+ Suggested fix: create namespace <code className="rounded bg-theme-elevated px-1 font-mono">{issue.remediation_target}</code> — apply it from the GitOps detail page.
275
+ </p>
276
+ ) : null}
277
+ {issue.stuck || issue.operation_retry_count ? (
278
+ <p className="text-xs text-theme-text-tertiary tabular-nums">
279
+ {issue.stuck ? 'Stuck' : 'Retrying'}
280
+ {issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
281
+ </p>
282
+ ) : null}
283
+ {/* Raw controller message, de-emphasized — shown below the parsed cause so
284
+ the precise error (URLs, resource names) is available without leading. */}
285
+ {issue.cause && rawMessage ? (
286
+ <p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
287
+ ) : null}
251
288
  {crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
289
+ {issue.change_context ? (
290
+ <p className="text-xs text-theme-text-tertiary">
291
+ {changeContextText(issue.change_context)}
292
+ </p>
293
+ ) : null}
252
294
  {issue.first_seen ? (
253
295
  <p className="text-xs text-theme-text-tertiary tabular-nums">
254
- Started {formatRelativeAgeTime(issue.first_seen)}
255
- {issue.last_seen ? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}` : ''}
296
+ {issue.issue_timing === 'started_at_resource_creation'
297
+ ? 'Present since deployment'
298
+ : issue.issue_timing === 'started_after_resource_was_healthy'
299
+ ? `Started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
300
+ : `Started ${formatRelativeAgeTime(issue.first_seen)}`}
301
+ {issue.last_seen && issue.issue_timing !== 'started_at_resource_creation'
302
+ ? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}`
303
+ : ''}
256
304
  </p>
257
305
  ) : null}
258
306
  </section>
259
307
  );
260
308
  }
261
309
 
262
- // Native-tooltip detail for the collapsed-row age chip: absolute onset + last-seen
310
+ function changeContextText(change: NonNullable<Issue['change_context']>): string {
311
+ const parts = [change.when ? `Changed ${change.when} ago` : 'Changed', change.what ? change.what.replace(/_/g, ' ') : null, change.evidence].filter(Boolean);
312
+ return parts.join(' · ');
313
+ }
314
+
315
+ function DiagnosticContext({
316
+ issue,
317
+ resourceHref,
318
+ onResourceClick,
319
+ }: {
320
+ issue: Issue;
321
+ resourceHref?: (ref: IssueResourceRef) => string;
322
+ onResourceClick?: (ref: IssueResourceRef) => void;
323
+ }) {
324
+ const ctx = issue.diagnostic_context;
325
+ const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
326
+ if (!ctx || facts.length === 0) return null;
327
+
328
+ return (
329
+ <section className="flex flex-col gap-2">
330
+ <div className="flex items-center gap-2">
331
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</h4>
332
+ {ctx.role ? <span className="badge-sm text-[10px] text-theme-text-secondary">{diagnosticRoleLabel(ctx.role)}</span> : null}
333
+ </div>
334
+ <ul className="flex flex-col gap-2">
335
+ {facts.map((fact, idx) => (
336
+ <li key={`${fact.type}-${idx}`} className="flex flex-col gap-1.5 rounded-md border border-theme-border/70 px-2.5 py-2">
337
+ <div className="flex min-w-0 items-baseline gap-2">
338
+ <span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
339
+ {fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
340
+ </div>
341
+ {fact.related_issues?.length ? (
342
+ <ul className="flex flex-col gap-px">
343
+ {fact.related_issues.map((related, relIdx) => (
344
+ <ResourceLine
345
+ key={`${related.ref.group ?? ''}/${related.ref.kind}/${related.ref.namespace ?? ''}/${related.ref.name}#${relIdx}`}
346
+ label="Related"
347
+ refForLink={memberRef(issue, related.ref)}
348
+ resourceHref={resourceHref}
349
+ onResourceClick={onResourceClick}
350
+ />
351
+ ))}
352
+ </ul>
353
+ ) : null}
354
+ {fact.refs?.length ? (
355
+ <ul className="flex flex-col gap-px">
356
+ {fact.refs.map((ref, refIdx) => (
357
+ <ResourceLine
358
+ key={`${ref.group ?? ''}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}#${refIdx}`}
359
+ refForLink={memberRef(issue, ref)}
360
+ resourceHref={resourceHref}
361
+ onResourceClick={onResourceClick}
362
+ />
363
+ ))}
364
+ </ul>
365
+ ) : null}
366
+ </li>
367
+ ))}
368
+ </ul>
369
+ </section>
370
+ );
371
+ }
372
+
373
+ function diagnosticRoleLabel(role: string): string {
374
+ switch (role) {
375
+ case 'candidate':
376
+ return 'Candidate signal';
377
+ case 'affected':
378
+ return 'Affected signal';
379
+ case 'rollup':
380
+ return 'Rollup';
381
+ default:
382
+ return 'Context';
383
+ }
384
+ }
385
+
386
+ function diagnosticFactLabel(type: string): string {
387
+ switch (type) {
388
+ case 'explicit_reference':
389
+ return 'Explicit reference';
390
+ case 'owner_rollup':
391
+ return 'Owner rollup';
392
+ case 'selected_backend_issue':
393
+ return 'Selected backend';
394
+ case 'service_config_mismatch':
395
+ return 'Service config';
396
+ case 'service_env_reference':
397
+ return 'Service env';
398
+ case 'probe_target_mismatch':
399
+ return 'Probe target';
400
+ case 'blocked_init_container':
401
+ return 'Init container';
402
+ case 'restart_cause':
403
+ return 'Restart cause';
404
+ default:
405
+ return type.replace(/_/g, ' ');
406
+ }
407
+ }
408
+
409
+ // Native-tooltip detail for the collapsed-row age chip: absolute first-seen + last-seen
263
410
  // freshness, the two facts the compact "2h" hides.
264
411
  function ageTitle(issue: Issue): string {
265
412
  const parts: string[] = [];
266
- if (issue.first_seen) parts.push(`Started ${new Date(issue.first_seen).toLocaleString()}`);
413
+ if (issue.issue_timing === 'started_at_resource_creation') {
414
+ parts.push('Present since deployment — treat as baseline');
415
+ } else if (issue.issue_timing === 'started_after_resource_was_healthy') {
416
+ parts.push('Resource was previously healthy');
417
+ }
418
+ if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
267
419
  if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
268
420
  return parts.join('\n');
269
421
  }
@@ -11,7 +11,7 @@ export {
11
11
  subjectRef,
12
12
  memberRef,
13
13
  } from './types';
14
- export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef } from './types';
14
+ export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
15
15
  export {
16
16
  ISSUE_SEVERITY_LABEL,
17
17
  ISSUE_SEVERITY_BADGE_CLASS,
@@ -16,27 +16,27 @@ const base: Issue = {
16
16
  const mk = (o: Partial<Issue>): Issue => ({ ...base, ...o })
17
17
 
18
18
  describe('compareIssues', () => {
19
- it('orders critical before warning regardless of onset', () => {
19
+ it('orders critical before warning regardless of observed age', () => {
20
20
  const warn = mk({ id: 'w', severity: 'warning', first_seen: '2026-05-01T00:00:00Z' }) // newer
21
21
  const crit = mk({ id: 'c', severity: 'critical', first_seen: '2026-01-01T00:00:00Z' }) // older
22
22
  expect([warn, crit].sort(compareIssues).map((i) => i.id)).toEqual(['c', 'w'])
23
23
  })
24
24
 
25
- it('breaks same-severity ties by first_seen DESC (newest onset first)', () => {
25
+ it('breaks same-severity ties by first_seen DESC (newest observed issue first)', () => {
26
26
  const older = mk({ id: 'o', first_seen: '2026-01-01T00:00:00Z' })
27
27
  const newer = mk({ id: 'n', first_seen: '2026-05-01T00:00:00Z' })
28
28
  expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
29
29
  })
30
30
 
31
31
  it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
32
- // Two same-severity rows, same onset — order is the deterministic name tiebreak.
32
+ // Two same-severity rows, same first_seen — order is the deterministic name tiebreak.
33
33
  const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
34
34
  const b = mk({ id: 'id-b', name: 'b', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-30T00:00:00Z' })
35
35
  const before = [a, b].sort(compareIssues).map((i) => i.id)
36
36
  expect(before).toEqual(['id-a', 'id-b'])
37
37
  // A refetch bumps a's last_seen to "now". Sorting on last_seen would flip the
38
38
  // order; keying on first_seen + identity must NOT — this is the whole point
39
- // of the onset-based sort.
39
+ // of the first_seen-based sort.
40
40
  const aRefetched = mk({ ...a, last_seen: '2026-06-01T00:00:00Z' })
41
41
  const after = [aRefetched, b].sort(compareIssues).map((i) => i.id)
42
42
  expect(after).toEqual(before)
@@ -91,6 +91,11 @@ const CATEGORY_LABEL: Record<string, string> = {
91
91
  node_not_ready: 'Node not ready',
92
92
  operator_condition_failed: 'Controller condition',
93
93
  gitops_sync_failed: 'GitOps sync failed',
94
+ gitops_render_failed: 'GitOps render failed',
95
+ gitops_spec_invalid: 'GitOps spec invalid',
96
+ gitops_operation_failed: 'GitOps operation failed',
97
+ gitops_out_of_sync: 'GitOps out of sync',
98
+ gitops_health_degraded: 'GitOps health degraded',
94
99
  webhook_backend_down: 'Webhook backend down',
95
100
  control_plane_not_ready: 'Control plane not ready',
96
101
  machine_not_ready: 'Machine not ready',
@@ -57,6 +57,52 @@ export interface IssueAffected {
57
57
  nodes?: number;
58
58
  }
59
59
 
60
+ export type IssueDiagnosticRole = 'candidate' | 'affected' | 'rollup' | 'context';
61
+
62
+ export interface IssueDiagnosticIssueRef {
63
+ ref: IssueResourceRef;
64
+ reason?: string;
65
+ category?: string;
66
+ severity?: IssueSeverity;
67
+ }
68
+
69
+ export interface IssueDiagnosticFact {
70
+ type: string;
71
+ message?: string;
72
+ refs?: IssueResourceRef[];
73
+ related_issues?: IssueDiagnosticIssueRef[];
74
+ }
75
+
76
+ export interface IssueDiagnosticContext {
77
+ role?: IssueDiagnosticRole;
78
+ facts?: IssueDiagnosticFact[];
79
+ }
80
+
81
+ export interface IssueChangeContext {
82
+ changed: boolean;
83
+ what?: string;
84
+ when?: string;
85
+ evidence?: string;
86
+ }
87
+
88
+ export interface IssueRecentChangeField {
89
+ path: string;
90
+ oldValue?: unknown;
91
+ newValue?: unknown;
92
+ }
93
+
94
+ export interface IssueRecentChange {
95
+ kind: string;
96
+ namespace?: string;
97
+ name: string;
98
+ changeType: string;
99
+ summary?: string;
100
+ timestamp: string;
101
+ change_category?: 'spec_config' | 'lifecycle' | 'runtime_status' | string;
102
+ rank_reason?: string;
103
+ fields?: IssueRecentChangeField[];
104
+ }
105
+
60
106
  /**
61
107
  * A grouped live issue — one row of the triage queue. Subject (kind/group/
62
108
  * namespace/name) is the topmost owner when the rows folded under a workload,
@@ -90,6 +136,18 @@ export interface Issue {
90
136
 
91
137
  reason: string;
92
138
  message?: string;
139
+ /** Parsed domain diagnosis (today GitOps controller errors): plain-English
140
+ * cause, suggested next step, and an optional structured one-click fix.
141
+ * Server-emitted (omitempty); empty for issues without a parser. */
142
+ cause?: string;
143
+ action?: string;
144
+ remediation_kind?: string;
145
+ remediation_target?: string;
146
+ /** Controller-operation retry count (e.g. Argo's "(retried N times)") —
147
+ * distinct from restart_count (pod restarts). stuck = not expected to
148
+ * self-recover. */
149
+ operation_retry_count?: number;
150
+ stuck?: boolean;
93
151
  first_seen?: string;
94
152
  last_seen?: string;
95
153
  /** Affected-resource fan-out, EXCLUDING the subject (the row header).
@@ -98,12 +156,28 @@ export interface Issue {
98
156
  count?: number;
99
157
 
100
158
  affected?: IssueAffected;
159
+ owner?: IssueResourceRef;
101
160
  members?: IssueResourceRef[];
102
161
  members_truncated?: boolean;
162
+ diagnostic_context?: IssueDiagnosticContext;
163
+ change_context?: IssueChangeContext;
103
164
 
104
165
  // Pod crash context carried from the representative member.
105
166
  restart_count?: number;
106
167
  last_terminated_reason?: string;
168
+
169
+ /**
170
+ * Best-effort timing evidence from K8s-native signals. Absent when Radar has
171
+ * no confident signal. This is not a root-cause verdict.
172
+ *
173
+ * "started_at_resource_creation" — failing state began during resource
174
+ * creation or first reconciliation.
175
+ * "started_after_resource_was_healthy" — a meaningful healthy window preceded
176
+ * the failing state.
177
+ */
178
+ issue_timing?: 'started_at_resource_creation' | 'started_after_resource_was_healthy';
179
+ /** The evidence that determined issue_timing (for auditability). */
180
+ issue_timing_basis?: 'condition' | 'owner_condition' | 'pod_creation' | 'deletion' | 'phase' | 'spec';
107
181
  }
108
182
 
109
183
  /** subjectRef builds a deep-linkable ref for an issue's subject — the row's
@@ -274,17 +274,27 @@ export function LogCore({
274
274
  })
275
275
  }, [])
276
276
 
277
- // Keyboard shortcut: Ctrl+F to open search
277
+ // Keyboard shortcuts: Ctrl+F opens search; bare 's' toggles streaming
278
+ // (matches k9s). 's' is ignored while typing in a field so it doesn't
279
+ // hijack search input or other text entry.
278
280
  useEffect(() => {
279
281
  const handleKeyDown = (e: KeyboardEvent) => {
280
282
  if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
281
283
  e.preventDefault()
282
284
  search.open()
285
+ return
286
+ }
287
+ if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream) {
288
+ const target = e.target as HTMLElement | null
289
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) return
290
+ e.preventDefault()
291
+ if (isStreaming) onStopStream()
292
+ else onStartStream()
283
293
  }
284
294
  }
285
295
  window.addEventListener('keydown', handleKeyDown)
286
296
  return () => window.removeEventListener('keydown', handleKeyDown)
287
- }, [search.open])
297
+ }, [search.open, onStartStream, onStopStream, isStreaming])
288
298
 
289
299
  const handleFollowOutput = useCallback((isAtBottom: boolean) => {
290
300
  if (isAtBottom) return 'smooth' as const
@@ -846,6 +856,7 @@ export function LogCore({
846
856
 
847
857
  {/* Keyboard shortcut hints */}
848
858
  <div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
859
+ {onStartStream && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
849
860
  <Shortcut keys="Ctrl+F" label="Search" palette={palette} />
850
861
  <Shortcut keys="Enter" label="Next match" palette={palette} />
851
862
  <Shortcut keys="Shift+Enter" label="Prev match" palette={palette} />
@@ -48,6 +48,8 @@ interface LogRangeSelectProps {
48
48
  tooltip?: string
49
49
  /** Dark/light palette selector. Defaults to `true` (dark viewer). */
50
50
  isDark?: boolean
51
+ /** When true, the control is greyed out and non-interactive. */
52
+ disabled?: boolean
51
53
  }
52
54
 
53
55
  export function LogRangeSelect({
@@ -56,14 +58,16 @@ export function LogRangeSelect({
56
58
  lineOptions = [100, 500, 1000, 5000],
57
59
  tooltip = 'How many logs to load — by line count or time range',
58
60
  isDark = true,
61
+ disabled = false,
59
62
  }: LogRangeSelectProps) {
60
63
  const palette = getLogPalette(isDark)
61
64
  return (
62
- <Tooltip content={tooltip} position="bottom">
65
+ <Tooltip content={disabled ? 'Stop streaming to change the log range' : tooltip} position="bottom">
63
66
  <select
64
67
  value={value}
65
68
  onChange={(e) => onChange(e.target.value)}
66
- className={`${selectClass(palette)} pr-5`}
69
+ disabled={disabled}
70
+ className={`${selectClass(palette)} pr-5 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
67
71
  >
68
72
  <optgroup label="Lines">
69
73
  {lineOptions.map(n => (