@skyhook-io/k8s-ui 1.7.13 → 1.7.15

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.
@@ -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
 
@@ -241,11 +271,9 @@ function Diagnosis({ issue }: { issue: Issue }) {
241
271
  .join(' · ')
242
272
  : null;
243
273
  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(' ');
274
+ // When the issue carries a parsed plain-English cause, lead with it. The raw
275
+ // detector message is kept below as de-emphasized detail.
276
+ const rawMessage = issue.cause ? issue.message ?? '' : [headline, detail].filter(Boolean).join(' ');
249
277
  return (
250
278
  <section className="flex flex-col gap-1">
251
279
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
@@ -280,7 +308,7 @@ function Diagnosis({ issue }: { issue: Issue }) {
280
308
  {issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
281
309
  </p>
282
310
  ) : null}
283
- {/* Raw controller message, de-emphasized — shown below the parsed cause so
311
+ {/* Raw detector message, de-emphasized — shown below the parsed cause so
284
312
  the precise error (URLs, resource names) is available without leading. */}
285
313
  {issue.cause && rawMessage ? (
286
314
  <p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
@@ -316,10 +344,12 @@ function DiagnosticContext({
316
344
  issue,
317
345
  resourceHref,
318
346
  onResourceClick,
347
+ ResourceLinkIcon,
319
348
  }: {
320
349
  issue: Issue;
321
350
  resourceHref?: (ref: IssueResourceRef) => string;
322
351
  onResourceClick?: (ref: IssueResourceRef) => void;
352
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
323
353
  }) {
324
354
  const ctx = issue.diagnostic_context;
325
355
  const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
@@ -347,6 +377,7 @@ function DiagnosticContext({
347
377
  refForLink={memberRef(issue, related.ref)}
348
378
  resourceHref={resourceHref}
349
379
  onResourceClick={onResourceClick}
380
+ ResourceLinkIcon={ResourceLinkIcon}
350
381
  />
351
382
  ))}
352
383
  </ul>
@@ -359,6 +390,7 @@ function DiagnosticContext({
359
390
  refForLink={memberRef(issue, ref)}
360
391
  resourceHref={resourceHref}
361
392
  onResourceClick={onResourceClick}
393
+ ResourceLinkIcon={ResourceLinkIcon}
362
394
  />
363
395
  ))}
364
396
  </ul>
@@ -424,10 +456,12 @@ function AffectedResources({
424
456
  issue,
425
457
  resourceHref,
426
458
  onResourceClick,
459
+ ResourceLinkIcon,
427
460
  }: {
428
461
  issue: Issue;
429
462
  resourceHref?: (ref: IssueResourceRef) => string;
430
463
  onResourceClick?: (ref: IssueResourceRef) => void;
464
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
431
465
  }) {
432
466
  const members = issue.members ?? [];
433
467
  // count is the backend fan-out size (members, subject excluded — see
@@ -439,7 +473,7 @@ function AffectedResources({
439
473
  first deep-link; members (the folded pods) follow. ResourceLine emits
440
474
  an <li>, so it needs a list parent of its own. */}
441
475
  <ul className="flex flex-col gap-px">
442
- <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
476
+ <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
443
477
  </ul>
444
478
  {members.length > 0 && (
445
479
  <>
@@ -453,6 +487,7 @@ function AffectedResources({
453
487
  refForLink={memberRef(issue, m)}
454
488
  resourceHref={resourceHref}
455
489
  onResourceClick={onResourceClick}
490
+ ResourceLinkIcon={ResourceLinkIcon}
456
491
  />
457
492
  ))}
458
493
  </ul>
@@ -472,11 +507,13 @@ function ResourceLine({
472
507
  refForLink,
473
508
  resourceHref,
474
509
  onResourceClick,
510
+ ResourceLinkIcon,
475
511
  }: {
476
512
  label?: string;
477
513
  refForLink: IssueResourceRef;
478
514
  resourceHref?: (ref: IssueResourceRef) => string;
479
515
  onResourceClick?: (ref: IssueResourceRef) => void;
516
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
480
517
  }) {
481
518
  const r = refForLink;
482
519
  const linkable = !!(onResourceClick || resourceHref);
@@ -488,7 +525,7 @@ function ResourceLine({
488
525
  {r.namespace ? `${r.namespace} / ` : ''}
489
526
  {r.name}
490
527
  </span>
491
- {linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
528
+ {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
492
529
  </>
493
530
  );
494
531
  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
  }
@@ -101,6 +108,16 @@ export interface IssueRecentChange {
101
108
  change_category?: 'spec_config' | 'lifecycle' | 'runtime_status' | string;
102
109
  rank_reason?: string;
103
110
  fields?: IssueRecentChangeField[];
111
+ /** Workloads that mount/reference this ConfigMap directly ("Deployment/flagd").
112
+ * Direct spec references only — runtime consumers via an intermediary
113
+ * service are not captured. */
114
+ consumed_by?: string[];
115
+ }
116
+
117
+ /** "No tracked non-status changes in the window" — the claim is scoped by
118
+ * window_seconds so a change just outside it can't be misread as absent. */
119
+ export interface IssueNoRecentChanges {
120
+ window_seconds: number;
104
121
  }
105
122
 
106
123
  /**
@@ -136,8 +153,8 @@ export interface Issue {
136
153
 
137
154
  reason: string;
138
155
  message?: string;
139
- /** Parsed domain diagnosis (today GitOps controller errors): plain-English
140
- * cause, suggested next step, and an optional structured one-click fix.
156
+ /** Parsed domain diagnosis: plain-English cause, suggested next step, and
157
+ * an optional structured one-click fix.
141
158
  * Server-emitted (omitempty); empty for issues without a parser. */
142
159
  cause?: string;
143
160
  action?: string;
@@ -178,6 +195,16 @@ export interface Issue {
178
195
  issue_timing?: 'started_at_resource_creation' | 'started_after_resource_was_healthy';
179
196
  /** The evidence that determined issue_timing (for auditability). */
180
197
  issue_timing_basis?: 'condition' | 'owner_condition' | 'pod_creation' | 'deletion' | 'phase' | 'spec';
198
+
199
+ /** Recent non-status changes (spec/config and lifecycle) on this issue's
200
+ * subject (and, for workload subjects, its referenced ConfigMaps) —
201
+ * deterministic evidence, not a causal claim. Populated only on
202
+ * single-namespace MCP issue responses; never set on /api/issues today. */
203
+ correlated_changes?: IssueRecentChange[];
204
+ /** Explicit "no tracked changes in the window" evidence. An issue with
205
+ * NEITHER correlation field was not checked — absence must not be read as
206
+ * "no changes". MCP-only, like correlated_changes. */
207
+ no_recent_changes?: IssueNoRecentChanges;
181
208
  }
182
209
 
183
210
  /** subjectRef builds a deep-linkable ref for an issue's subject — the row's
@@ -209,15 +236,18 @@ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResource
209
236
 
210
237
  /**
211
238
  * 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.
239
+ * it can be unit-tested). Severity first (critical before warning), then
240
+ * direct-blocker source priority, then ONSET first_seen DESC, deliberately
241
+ * NOT last_seen: last_seen bumps to compose-time on every poll, so sorting by
242
+ * it would reshuffle same-severity rows on each refetch. The remaining keys
243
+ * (cluster namespace name id) are a fully deterministic tiebreak so the
244
+ * order never churns under auto-refresh.
217
245
  */
218
246
  export function compareIssues(a: Issue, b: Issue): number {
219
247
  const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
220
248
  if (r !== 0) return r;
249
+ const sr = (ISSUE_SOURCE_RANK[b.source] ?? 0) - (ISSUE_SOURCE_RANK[a.source] ?? 0);
250
+ if (sr !== 0) return sr;
221
251
  const fa = a.first_seen ?? '';
222
252
  const fb = b.first_seen ?? '';
223
253
  if (fa !== fb) return fb.localeCompare(fa);
@@ -82,6 +82,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
82
82
  pods.forEach((pod, i) => m.set(pod.name, i))
83
83
  return m
84
84
  }, [pods])
85
+ const podColorIndexRef = useRef<Map<string, number>>(new Map())
86
+
87
+ useEffect(() => {
88
+ podColorIndexRef.current = podColorIndex
89
+ }, [podColorIndex])
85
90
 
86
91
  const podsInitialized = useRef(false)
87
92
 
@@ -94,6 +99,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
94
99
  const resultPods = result.pods ?? []
95
100
  const resultLogs = result.logs ?? []
96
101
 
102
+ podColorIndexRef.current = new Map(resultPods.map((pod, i) => [pod.name, i]))
97
103
  setPods(resultPods)
98
104
 
99
105
  if (!podsInitialized.current && resultPods.length > 0) {
@@ -148,10 +154,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
148
154
  {
149
155
  onConnected: (data: any) => {
150
156
  if (data?.pods) {
151
- setPods(data.pods)
152
- if (selectedPods.size === 0) {
153
- setSelectedPods(new Set((data.pods as WorkloadPodInfo[]).map((p: WorkloadPodInfo) => p.name)))
154
- }
157
+ const nextPods = data.pods as WorkloadPodInfo[]
158
+ podColorIndexRef.current = new Map(nextPods.map((pod, i) => [pod.name, i]))
159
+ setPods(nextPods)
160
+ setSelectedPods(prev => (
161
+ prev.size === 0 ? new Set(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
162
+ ))
155
163
  }
156
164
  },
157
165
  onLog: (data: any) => {
@@ -161,7 +169,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
161
169
  content: data.content || '',
162
170
  container: data.container || '',
163
171
  pod: data.pod || '',
164
- podColorIndex: podColorIndex.get(data.pod || ''),
172
+ podColorIndex: podColorIndexRef.current.get(data.pod || ''),
165
173
  })
166
174
  }
167
175
  },
@@ -171,7 +179,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
171
179
  setPods(prev => {
172
180
  const existing = new Set(prev.map(p => p.name))
173
181
  const toAdd = newPods.filter(p => !existing.has(p.name))
174
- return toAdd.length > 0 ? [...prev, ...toAdd] : prev
182
+ if (toAdd.length === 0) return prev
183
+ return [...prev, ...toAdd]
175
184
  })
176
185
  setSelectedPods(prev => {
177
186
  const next = new Set(prev)
@@ -191,7 +200,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
191
200
  },
192
201
  'Workload log stream connection failed',
193
202
  )
194
- }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColorIndex, selectedPods.size, clear])
203
+ }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear])
195
204
 
196
205
  const handleStopStreaming = useCallback(() => {
197
206
  userStoppedRef.current = true