@skyhook-io/k8s-ui 1.8.9 → 1.8.10
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/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +40 -9
- package/src/components/checks/ChecksView.tsx +80 -49
- package/src/components/checks/severity.ts +29 -39
- package/src/components/gitops/GitOpsTableView.tsx +11 -56
- package/src/components/issues/IssuesView.tsx +186 -123
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
- package/src/components/issues/ResourceIssuesSection.tsx +21 -3
- package/src/components/issues/index.ts +0 -2
- package/src/components/issues/issues.test.ts +45 -4
- package/src/components/issues/severity.ts +27 -32
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
- package/src/components/resources/ResourcesSidebar.tsx +24 -6
- package/src/components/resources/ResourcesView.tsx +10 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/severity-tone.ts +56 -0
- package/src/theme/variables.css +14 -0
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/tsconfig.json +2 -2
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
2
|
-
import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
|
|
3
|
-
import { ClusterName, EmptyState } from '../ui';
|
|
2
|
+
import { AlertOctagon, AlertTriangle, ArrowRight, ChevronRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
|
|
3
|
+
import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
|
|
4
4
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
5
5
|
import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
|
|
6
6
|
import {
|
|
7
7
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
8
|
+
ISSUE_SEVERITY_HEADER_BAND_CLASS,
|
|
8
9
|
ISSUE_SEVERITY_LABEL,
|
|
9
10
|
ISSUE_SEVERITY_RAIL_CLASS,
|
|
11
|
+
ISSUE_SEVERITY_SOLID_CLASS,
|
|
12
|
+
ISSUE_SEVERITY_TEXT_CLASS,
|
|
10
13
|
categoryLabel,
|
|
11
14
|
groupBadgeClass,
|
|
12
15
|
groupLabel,
|
|
@@ -19,8 +22,25 @@ import {
|
|
|
19
22
|
type Issue,
|
|
20
23
|
type IssueAffected,
|
|
21
24
|
type IssueResourceRef,
|
|
25
|
+
type IssueSeverity,
|
|
22
26
|
} from './types';
|
|
23
27
|
|
|
28
|
+
// Leading severity glyph — the at-a-glance severity cue on every row: critical =
|
|
29
|
+
// octagon (stop), warning = triangle.
|
|
30
|
+
const ISSUE_SEVERITY_ICON: Record<IssueSeverity, ComponentType<{ className?: string }>> = {
|
|
31
|
+
critical: AlertOctagon,
|
|
32
|
+
warning: AlertTriangle,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// An out-of-contract severity the backend might emit is coerced to this tier
|
|
36
|
+
// once, up front (normalizeIssueSeverity), so the icon AND every color map
|
|
37
|
+
// (text/rail/band/pill) resolve together — a raw miss would crash the icon and
|
|
38
|
+
// silently drop the tint everywhere else.
|
|
39
|
+
const ISSUE_SEVERITY_FALLBACK: IssueSeverity = 'warning';
|
|
40
|
+
// Object.hasOwn (not `in`) so inherited keys like "toString" don't slip past.
|
|
41
|
+
const normalizeIssueSeverity = (s: IssueSeverity): IssueSeverity =>
|
|
42
|
+
Object.hasOwn(ISSUE_SEVERITY_ICON, s) ? s : ISSUE_SEVERITY_FALLBACK;
|
|
43
|
+
|
|
24
44
|
export interface IssuesViewProps {
|
|
25
45
|
/** Grouped live issues — one row per subject+category. Typically flattened
|
|
26
46
|
* across the fleet by the host (the hub) or a single cluster (OSS). */
|
|
@@ -132,6 +152,8 @@ export function IssueRow({
|
|
|
132
152
|
const { headline } = issueMessageParts(issue);
|
|
133
153
|
const [renderDetails, setRenderDetails] = useState(open);
|
|
134
154
|
const Container = as;
|
|
155
|
+
const severity = normalizeIssueSeverity(issue.severity);
|
|
156
|
+
const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
|
|
135
157
|
const slotCtx = { issue, open };
|
|
136
158
|
|
|
137
159
|
useEffect(() => {
|
|
@@ -148,15 +170,23 @@ export function IssueRow({
|
|
|
148
170
|
return (
|
|
149
171
|
<Container
|
|
150
172
|
className={[
|
|
151
|
-
'overflow-hidden rounded-xl border
|
|
173
|
+
'overflow-hidden rounded-xl border bg-theme-surface transition-[border-color,box-shadow] duration-200',
|
|
174
|
+
// The open card lifts via elevation — heavier shadow + a bright
|
|
175
|
+
// emphasis edge that clearly separates it from sibling cards. Severity
|
|
176
|
+
// stays rationed to the band + pill; separation is depth, not more color.
|
|
177
|
+
// ring-1 widens the edge to 2px without the layout shift a border-2
|
|
178
|
+
// swap would cause on expand.
|
|
179
|
+
open ? 'border-[var(--border-emphasis)] ring-1 ring-[var(--border-emphasis)] shadow-theme-md' : 'border-theme-border shadow-theme-sm',
|
|
152
180
|
dimmed ? 'opacity-60' : '',
|
|
153
181
|
className ?? '',
|
|
154
182
|
].filter(Boolean).join(' ')}
|
|
155
183
|
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
|
|
156
184
|
>
|
|
157
|
-
{/* The whole header is the single toggle target
|
|
158
|
-
|
|
159
|
-
expanded body (a link nested in a button would
|
|
185
|
+
{/* The whole header is the single toggle target. The leading severity
|
|
186
|
+
icon is the at-a-glance cue; a trailing chevron shows open/closed.
|
|
187
|
+
Deep-links live in the expanded body (a link nested in a button would
|
|
188
|
+
be invalid). Collapsed: neutral row + rail. Expanded: severity-tinted
|
|
189
|
+
band + solid pill — the tint is a focus signal, not per-row alarm. */}
|
|
160
190
|
<div
|
|
161
191
|
role="button"
|
|
162
192
|
tabIndex={0}
|
|
@@ -169,28 +199,32 @@ export function IssueRow({
|
|
|
169
199
|
onToggle();
|
|
170
200
|
}
|
|
171
201
|
}}
|
|
172
|
-
className={`group flex cursor-pointer items-center gap-3 border-l-
|
|
202
|
+
className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
173
203
|
>
|
|
174
|
-
<
|
|
204
|
+
<SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
|
|
175
205
|
|
|
176
206
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
177
207
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
178
208
|
<span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
|
|
179
|
-
<span className={`
|
|
209
|
+
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
180
210
|
{renderBadges?.(slotCtx)}
|
|
181
|
-
{/* The detector reason
|
|
182
|
-
|
|
183
|
-
the
|
|
184
|
-
|
|
211
|
+
{/* The detector reason rides the title row while COLLAPSED so the
|
|
212
|
+
key triage signal shows without expanding. When open, the full
|
|
213
|
+
cause lives in the WHAT'S WRONG section below, so it fades out
|
|
214
|
+
here (stays mounted — it's the flex-1 filler, so unmounting
|
|
215
|
+
wouldn't reflow anything, but fading avoids the pop). */}
|
|
185
216
|
{issue.reason ? (
|
|
186
|
-
<span
|
|
217
|
+
<span
|
|
218
|
+
aria-hidden={open || undefined}
|
|
219
|
+
className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
|
|
220
|
+
>
|
|
187
221
|
<span className="font-medium text-theme-text-secondary">{issue.reason}</span>
|
|
188
222
|
{headline ? <span> — {headline}</span> : null}
|
|
189
223
|
</span>
|
|
190
224
|
) : null}
|
|
191
225
|
</div>
|
|
192
226
|
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
193
|
-
<span className=
|
|
227
|
+
<span className={KIND_CHIP_CLASS}>{issue.kind}</span>
|
|
194
228
|
<span className="min-w-0 truncate font-medium text-theme-text-secondary">
|
|
195
229
|
{issue.namespace ? `${issue.namespace} / ` : ''}
|
|
196
230
|
{issue.name}
|
|
@@ -224,27 +258,28 @@ export function IssueRow({
|
|
|
224
258
|
</div>
|
|
225
259
|
</div>
|
|
226
260
|
|
|
227
|
-
{/*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
261
|
+
{/* Right cluster — severity pill THEN age, vertically centered as a group. */}
|
|
262
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
263
|
+
<span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
|
|
264
|
+
{ISSUE_SEVERITY_LABEL[severity]}
|
|
265
|
+
</span>
|
|
266
|
+
{/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
|
|
267
|
+
{issue.first_seen ? (
|
|
268
|
+
<time
|
|
269
|
+
dateTime={issue.first_seen}
|
|
270
|
+
title={ageTitle(issue)}
|
|
271
|
+
className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
272
|
+
>
|
|
273
|
+
<Clock className="h-3 w-3" aria-hidden />
|
|
274
|
+
{formatCompactAge(issue.first_seen)}
|
|
275
|
+
{issue.issue_timing === 'started_at_resource_creation' ? (
|
|
276
|
+
<span className="badge-sm ml-1 text-[10px] text-theme-text-secondary">since deploy</span>
|
|
277
|
+
) : null}
|
|
278
|
+
</time>
|
|
279
|
+
) : null}
|
|
280
|
+
{renderActions?.(slotCtx)}
|
|
281
|
+
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
282
|
+
</div>
|
|
248
283
|
</div>
|
|
249
284
|
|
|
250
285
|
{renderDetails ? (
|
|
@@ -257,8 +292,10 @@ export function IssueRow({
|
|
|
257
292
|
}}
|
|
258
293
|
>
|
|
259
294
|
<div className="overflow-hidden">
|
|
260
|
-
|
|
261
|
-
|
|
295
|
+
{/* Body sits on the card surface (not a recessed grey panel) so its
|
|
296
|
+
text keeps enough contrast. */}
|
|
297
|
+
<div className="border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4">
|
|
298
|
+
<div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
|
262
299
|
<Diagnosis issue={issue} />
|
|
263
300
|
{issue.incident_parent ? (
|
|
264
301
|
<section className="flex flex-col gap-1">
|
|
@@ -276,9 +313,7 @@ export function IssueRow({
|
|
|
276
313
|
</section>
|
|
277
314
|
) : null}
|
|
278
315
|
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
279
|
-
<
|
|
280
|
-
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
281
|
-
</div>
|
|
316
|
+
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
282
317
|
</div>
|
|
283
318
|
</div>
|
|
284
319
|
</div>
|
|
@@ -288,8 +323,9 @@ export function IssueRow({
|
|
|
288
323
|
);
|
|
289
324
|
}
|
|
290
325
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
326
|
+
// Diagnosis: WHAT'S WRONG (amber) → NEXT STEP (emerald) → RAW ERROR (monochrome
|
|
327
|
+
// terminal block). Status/timing facts share one muted meta line under the
|
|
328
|
+
// diagnosis so the body reads as three beats rather than a stack of one-liners.
|
|
293
329
|
function Diagnosis({ issue }: { issue: Issue }) {
|
|
294
330
|
const crash =
|
|
295
331
|
issue.restart_count || issue.last_terminated_reason
|
|
@@ -298,71 +334,85 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
298
334
|
.join(' · ')
|
|
299
335
|
: null;
|
|
300
336
|
const { headline, detail } = issueMessageParts(issue);
|
|
301
|
-
//
|
|
302
|
-
//
|
|
337
|
+
// Raw detector text → terminal block. Two independent sources: the explicit
|
|
338
|
+
// raw_message (or, for a derived cause, the original message it came from) and
|
|
339
|
+
// the precise kubelet/containerd `detail` a short image-pull headline replaced
|
|
340
|
+
// — both surface when distinct, so setting raw_message never hides the CRI
|
|
341
|
+
// detail. Each is kept only when it adds something the prose above doesn't
|
|
342
|
+
// already show, compared against what's actually rendered (the cause when
|
|
343
|
+
// present, else headline+detail) rather than a string that may never appear.
|
|
303
344
|
const visibleMessage = [headline, detail].filter(Boolean).join(' ');
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
345
|
+
const shownText = issue.cause ?? visibleMessage;
|
|
346
|
+
const rawError = [
|
|
347
|
+
...new Set(
|
|
348
|
+
[issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
|
|
349
|
+
(s): s is string => !!s && s !== shownText,
|
|
350
|
+
),
|
|
351
|
+
),
|
|
352
|
+
].join('\n');
|
|
353
|
+
|
|
354
|
+
// One muted line — stuck/retry · crash · timing · change. A retrying-but-not-
|
|
355
|
+
// yet-stuck operation leads with "Retrying"; once it's declared stuck, "Stuck"
|
|
356
|
+
// takes over.
|
|
357
|
+
const meta: string[] = [];
|
|
358
|
+
if (issue.stuck) meta.push('Stuck');
|
|
359
|
+
else if (issue.operation_retry_count) meta.push('Retrying');
|
|
360
|
+
if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
|
|
361
|
+
if (crash) meta.push(crash);
|
|
362
|
+
if (issue.first_seen) {
|
|
363
|
+
meta.push(
|
|
364
|
+
issue.issue_timing === 'started_at_resource_creation'
|
|
365
|
+
? 'present since deployment'
|
|
366
|
+
: issue.issue_timing === 'started_after_resource_was_healthy'
|
|
367
|
+
? `started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
|
|
368
|
+
: `started ${formatRelativeAgeTime(issue.first_seen)}`,
|
|
369
|
+
);
|
|
370
|
+
if (issue.last_seen && issue.issue_timing !== 'started_at_resource_creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
371
|
+
}
|
|
372
|
+
if (issue.change_context) meta.push(changeContextText(issue.change_context));
|
|
373
|
+
|
|
374
|
+
const hasNextStep = !!issue.action || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
|
|
375
|
+
|
|
308
376
|
return (
|
|
309
|
-
<
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
{
|
|
332
|
-
<
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
</p>
|
|
341
|
-
) : null}
|
|
342
|
-
{/* Raw detector message, de-emphasized so precise controller/kubelet text
|
|
343
|
-
remains available without leading the diagnosis. */}
|
|
344
|
-
{shouldShowRawMessage ? (
|
|
345
|
-
<p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
|
|
346
|
-
) : null}
|
|
347
|
-
{crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
|
|
348
|
-
{issue.change_context ? (
|
|
349
|
-
<p className="text-xs text-theme-text-tertiary">
|
|
350
|
-
{changeContextText(issue.change_context)}
|
|
351
|
-
</p>
|
|
377
|
+
<div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
|
378
|
+
{/* When a parsed cause replaces the detector text, the terse reason code
|
|
379
|
+
(CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
|
|
380
|
+
identifier operators anchor on, and the collapsed header drops it while
|
|
381
|
+
open. The non-cause branch already leads with it in the prose. */}
|
|
382
|
+
<CardSection
|
|
383
|
+
icon={AlertTriangle}
|
|
384
|
+
label="What's wrong"
|
|
385
|
+
tone="warn"
|
|
386
|
+
labelExtra={issue.cause && issue.reason ? `· ${issue.reason}` : undefined}
|
|
387
|
+
>
|
|
388
|
+
{issue.cause ? (
|
|
389
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
|
|
390
|
+
) : (
|
|
391
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">
|
|
392
|
+
<span className="font-medium">{issue.reason}</span>
|
|
393
|
+
{headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
|
|
394
|
+
</p>
|
|
395
|
+
)}
|
|
396
|
+
{meta.length > 0 ? <p className="text-xs leading-relaxed text-theme-text-tertiary tabular-nums">{meta.join(' · ')}</p> : null}
|
|
397
|
+
</CardSection>
|
|
398
|
+
|
|
399
|
+
{hasNextStep ? (
|
|
400
|
+
<CardSection icon={ArrowRight} label="Next step" tone="fix">
|
|
401
|
+
{issue.action ? <CardBody>{issue.action}</CardBody> : null}
|
|
402
|
+
{issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
|
|
403
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
404
|
+
Suggested fix: create namespace <code className="inline-code">{issue.remediation_target}</code> — apply it from the GitOps detail page.
|
|
405
|
+
</p>
|
|
406
|
+
) : null}
|
|
407
|
+
</CardSection>
|
|
352
408
|
) : null}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
? `Started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
|
|
359
|
-
: `Started ${formatRelativeAgeTime(issue.first_seen)}`}
|
|
360
|
-
{issue.last_seen && issue.issue_timing !== 'started_at_resource_creation'
|
|
361
|
-
? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}`
|
|
362
|
-
: ''}
|
|
363
|
-
</p>
|
|
409
|
+
|
|
410
|
+
{rawError ? (
|
|
411
|
+
<CardSection icon={Terminal} label="Raw error">
|
|
412
|
+
<TerminalBlock>{rawError}</TerminalBlock>
|
|
413
|
+
</CardSection>
|
|
364
414
|
) : null}
|
|
365
|
-
</
|
|
415
|
+
</div>
|
|
366
416
|
);
|
|
367
417
|
}
|
|
368
418
|
|
|
@@ -387,11 +437,11 @@ function DiagnosticContext({
|
|
|
387
437
|
if (!ctx || facts.length === 0) return null;
|
|
388
438
|
|
|
389
439
|
return (
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
440
|
+
<CardSection
|
|
441
|
+
icon={Workflow}
|
|
442
|
+
label="Context"
|
|
443
|
+
labelExtra={ctx.role ? diagnosticRoleLabel(ctx.role) : undefined}
|
|
444
|
+
>
|
|
395
445
|
<ul className="flex flex-col gap-2">
|
|
396
446
|
{facts.map((fact, idx) => (
|
|
397
447
|
<li key={`${fact.type}-${idx}`} className="flex flex-col gap-1.5 rounded-md border border-theme-border/70 px-2.5 py-2">
|
|
@@ -438,7 +488,7 @@ function DiagnosticContext({
|
|
|
438
488
|
</li>
|
|
439
489
|
))}
|
|
440
490
|
</ul>
|
|
441
|
-
</
|
|
491
|
+
</CardSection>
|
|
442
492
|
);
|
|
443
493
|
}
|
|
444
494
|
|
|
@@ -472,18 +522,15 @@ function AffectedResources({
|
|
|
472
522
|
// grouping.go); fall back to the inline member count, not +1.
|
|
473
523
|
const total = issue.count ?? members.length;
|
|
474
524
|
return (
|
|
475
|
-
<section className="flex flex-col gap-
|
|
525
|
+
<section className="flex flex-col gap-3">
|
|
476
526
|
{/* The subject (the grouped thing — e.g. the Deployment) is always the
|
|
477
527
|
first deep-link; members (the folded pods) follow. ResourceLine emits
|
|
478
528
|
an <li>, so it needs a list parent of its own. */}
|
|
479
529
|
<ul className="flex flex-col gap-px">
|
|
480
|
-
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
530
|
+
<ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
481
531
|
</ul>
|
|
482
532
|
{members.length > 0 && (
|
|
483
|
-
|
|
484
|
-
<h4 className="mt-1.5 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
485
|
-
Affected resources <span className="tabular-nums">({total})</span>
|
|
486
|
-
</h4>
|
|
533
|
+
<CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
|
|
487
534
|
<ul className="flex flex-col gap-px">
|
|
488
535
|
{members.map((m, i) => (
|
|
489
536
|
<ResourceLine
|
|
@@ -496,11 +543,11 @@ function AffectedResources({
|
|
|
496
543
|
))}
|
|
497
544
|
</ul>
|
|
498
545
|
{issue.members_truncated && (
|
|
499
|
-
<p className="
|
|
546
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
500
547
|
Showing {members.length} of {total} — open the subject to see the rest.
|
|
501
548
|
</p>
|
|
502
549
|
)}
|
|
503
|
-
|
|
550
|
+
</CardSection>
|
|
504
551
|
)}
|
|
505
552
|
</section>
|
|
506
553
|
);
|
|
@@ -513,6 +560,7 @@ function ResourceLine({
|
|
|
513
560
|
resourceHref,
|
|
514
561
|
onResourceClick,
|
|
515
562
|
ResourceLinkIcon,
|
|
563
|
+
compact,
|
|
516
564
|
}: {
|
|
517
565
|
label?: string;
|
|
518
566
|
refForLink: IssueResourceRef;
|
|
@@ -520,14 +568,23 @@ function ResourceLine({
|
|
|
520
568
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
521
569
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
522
570
|
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
571
|
+
// Footer variant (the Subject row): renders the kind as a grey mono chip and
|
|
572
|
+
// a slightly bolder name, distinguishing the subject from the affected-members
|
|
573
|
+
// list below it.
|
|
574
|
+
compact?: boolean;
|
|
523
575
|
}) {
|
|
524
576
|
const r = refForLink;
|
|
525
577
|
const linkable = !!(onResourceClick || resourceHref);
|
|
526
578
|
const body = (
|
|
527
579
|
<>
|
|
528
|
-
{label ? <span className="shrink-0 text-[
|
|
529
|
-
|
|
530
|
-
|
|
580
|
+
{label ? <span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-theme-text-tertiary">{label}</span> : null}
|
|
581
|
+
{/* Footer (compact): the kind is a grey mono chip; member rows keep plain mono. */}
|
|
582
|
+
{compact ? (
|
|
583
|
+
<span className={KIND_CHIP_CLASS}>{r.kind}</span>
|
|
584
|
+
) : (
|
|
585
|
+
<span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
|
|
586
|
+
)}
|
|
587
|
+
<span className={`min-w-0 truncate text-sm ${linkable ? `${compact ? 'font-semibold' : 'font-medium'} text-[var(--color-radar-accent)]` : 'font-medium text-theme-text-primary'}`}>
|
|
531
588
|
{r.namespace ? `${r.namespace} / ` : ''}
|
|
532
589
|
{r.name}
|
|
533
590
|
</span>
|
|
@@ -537,7 +594,13 @@ function ResourceLine({
|
|
|
537
594
|
{linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
538
595
|
</>
|
|
539
596
|
);
|
|
540
|
-
|
|
597
|
+
// compact = Subject row; default = the affected-members list. Both get the
|
|
598
|
+
// padded, rounded hover hit-box so the Subject reads as clickable like the
|
|
599
|
+
// members below it. items-baseline so the label + kind chip sit on the same
|
|
600
|
+
// baseline as the larger resource name.
|
|
601
|
+
const cls = compact
|
|
602
|
+
? 'group/r flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left transition-colors hover:bg-theme-hover/60'
|
|
603
|
+
: 'group/r flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
|
|
541
604
|
return (
|
|
542
605
|
<li>
|
|
543
606
|
{onResourceClick ? (
|
|
@@ -549,7 +612,7 @@ function ResourceLine({
|
|
|
549
612
|
{body}
|
|
550
613
|
</a>
|
|
551
614
|
) : (
|
|
552
|
-
<span className=
|
|
615
|
+
<span className={compact ? 'flex items-baseline gap-2 rounded-md px-2 py-1' : 'flex items-baseline gap-2 rounded-md px-2 py-1 text-sm'}>{body}</span>
|
|
553
616
|
)}
|
|
554
617
|
</li>
|
|
555
618
|
);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { ResourceIssuesSection } from './ResourceIssuesSection'
|
|
4
|
+
import type { Issue } from './types'
|
|
5
|
+
|
|
6
|
+
const issue: Issue = {
|
|
7
|
+
id: 'pvc-root',
|
|
8
|
+
severity: 'critical',
|
|
9
|
+
source: 'missing_ref',
|
|
10
|
+
category: 'pvc_pending',
|
|
11
|
+
category_group: 'storage',
|
|
12
|
+
grouping_scope: 'pvc',
|
|
13
|
+
kind: 'PersistentVolumeClaim',
|
|
14
|
+
namespace: 'demo',
|
|
15
|
+
name: 'data',
|
|
16
|
+
reason: 'StorageClassMissing',
|
|
17
|
+
cause: 'PVC demo/data references a StorageClass that does not exist.',
|
|
18
|
+
diagnostic_context: {
|
|
19
|
+
role: 'candidate',
|
|
20
|
+
facts: [
|
|
21
|
+
{
|
|
22
|
+
type: 'pvc_blast_radius',
|
|
23
|
+
confidence: 'high',
|
|
24
|
+
message: 'Blocks pods that mount this claim.',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('ResourceIssuesSection', () => {
|
|
31
|
+
it('keeps drawer context facts but omits the role badge', () => {
|
|
32
|
+
const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
|
|
33
|
+
|
|
34
|
+
expect(html).toContain('Context')
|
|
35
|
+
expect(html).toContain('Blocked pods')
|
|
36
|
+
expect(html).toContain('high<!-- --> confidence')
|
|
37
|
+
expect(html).not.toContain('Possible cause')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -3,7 +3,7 @@ import { Section } from '../ui/drawer-components'
|
|
|
3
3
|
import { Badge } from '../ui/Badge'
|
|
4
4
|
import type { Issue, IssueResourceRef } from './types'
|
|
5
5
|
import { categoryLabel } from './severity'
|
|
6
|
-
import {
|
|
6
|
+
import { diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* ResourceIssuesSection — the compact "Operational Issues" block for the resource
|
|
@@ -14,7 +14,7 @@ import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle } from './dia
|
|
|
14
14
|
*
|
|
15
15
|
* Header mirrors the queue: the plain `categoryLabel` is the operator-facing
|
|
16
16
|
* headline and the raw `reason` rides alongside as a muted signal (so the K8s
|
|
17
|
-
* jargon is available but not the lead). Body is
|
|
17
|
+
* jargon is available but not the lead). Body is primarily the plain
|
|
18
18
|
* `cause` (which names the offending object) + the `Next step` — the diagnosis
|
|
19
19
|
* and the fix. The raw `message`/evidence stays in the queue + MCP where the
|
|
20
20
|
* locator detail is wanted; inline it mostly restated the cause or the category,
|
|
@@ -34,6 +34,7 @@ export function ResourceIssuesSection({
|
|
|
34
34
|
<Section title={`Operational Issues (${issues.length})`} icon={AlertTriangle} defaultExpanded>
|
|
35
35
|
<div className="space-y-3">
|
|
36
36
|
{issues.map((issue) => {
|
|
37
|
+
const parent = issue.incident_parent
|
|
37
38
|
return (
|
|
38
39
|
<div key={issue.id} className="card-inner">
|
|
39
40
|
<div className="mb-1 flex min-w-0 items-baseline gap-2">
|
|
@@ -65,6 +66,24 @@ export function ResourceIssuesSection({
|
|
|
65
66
|
<code className="rounded bg-theme-elevated px-1 font-mono">{issue.remediation_target}</code> — apply it from the GitOps detail page.
|
|
66
67
|
</p>
|
|
67
68
|
) : null}
|
|
69
|
+
{parent ? (
|
|
70
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
71
|
+
{incidentParentLabel(parent.fact_type, parent.confidence)}:{' '}
|
|
72
|
+
{onResourceClick ? (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => onResourceClick(parent.ref)}
|
|
76
|
+
className="group inline-flex items-center gap-1 text-left font-mono hover:text-theme-text-secondary"
|
|
77
|
+
title={confidenceTitle(parent.confidence ?? '')}
|
|
78
|
+
>
|
|
79
|
+
{parent.ref.kind} / {parent.ref.name}
|
|
80
|
+
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100" />
|
|
81
|
+
</button>
|
|
82
|
+
) : (
|
|
83
|
+
<span className="font-mono">{parent.ref.kind} / {parent.ref.name}</span>
|
|
84
|
+
)}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
68
87
|
<CausalContext issue={issue} onResourceClick={onResourceClick} />
|
|
69
88
|
</div>
|
|
70
89
|
)
|
|
@@ -89,7 +108,6 @@ function CausalContext({ issue, onResourceClick }: { issue: Issue; onResourceCli
|
|
|
89
108
|
<div className="mt-2 border-t border-theme-border/60 pt-2">
|
|
90
109
|
<div className="mb-1 flex items-center gap-2">
|
|
91
110
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</span>
|
|
92
|
-
{ctx.role ? <span className="badge-sm text-[10px] text-theme-text-secondary">{diagnosticRoleLabel(ctx.role)}</span> : null}
|
|
93
111
|
</div>
|
|
94
112
|
<ul className="space-y-1.5">
|
|
95
113
|
{links.map((fact, idx) => (
|
|
@@ -16,10 +16,8 @@ export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagno
|
|
|
16
16
|
export {
|
|
17
17
|
ISSUE_SEVERITY_LABEL,
|
|
18
18
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
19
|
-
ISSUE_SEVERITY_FILL_CLASS,
|
|
20
19
|
ISSUE_SEVERITY_TEXT_CLASS,
|
|
21
20
|
ISSUE_SEVERITY_RAIL_CLASS,
|
|
22
|
-
groupBadgeClass,
|
|
23
21
|
categoryLabel,
|
|
24
22
|
groupLabel,
|
|
25
23
|
} from './severity';
|