@skyhook-io/k8s-ui 1.8.9 → 1.8.11
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 +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- 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/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -1,12 +1,17 @@
|
|
|
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
|
+
import { Tooltip } from '../ui/Tooltip';
|
|
4
5
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
5
6
|
import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
|
|
7
|
+
import { issueTiming } from './issue-timing';
|
|
6
8
|
import {
|
|
7
9
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
10
|
+
ISSUE_SEVERITY_HEADER_BAND_CLASS,
|
|
8
11
|
ISSUE_SEVERITY_LABEL,
|
|
9
12
|
ISSUE_SEVERITY_RAIL_CLASS,
|
|
13
|
+
ISSUE_SEVERITY_SOLID_CLASS,
|
|
14
|
+
ISSUE_SEVERITY_TEXT_CLASS,
|
|
10
15
|
categoryLabel,
|
|
11
16
|
groupBadgeClass,
|
|
12
17
|
groupLabel,
|
|
@@ -19,8 +24,25 @@ import {
|
|
|
19
24
|
type Issue,
|
|
20
25
|
type IssueAffected,
|
|
21
26
|
type IssueResourceRef,
|
|
27
|
+
type IssueSeverity,
|
|
22
28
|
} from './types';
|
|
23
29
|
|
|
30
|
+
// Leading severity glyph — the at-a-glance severity cue on every row: critical =
|
|
31
|
+
// octagon (stop), warning = triangle.
|
|
32
|
+
const ISSUE_SEVERITY_ICON: Record<IssueSeverity, ComponentType<{ className?: string }>> = {
|
|
33
|
+
critical: AlertOctagon,
|
|
34
|
+
warning: AlertTriangle,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// An out-of-contract severity the backend might emit is coerced to this tier
|
|
38
|
+
// once, up front (normalizeIssueSeverity), so the icon AND every color map
|
|
39
|
+
// (text/rail/band/pill) resolve together — a raw miss would crash the icon and
|
|
40
|
+
// silently drop the tint everywhere else.
|
|
41
|
+
const ISSUE_SEVERITY_FALLBACK: IssueSeverity = 'warning';
|
|
42
|
+
// Object.hasOwn (not `in`) so inherited keys like "toString" don't slip past.
|
|
43
|
+
const normalizeIssueSeverity = (s: IssueSeverity): IssueSeverity =>
|
|
44
|
+
Object.hasOwn(ISSUE_SEVERITY_ICON, s) ? s : ISSUE_SEVERITY_FALLBACK;
|
|
45
|
+
|
|
24
46
|
export interface IssuesViewProps {
|
|
25
47
|
/** Grouped live issues — one row per subject+category. Typically flattened
|
|
26
48
|
* across the fleet by the host (the hub) or a single cluster (OSS). */
|
|
@@ -40,12 +62,15 @@ export interface IssuesViewProps {
|
|
|
40
62
|
clusterLabel?: (issue: Issue) => string | undefined;
|
|
41
63
|
/** Empty-state CTA shown when there's no data. */
|
|
42
64
|
emptyAction?: ReactNode;
|
|
65
|
+
/** Per-row trailing action, rendered after the severity badge — e.g. the
|
|
66
|
+
* "Diagnose with AI" button in OSS. Omit to render no per-row action. */
|
|
67
|
+
renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
// The queue list. Filtering/faceting is the host page's job (FleetPageShell on
|
|
46
71
|
// the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
|
|
47
72
|
// no-data terminal states only.
|
|
48
|
-
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
|
|
73
|
+
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction, renderActions }: IssuesViewProps) {
|
|
49
74
|
// Single-open accordion: opening a row collapses the previous one, so the
|
|
50
75
|
// queue stays scannable and you never lose your place to a wall of expansions.
|
|
51
76
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
@@ -84,6 +109,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
84
109
|
onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
|
|
85
110
|
resourceHref={resourceHref}
|
|
86
111
|
onResourceClick={onResourceClick}
|
|
112
|
+
renderActions={renderActions}
|
|
87
113
|
/>
|
|
88
114
|
);
|
|
89
115
|
})}
|
|
@@ -132,7 +158,10 @@ export function IssueRow({
|
|
|
132
158
|
const { headline } = issueMessageParts(issue);
|
|
133
159
|
const [renderDetails, setRenderDetails] = useState(open);
|
|
134
160
|
const Container = as;
|
|
161
|
+
const severity = normalizeIssueSeverity(issue.severity);
|
|
162
|
+
const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
|
|
135
163
|
const slotCtx = { issue, open };
|
|
164
|
+
const timing = issueTiming(issue);
|
|
136
165
|
|
|
137
166
|
useEffect(() => {
|
|
138
167
|
if (open) {
|
|
@@ -148,15 +177,23 @@ export function IssueRow({
|
|
|
148
177
|
return (
|
|
149
178
|
<Container
|
|
150
179
|
className={[
|
|
151
|
-
'overflow-hidden rounded-xl border
|
|
180
|
+
'overflow-hidden rounded-xl border bg-theme-surface transition-[border-color,box-shadow] duration-200',
|
|
181
|
+
// The open card lifts via elevation — heavier shadow + a bright
|
|
182
|
+
// emphasis edge that clearly separates it from sibling cards. Severity
|
|
183
|
+
// stays rationed to the band + pill; separation is depth, not more color.
|
|
184
|
+
// ring-1 widens the edge to 2px without the layout shift a border-2
|
|
185
|
+
// swap would cause on expand.
|
|
186
|
+
open ? 'border-[var(--border-emphasis)] ring-1 ring-[var(--border-emphasis)] shadow-theme-md' : 'border-theme-border shadow-theme-sm',
|
|
152
187
|
dimmed ? 'opacity-60' : '',
|
|
153
188
|
className ?? '',
|
|
154
189
|
].filter(Boolean).join(' ')}
|
|
155
190
|
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
|
|
156
191
|
>
|
|
157
|
-
{/* The whole header is the single toggle target
|
|
158
|
-
|
|
159
|
-
expanded body (a link nested in a button would
|
|
192
|
+
{/* The whole header is the single toggle target. The leading severity
|
|
193
|
+
icon is the at-a-glance cue; a trailing chevron shows open/closed.
|
|
194
|
+
Deep-links live in the expanded body (a link nested in a button would
|
|
195
|
+
be invalid). Collapsed: neutral row + rail. Expanded: severity-tinted
|
|
196
|
+
band + solid pill — the tint is a focus signal, not per-row alarm. */}
|
|
160
197
|
<div
|
|
161
198
|
role="button"
|
|
162
199
|
tabIndex={0}
|
|
@@ -169,28 +206,32 @@ export function IssueRow({
|
|
|
169
206
|
onToggle();
|
|
170
207
|
}
|
|
171
208
|
}}
|
|
172
|
-
className={`group flex cursor-pointer items-center gap-3 border-l-
|
|
209
|
+
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
210
|
>
|
|
174
|
-
<
|
|
211
|
+
<SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
|
|
175
212
|
|
|
176
213
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
177
214
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
178
215
|
<span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
|
|
179
|
-
<span className={`
|
|
216
|
+
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
180
217
|
{renderBadges?.(slotCtx)}
|
|
181
|
-
{/* The detector reason
|
|
182
|
-
|
|
183
|
-
the
|
|
184
|
-
|
|
218
|
+
{/* The detector reason rides the title row while COLLAPSED so the
|
|
219
|
+
key triage signal shows without expanding. When open, the full
|
|
220
|
+
cause lives in the WHAT'S WRONG section below, so it fades out
|
|
221
|
+
here (stays mounted — it's the flex-1 filler, so unmounting
|
|
222
|
+
wouldn't reflow anything, but fading avoids the pop). */}
|
|
185
223
|
{issue.reason ? (
|
|
186
|
-
<span
|
|
224
|
+
<span
|
|
225
|
+
aria-hidden={open || undefined}
|
|
226
|
+
className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
|
|
227
|
+
>
|
|
187
228
|
<span className="font-medium text-theme-text-secondary">{issue.reason}</span>
|
|
188
229
|
{headline ? <span> — {headline}</span> : null}
|
|
189
230
|
</span>
|
|
190
231
|
) : null}
|
|
191
232
|
</div>
|
|
192
233
|
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
193
|
-
<span className=
|
|
234
|
+
<span className={KIND_CHIP_CLASS}>{issue.kind}</span>
|
|
194
235
|
<span className="min-w-0 truncate font-medium text-theme-text-secondary">
|
|
195
236
|
{issue.namespace ? `${issue.namespace} / ` : ''}
|
|
196
237
|
{issue.name}
|
|
@@ -214,37 +255,45 @@ export function IssueRow({
|
|
|
214
255
|
<span aria-hidden>·</span>
|
|
215
256
|
{/* Non-interactive signal (the header is the toggle — a nested
|
|
216
257
|
button would be invalid); the clickable link lives in the body. */}
|
|
217
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
258
|
+
<Tooltip content={confidenceTitle(issue.incident_parent.confidence ?? '')} delay={200} wrapperClassName="min-w-0">
|
|
259
|
+
<span className="truncate text-theme-text-tertiary">
|
|
260
|
+
↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
|
|
261
|
+
<span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
|
|
262
|
+
</span>
|
|
263
|
+
</Tooltip>
|
|
221
264
|
</>
|
|
222
265
|
) : null}
|
|
223
266
|
{renderMeta?.(slotCtx)}
|
|
224
267
|
</div>
|
|
225
268
|
</div>
|
|
226
269
|
|
|
227
|
-
{/*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
270
|
+
{/* Right cluster — severity pill THEN age, vertically centered as a group. */}
|
|
271
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
272
|
+
<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]}`}>
|
|
273
|
+
{ISSUE_SEVERITY_LABEL[severity]}
|
|
274
|
+
</span>
|
|
275
|
+
{/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
|
|
276
|
+
{issue.first_seen ? (
|
|
277
|
+
<>
|
|
278
|
+
<Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
|
|
279
|
+
<time
|
|
280
|
+
dateTime={issue.first_seen}
|
|
281
|
+
className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
282
|
+
>
|
|
283
|
+
<Clock className="h-3 w-3" aria-hidden />
|
|
284
|
+
{formatCompactAge(issue.first_seen)}
|
|
285
|
+
</time>
|
|
286
|
+
</Tooltip>
|
|
287
|
+
{timing ? (
|
|
288
|
+
<Tooltip content={timing.tooltip} delay={200}>
|
|
289
|
+
<span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
|
|
290
|
+
</Tooltip>
|
|
291
|
+
) : null}
|
|
292
|
+
</>
|
|
293
|
+
) : null}
|
|
294
|
+
{renderActions?.(slotCtx)}
|
|
295
|
+
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
296
|
+
</div>
|
|
248
297
|
</div>
|
|
249
298
|
|
|
250
299
|
{renderDetails ? (
|
|
@@ -257,17 +306,21 @@ export function IssueRow({
|
|
|
257
306
|
}}
|
|
258
307
|
>
|
|
259
308
|
<div className="overflow-hidden">
|
|
260
|
-
|
|
261
|
-
|
|
309
|
+
{/* Body sits on the card surface (not a recessed grey panel) so its
|
|
310
|
+
text keeps enough contrast. */}
|
|
311
|
+
<div className="border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4">
|
|
312
|
+
<div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
|
262
313
|
<Diagnosis issue={issue} />
|
|
263
314
|
{issue.incident_parent ? (
|
|
264
315
|
<section className="flex flex-col gap-1">
|
|
265
316
|
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
266
317
|
{incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
|
|
267
318
|
{issue.incident_parent.confidence ? (
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
319
|
+
<Tooltip content={confidenceTitle(issue.incident_parent.confidence)} delay={200}>
|
|
320
|
+
<span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary">
|
|
321
|
+
{issue.incident_parent.confidence} confidence
|
|
322
|
+
</span>
|
|
323
|
+
</Tooltip>
|
|
271
324
|
) : null}
|
|
272
325
|
</h4>
|
|
273
326
|
<ul className="flex flex-col gap-px">
|
|
@@ -276,9 +329,7 @@ export function IssueRow({
|
|
|
276
329
|
</section>
|
|
277
330
|
) : null}
|
|
278
331
|
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
279
|
-
<
|
|
280
|
-
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
281
|
-
</div>
|
|
332
|
+
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
282
333
|
</div>
|
|
283
334
|
</div>
|
|
284
335
|
</div>
|
|
@@ -288,8 +339,9 @@ export function IssueRow({
|
|
|
288
339
|
);
|
|
289
340
|
}
|
|
290
341
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
342
|
+
// Diagnosis: WHAT'S WRONG (amber) → NEXT STEP (emerald) → RAW ERROR (monochrome
|
|
343
|
+
// terminal block). Status/timing facts share one muted meta line under the
|
|
344
|
+
// diagnosis so the body reads as three beats rather than a stack of one-liners.
|
|
293
345
|
function Diagnosis({ issue }: { issue: Issue }) {
|
|
294
346
|
const crash =
|
|
295
347
|
issue.restart_count || issue.last_terminated_reason
|
|
@@ -298,71 +350,84 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
298
350
|
.join(' · ')
|
|
299
351
|
: null;
|
|
300
352
|
const { headline, detail } = issueMessageParts(issue);
|
|
301
|
-
//
|
|
302
|
-
//
|
|
353
|
+
// Raw detector text → terminal block. Two independent sources: the explicit
|
|
354
|
+
// raw_message (or, for a derived cause, the original message it came from) and
|
|
355
|
+
// the precise kubelet/containerd `detail` a short image-pull headline replaced
|
|
356
|
+
// — both surface when distinct, so setting raw_message never hides the CRI
|
|
357
|
+
// detail. Each is kept only when it adds something the prose above doesn't
|
|
358
|
+
// already show, compared against what's actually rendered (the cause when
|
|
359
|
+
// present, else headline+detail) rather than a string that may never appear.
|
|
303
360
|
const visibleMessage = [headline, detail].filter(Boolean).join(' ');
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
361
|
+
const shownText = issue.cause ?? visibleMessage;
|
|
362
|
+
const timing = issueTiming(issue);
|
|
363
|
+
const rawError = [
|
|
364
|
+
...new Set(
|
|
365
|
+
[issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
|
|
366
|
+
(s): s is string => !!s && s !== shownText,
|
|
367
|
+
),
|
|
368
|
+
),
|
|
369
|
+
].join('\n');
|
|
370
|
+
|
|
371
|
+
// One muted line — stuck/retry · crash · timing · change. A retrying-but-not-
|
|
372
|
+
// yet-stuck operation leads with "Retrying"; once it's declared stuck, "Stuck"
|
|
373
|
+
// takes over.
|
|
374
|
+
const meta: string[] = [];
|
|
375
|
+
if (issue.stuck) meta.push('Stuck');
|
|
376
|
+
else if (issue.operation_retry_count) meta.push('Retrying');
|
|
377
|
+
if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
|
|
378
|
+
if (crash) meta.push(crash);
|
|
379
|
+
if (timing) {
|
|
380
|
+
meta.push(timing.meta);
|
|
381
|
+
} else if (issue.first_seen) {
|
|
382
|
+
meta.push(`started ${formatRelativeAgeTime(issue.first_seen)}`);
|
|
383
|
+
}
|
|
384
|
+
if (issue.first_seen) {
|
|
385
|
+
if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
386
|
+
}
|
|
387
|
+
if (issue.change_context) meta.push(changeContextText(issue.change_context));
|
|
388
|
+
|
|
389
|
+
const hasNextStep = !!issue.action || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
|
|
390
|
+
|
|
308
391
|
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>
|
|
392
|
+
<div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
|
393
|
+
{/* When a parsed cause replaces the detector text, the terse reason code
|
|
394
|
+
(CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
|
|
395
|
+
identifier operators anchor on, and the collapsed header drops it while
|
|
396
|
+
open. The non-cause branch already leads with it in the prose. */}
|
|
397
|
+
<CardSection
|
|
398
|
+
icon={AlertTriangle}
|
|
399
|
+
label="What's wrong"
|
|
400
|
+
tone="warn"
|
|
401
|
+
labelExtra={issue.cause && issue.reason ? `· ${issue.reason}` : undefined}
|
|
402
|
+
>
|
|
403
|
+
{issue.cause ? (
|
|
404
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
|
|
405
|
+
) : (
|
|
406
|
+
<p className="text-sm leading-relaxed text-theme-text-primary">
|
|
407
|
+
<span className="font-medium">{issue.reason}</span>
|
|
408
|
+
{headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
|
|
409
|
+
</p>
|
|
410
|
+
)}
|
|
411
|
+
{meta.length > 0 ? <p className="text-xs leading-relaxed text-theme-text-tertiary tabular-nums">{meta.join(' · ')}</p> : null}
|
|
412
|
+
</CardSection>
|
|
413
|
+
|
|
414
|
+
{hasNextStep ? (
|
|
415
|
+
<CardSection icon={ArrowRight} label="Next step" tone="fix">
|
|
416
|
+
{issue.action ? <CardBody>{issue.action}</CardBody> : null}
|
|
417
|
+
{issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
|
|
418
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
419
|
+
Suggested fix: create namespace <code className="inline-code">{issue.remediation_target}</code> — apply it from the GitOps detail page.
|
|
420
|
+
</p>
|
|
421
|
+
) : null}
|
|
422
|
+
</CardSection>
|
|
352
423
|
) : 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>
|
|
424
|
+
|
|
425
|
+
{rawError ? (
|
|
426
|
+
<CardSection icon={Terminal} label="Raw error">
|
|
427
|
+
<TerminalBlock>{rawError}</TerminalBlock>
|
|
428
|
+
</CardSection>
|
|
364
429
|
) : null}
|
|
365
|
-
</
|
|
430
|
+
</div>
|
|
366
431
|
);
|
|
367
432
|
}
|
|
368
433
|
|
|
@@ -387,23 +452,22 @@ function DiagnosticContext({
|
|
|
387
452
|
if (!ctx || facts.length === 0) return null;
|
|
388
453
|
|
|
389
454
|
return (
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
455
|
+
<CardSection
|
|
456
|
+
icon={Workflow}
|
|
457
|
+
label="Context"
|
|
458
|
+
labelExtra={ctx.role ? diagnosticRoleLabel(ctx.role) : undefined}
|
|
459
|
+
>
|
|
395
460
|
<ul className="flex flex-col gap-2">
|
|
396
461
|
{facts.map((fact, idx) => (
|
|
397
462
|
<li key={`${fact.type}-${idx}`} className="flex flex-col gap-1.5 rounded-md border border-theme-border/70 px-2.5 py-2">
|
|
398
463
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
399
464
|
<span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
|
|
400
465
|
{fact.confidence ? (
|
|
401
|
-
<
|
|
402
|
-
className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
</span>
|
|
466
|
+
<Tooltip content={confidenceTitle(fact.confidence)} delay={200}>
|
|
467
|
+
<span className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary">
|
|
468
|
+
{fact.confidence} confidence
|
|
469
|
+
</span>
|
|
470
|
+
</Tooltip>
|
|
407
471
|
) : null}
|
|
408
472
|
{fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
|
|
409
473
|
</div>
|
|
@@ -438,7 +502,7 @@ function DiagnosticContext({
|
|
|
438
502
|
</li>
|
|
439
503
|
))}
|
|
440
504
|
</ul>
|
|
441
|
-
</
|
|
505
|
+
</CardSection>
|
|
442
506
|
);
|
|
443
507
|
}
|
|
444
508
|
|
|
@@ -446,11 +510,8 @@ function DiagnosticContext({
|
|
|
446
510
|
// freshness, the two facts the compact "2h" hides.
|
|
447
511
|
function ageTitle(issue: Issue): string {
|
|
448
512
|
const parts: string[] = [];
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
} else if (issue.issue_timing === 'started_after_resource_was_healthy') {
|
|
452
|
-
parts.push('Resource was previously healthy');
|
|
453
|
-
}
|
|
513
|
+
const timing = issueTiming(issue);
|
|
514
|
+
if (timing) parts.push(timing.tooltip);
|
|
454
515
|
if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
|
|
455
516
|
if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
456
517
|
return parts.join('\n');
|
|
@@ -472,18 +533,15 @@ function AffectedResources({
|
|
|
472
533
|
// grouping.go); fall back to the inline member count, not +1.
|
|
473
534
|
const total = issue.count ?? members.length;
|
|
474
535
|
return (
|
|
475
|
-
<section className="flex flex-col gap-
|
|
536
|
+
<section className="flex flex-col gap-3">
|
|
476
537
|
{/* The subject (the grouped thing — e.g. the Deployment) is always the
|
|
477
538
|
first deep-link; members (the folded pods) follow. ResourceLine emits
|
|
478
539
|
an <li>, so it needs a list parent of its own. */}
|
|
479
540
|
<ul className="flex flex-col gap-px">
|
|
480
|
-
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
541
|
+
<ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
481
542
|
</ul>
|
|
482
543
|
{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>
|
|
544
|
+
<CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
|
|
487
545
|
<ul className="flex flex-col gap-px">
|
|
488
546
|
{members.map((m, i) => (
|
|
489
547
|
<ResourceLine
|
|
@@ -496,11 +554,11 @@ function AffectedResources({
|
|
|
496
554
|
))}
|
|
497
555
|
</ul>
|
|
498
556
|
{issue.members_truncated && (
|
|
499
|
-
<p className="
|
|
557
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
500
558
|
Showing {members.length} of {total} — open the subject to see the rest.
|
|
501
559
|
</p>
|
|
502
560
|
)}
|
|
503
|
-
|
|
561
|
+
</CardSection>
|
|
504
562
|
)}
|
|
505
563
|
</section>
|
|
506
564
|
);
|
|
@@ -513,6 +571,7 @@ function ResourceLine({
|
|
|
513
571
|
resourceHref,
|
|
514
572
|
onResourceClick,
|
|
515
573
|
ResourceLinkIcon,
|
|
574
|
+
compact,
|
|
516
575
|
}: {
|
|
517
576
|
label?: string;
|
|
518
577
|
refForLink: IssueResourceRef;
|
|
@@ -520,24 +579,41 @@ function ResourceLine({
|
|
|
520
579
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
521
580
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
522
581
|
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
582
|
+
// Footer variant (the Subject row): renders the kind as a grey mono chip and
|
|
583
|
+
// a slightly bolder name, distinguishing the subject from the affected-members
|
|
584
|
+
// list below it.
|
|
585
|
+
compact?: boolean;
|
|
523
586
|
}) {
|
|
524
587
|
const r = refForLink;
|
|
525
588
|
const linkable = !!(onResourceClick || resourceHref);
|
|
526
589
|
const body = (
|
|
527
590
|
<>
|
|
528
|
-
{label ? <span className="shrink-0 text-[
|
|
529
|
-
|
|
530
|
-
|
|
591
|
+
{label ? <span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-theme-text-tertiary">{label}</span> : null}
|
|
592
|
+
{/* Footer (compact): the kind is a grey mono chip; member rows keep plain mono. */}
|
|
593
|
+
{compact ? (
|
|
594
|
+
<span className={KIND_CHIP_CLASS}>{r.kind}</span>
|
|
595
|
+
) : (
|
|
596
|
+
<span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
|
|
597
|
+
)}
|
|
598
|
+
<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
599
|
{r.namespace ? `${r.namespace} / ` : ''}
|
|
532
600
|
{r.name}
|
|
533
601
|
</span>
|
|
534
602
|
{count && count > 1 ? (
|
|
535
|
-
<
|
|
603
|
+
<Tooltip content={`${count} affected resources grouped under this issue`} delay={200}>
|
|
604
|
+
<span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums">{count} affected</span>
|
|
605
|
+
</Tooltip>
|
|
536
606
|
) : null}
|
|
537
607
|
{linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
538
608
|
</>
|
|
539
609
|
);
|
|
540
|
-
|
|
610
|
+
// compact = Subject row; default = the affected-members list. Both get the
|
|
611
|
+
// padded, rounded hover hit-box so the Subject reads as clickable like the
|
|
612
|
+
// members below it. items-baseline so the label + kind chip sit on the same
|
|
613
|
+
// baseline as the larger resource name.
|
|
614
|
+
const cls = compact
|
|
615
|
+
? 'group/r flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left transition-colors hover:bg-theme-hover/60'
|
|
616
|
+
: '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
617
|
return (
|
|
542
618
|
<li>
|
|
543
619
|
{onResourceClick ? (
|
|
@@ -549,7 +625,7 @@ function ResourceLine({
|
|
|
549
625
|
{body}
|
|
550
626
|
</a>
|
|
551
627
|
) : (
|
|
552
|
-
<span className=
|
|
628
|
+
<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
629
|
)}
|
|
554
630
|
</li>
|
|
555
631
|
);
|
|
@@ -0,0 +1,40 @@
|
|
|
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('uses the Issues row treatment and defaults collapsed', () => {
|
|
32
|
+
const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
|
|
33
|
+
|
|
34
|
+
expect(html).toContain('Operational issues')
|
|
35
|
+
expect(html).toContain('StorageClassMissing')
|
|
36
|
+
expect(html).not.toContain('What's wrong')
|
|
37
|
+
expect(html).not.toContain('Context')
|
|
38
|
+
expect(html).not.toContain('Blocked pods')
|
|
39
|
+
})
|
|
40
|
+
})
|