@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.
- package/package.json +1 -1
- package/src/components/dock/TerminalTab.tsx +4 -2
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +169 -17
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +4 -4
- package/src/components/issues/severity.ts +5 -0
- package/src/components/issues/types.ts +74 -0
- package/src/components/logs/LogCore.tsx +13 -2
- package/src/components/logs/LogToolbarSelects.tsx +6 -2
- package/src/components/logs/LogsViewer.tsx +66 -10
- package/src/components/logs/WorkloadLogsViewer.tsx +60 -8
- package/src/components/logs/useLogStream.ts +41 -3
- package/src/components/resources/ResourcesView.tsx +550 -52
- package/src/components/resources/column-filter-serialization.test.ts +26 -0
- package/src/components/resources/get-default-container-name.test.ts +31 -0
- package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
- package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
- package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
- package/src/components/resources/renderers/PodRenderer.tsx +2 -2
- package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
- package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
- package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
- package/src/components/resources/renderers/dra-cells.tsx +80 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
- package/src/components/resources/resource-utils-dra.ts +90 -0
- package/src/components/resources/resource-utils-nvidia.ts +63 -0
- package/src/components/resources/resource-utils.ts +62 -4
- package/src/components/shared/CreateResourceDialog.tsx +32 -3
- package/src/components/shared/EditableYamlView.tsx +31 -2
- package/src/components/shared/ResourceActionsBar.tsx +5 -4
- package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
- package/src/components/ui/ConfirmDialog.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +23 -1
- package/src/types/core.ts +1 -0
- package/src/utils/api-resources.ts +21 -0
- package/src/utils/custom-columns.test.ts +111 -0
- package/src/utils/custom-columns.ts +49 -0
- package/src/utils/extended-resources.test.ts +152 -0
- package/src/utils/extended-resources.ts +121 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
289
|
-
|
|
288
|
+
Container <span className="font-mono text-theme-text-secondary">{selectedContainer}</span> doesn'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
|
|
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
|
|
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 →
|
|
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
|
-
{/*
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 => (
|