@skyhook-io/k8s-ui 1.8.10 → 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/ApplicationsView.tsx +62 -10
- 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 +2 -3
- package/src/components/checks/severity.ts +14 -10
- 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 +3 -3
- 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 +51 -38
- package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
- package/src/components/issues/ResourceIssuesSection.tsx +28 -146
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +2 -0
- 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 +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- 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/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/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/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +4 -0
- package/src/components/ui/severity-tone.ts +11 -4
- 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 +1 -1
- package/src/types/core.ts +57 -4
- 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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// GitOpsInsightViews.tsx so they can be unit-tested without importing JSX.
|
|
3
3
|
|
|
4
4
|
import { SEVERITY_DOT, type Severity } from '../../../utils/badge-colors'
|
|
5
|
-
import type { GitOpsHistoryItem } from '../../../types'
|
|
5
|
+
import type { GitOpsChange, GitOpsHistoryItem } from '../../../types'
|
|
6
6
|
import type { SyncStatus, GitOpsHealthStatus } from '../../../types/gitops'
|
|
7
7
|
|
|
8
8
|
const SYNC_STATUS_SET = new Set<SyncStatus>(['Synced', 'OutOfSync', 'Reconciling', 'Unknown'])
|
|
@@ -23,6 +23,74 @@ export function normalizeHealthStatus(value: string | undefined | null): GitOpsH
|
|
|
23
23
|
return 'Unknown'
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// --- Resources table: filtering + sorting -------------------------------
|
|
27
|
+
// Pure predicates + rank functions backing the Resources list's search box,
|
|
28
|
+
// status facets, and sortable columns. Kept here (not inline in the view) so
|
|
29
|
+
// the sort/filter behavior is unit-testable without rendering.
|
|
30
|
+
|
|
31
|
+
// The status facets an operator filters the Resources list by — the three
|
|
32
|
+
// states worth isolating on a broadly-drifted app. (These replaced the
|
|
33
|
+
// per-resource OutOfSync "issues" that used to restate the whole table.)
|
|
34
|
+
export type ResourceStatusFacet = 'outOfSync' | 'degraded' | 'missing'
|
|
35
|
+
|
|
36
|
+
export type ResourceSortKey = 'order' | 'name' | 'sync' | 'health'
|
|
37
|
+
|
|
38
|
+
// changeSync/changeHealth normalize a change's raw backend strings onto the
|
|
39
|
+
// badge unions. `sync` falls back to `category` to match how ChangeRow and
|
|
40
|
+
// the sync badge resolve it.
|
|
41
|
+
export function changeSync(change: GitOpsChange): SyncStatus {
|
|
42
|
+
return normalizeSyncStatus(change.sync ?? change.category)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
|
|
46
|
+
return normalizeHealthStatus(change.health)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Rank sync/health so an ascending sort surfaces the states an operator cares
|
|
50
|
+
// about first (drift, then failures). Mirrors the GitOps application table's
|
|
51
|
+
// syncRank/healthRank so the two tables order status the same way.
|
|
52
|
+
export function syncStatusRank(sync: SyncStatus): number {
|
|
53
|
+
return ({ OutOfSync: 0, Reconciling: 1, Unknown: 2, Synced: 3 } as Record<SyncStatus, number>)[sync]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function healthStatusRank(health: GitOpsHealthStatus): number {
|
|
57
|
+
return ({ Missing: 0, Degraded: 1, Progressing: 2, Unknown: 3, Suspended: 4, Healthy: 5 } as Record<GitOpsHealthStatus, number>)[health]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function changeMatchesSearch(change: GitOpsChange, query: string): boolean {
|
|
61
|
+
const q = query.trim().toLowerCase()
|
|
62
|
+
if (!q) return true
|
|
63
|
+
const { kind, name, namespace, group } = change.ref
|
|
64
|
+
return [kind, name, namespace, group].some((s) => s?.toLowerCase().includes(q))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Union match: a change passes when it satisfies ANY active facet (empty set
|
|
68
|
+
// = no filter). OutOfSync keys off sync status; Degraded/Missing off health.
|
|
69
|
+
export function changeMatchesFacets(change: GitOpsChange, facets: Set<ResourceStatusFacet>): boolean {
|
|
70
|
+
if (facets.size === 0) return true
|
|
71
|
+
if (facets.has('outOfSync') && changeSync(change) === 'OutOfSync') return true
|
|
72
|
+
if (facets.has('degraded') && changeHealth(change) === 'Degraded') return true
|
|
73
|
+
if (facets.has('missing') && changeHealth(change) === 'Missing') return true
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ResourceStatusCounts {
|
|
78
|
+
outOfSync: number
|
|
79
|
+
degraded: number
|
|
80
|
+
missing: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resourceStatusCounts(changes: GitOpsChange[]): ResourceStatusCounts {
|
|
84
|
+
const counts: ResourceStatusCounts = { outOfSync: 0, degraded: 0, missing: 0 }
|
|
85
|
+
for (const c of changes) {
|
|
86
|
+
if (changeSync(c) === 'OutOfSync') counts.outOfSync++
|
|
87
|
+
const health = changeHealth(c)
|
|
88
|
+
if (health === 'Degraded') counts.degraded++
|
|
89
|
+
if (health === 'Missing') counts.missing++
|
|
90
|
+
}
|
|
91
|
+
return counts
|
|
92
|
+
}
|
|
93
|
+
|
|
26
94
|
// Map GitOps-flavored vocabulary (Argo/Flux phase strings, insight Issue
|
|
27
95
|
// severities) onto the canonical Severity tokens used by SEVERITY_BADGE /
|
|
28
96
|
// SEVERITY_TEXT / SEVERITY_DOT. Centralizing this keeps call sites from
|
|
@@ -17,12 +17,14 @@ import {
|
|
|
17
17
|
import '@xyflow/react/dist/style.css'
|
|
18
18
|
import { AlertTriangle, ChevronRight, Maximize, Search, X } from 'lucide-react'
|
|
19
19
|
import { PaneLoader } from '../../ui/PaneLoader'
|
|
20
|
+
import { Input } from '../../ui/Input'
|
|
20
21
|
import { clsx } from 'clsx'
|
|
21
22
|
|
|
22
23
|
import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
|
|
23
24
|
import { displayKind } from '../../../types'
|
|
24
25
|
import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
25
26
|
import { formatCompactAge } from '../../../utils/format'
|
|
27
|
+
import { getTopologyIcon } from '../../../utils/resource-icons'
|
|
26
28
|
import { Tooltip } from '../../ui/Tooltip'
|
|
27
29
|
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
28
30
|
|
|
@@ -252,7 +254,7 @@ function GitOpsTreeToolbar({
|
|
|
252
254
|
</div>
|
|
253
255
|
<div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 px-2 py-1.5 backdrop-blur">
|
|
254
256
|
<Search className="h-3.5 w-3.5 text-theme-text-tertiary" />
|
|
255
|
-
<
|
|
257
|
+
<Input
|
|
256
258
|
value={query}
|
|
257
259
|
onChange={(event) => onQueryChange(event.target.value)}
|
|
258
260
|
placeholder="Find node..."
|
|
@@ -549,6 +551,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
549
551
|
const gitopsTool = gitopsToolForNode(node)
|
|
550
552
|
const chips = buildChips(node)
|
|
551
553
|
const dim = getNodeDimensions(node)
|
|
554
|
+
const KindIcon = getTopologyIcon(kind)
|
|
552
555
|
|
|
553
556
|
return (
|
|
554
557
|
<>
|
|
@@ -584,7 +587,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
584
587
|
>
|
|
585
588
|
<div className="px-3 py-2.5">
|
|
586
589
|
<div className="mb-1 flex items-center gap-1.5">
|
|
587
|
-
<
|
|
590
|
+
<KindIcon className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
|
|
588
591
|
<span className="truncate text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
589
592
|
{node.role === 'group' ? displayKind((node.data?.groupedKind as string) || kind) : displayKind(kind)}
|
|
590
593
|
</span>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
2
2
|
import { AlertOctagon, AlertTriangle, ArrowRight, ChevronRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
|
|
3
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,
|
|
8
10
|
ISSUE_SEVERITY_HEADER_BAND_CLASS,
|
|
@@ -60,12 +62,15 @@ export interface IssuesViewProps {
|
|
|
60
62
|
clusterLabel?: (issue: Issue) => string | undefined;
|
|
61
63
|
/** Empty-state CTA shown when there's no data. */
|
|
62
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;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
// The queue list. Filtering/faceting is the host page's job (FleetPageShell on
|
|
66
71
|
// the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
|
|
67
72
|
// no-data terminal states only.
|
|
68
|
-
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
|
|
73
|
+
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction, renderActions }: IssuesViewProps) {
|
|
69
74
|
// Single-open accordion: opening a row collapses the previous one, so the
|
|
70
75
|
// queue stays scannable and you never lose your place to a wall of expansions.
|
|
71
76
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
@@ -104,6 +109,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
104
109
|
onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
|
|
105
110
|
resourceHref={resourceHref}
|
|
106
111
|
onResourceClick={onResourceClick}
|
|
112
|
+
renderActions={renderActions}
|
|
107
113
|
/>
|
|
108
114
|
);
|
|
109
115
|
})}
|
|
@@ -155,6 +161,7 @@ export function IssueRow({
|
|
|
155
161
|
const severity = normalizeIssueSeverity(issue.severity);
|
|
156
162
|
const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
|
|
157
163
|
const slotCtx = { issue, open };
|
|
164
|
+
const timing = issueTiming(issue);
|
|
158
165
|
|
|
159
166
|
useEffect(() => {
|
|
160
167
|
if (open) {
|
|
@@ -248,10 +255,12 @@ export function IssueRow({
|
|
|
248
255
|
<span aria-hidden>·</span>
|
|
249
256
|
{/* Non-interactive signal (the header is the toggle — a nested
|
|
250
257
|
button would be invalid); the clickable link lives in the body. */}
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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>
|
|
255
264
|
</>
|
|
256
265
|
) : null}
|
|
257
266
|
{renderMeta?.(slotCtx)}
|
|
@@ -265,17 +274,22 @@ export function IssueRow({
|
|
|
265
274
|
</span>
|
|
266
275
|
{/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
|
|
267
276
|
{issue.first_seen ? (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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>
|
|
277
291
|
) : null}
|
|
278
|
-
|
|
292
|
+
</>
|
|
279
293
|
) : null}
|
|
280
294
|
{renderActions?.(slotCtx)}
|
|
281
295
|
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
@@ -302,9 +316,11 @@ export function IssueRow({
|
|
|
302
316
|
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
303
317
|
{incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
|
|
304
318
|
{issue.incident_parent.confidence ? (
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
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>
|
|
308
324
|
) : null}
|
|
309
325
|
</h4>
|
|
310
326
|
<ul className="flex flex-col gap-px">
|
|
@@ -343,6 +359,7 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
343
359
|
// present, else headline+detail) rather than a string that may never appear.
|
|
344
360
|
const visibleMessage = [headline, detail].filter(Boolean).join(' ');
|
|
345
361
|
const shownText = issue.cause ?? visibleMessage;
|
|
362
|
+
const timing = issueTiming(issue);
|
|
346
363
|
const rawError = [
|
|
347
364
|
...new Set(
|
|
348
365
|
[issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
|
|
@@ -359,15 +376,13 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
359
376
|
else if (issue.operation_retry_count) meta.push('Retrying');
|
|
360
377
|
if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
|
|
361
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
|
+
}
|
|
362
384
|
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)}`);
|
|
385
|
+
if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
371
386
|
}
|
|
372
387
|
if (issue.change_context) meta.push(changeContextText(issue.change_context));
|
|
373
388
|
|
|
@@ -448,12 +463,11 @@ function DiagnosticContext({
|
|
|
448
463
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
449
464
|
<span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
|
|
450
465
|
{fact.confidence ? (
|
|
451
|
-
<
|
|
452
|
-
className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary"
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
</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>
|
|
457
471
|
) : null}
|
|
458
472
|
{fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
|
|
459
473
|
</div>
|
|
@@ -496,11 +510,8 @@ function DiagnosticContext({
|
|
|
496
510
|
// freshness, the two facts the compact "2h" hides.
|
|
497
511
|
function ageTitle(issue: Issue): string {
|
|
498
512
|
const parts: string[] = [];
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
} else if (issue.issue_timing === 'started_after_resource_was_healthy') {
|
|
502
|
-
parts.push('Resource was previously healthy');
|
|
503
|
-
}
|
|
513
|
+
const timing = issueTiming(issue);
|
|
514
|
+
if (timing) parts.push(timing.tooltip);
|
|
504
515
|
if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
|
|
505
516
|
if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
506
517
|
return parts.join('\n');
|
|
@@ -589,7 +600,9 @@ function ResourceLine({
|
|
|
589
600
|
{r.name}
|
|
590
601
|
</span>
|
|
591
602
|
{count && count > 1 ? (
|
|
592
|
-
<
|
|
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>
|
|
593
606
|
) : null}
|
|
594
607
|
{linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
595
608
|
</>
|
|
@@ -28,12 +28,13 @@ const issue: Issue = {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
describe('ResourceIssuesSection', () => {
|
|
31
|
-
it('
|
|
31
|
+
it('uses the Issues row treatment and defaults collapsed', () => {
|
|
32
32
|
const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
|
|
33
33
|
|
|
34
|
-
expect(html).toContain('
|
|
35
|
-
expect(html).toContain('
|
|
36
|
-
expect(html).toContain('
|
|
37
|
-
expect(html).not.toContain('
|
|
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')
|
|
38
39
|
})
|
|
39
40
|
})
|
|
@@ -1,26 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import type
|
|
5
|
-
import { categoryLabel } from './severity'
|
|
6
|
-
import { diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic'
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { AlertTriangle } from 'lucide-react'
|
|
3
|
+
import { IssueRow } from './IssuesView'
|
|
4
|
+
import { compareIssues, type Issue, type IssueResourceRef } from './types'
|
|
7
5
|
|
|
8
|
-
/**
|
|
9
|
-
* ResourceIssuesSection — the compact "Operational Issues" block for the resource
|
|
10
|
-
* detail. Renders the live, diagnosed Issues that touch one resource (its own +,
|
|
11
|
-
* for a workload, its owned pods' issues, server-rolled-up via RelatedIssues).
|
|
12
|
-
* The diagnosis sibling of IssuesView's queue row — same Cause/Action vocabulary,
|
|
13
|
-
* stripped of the queue chrome (accordion, scope copy, fan-out expansion).
|
|
14
|
-
*
|
|
15
|
-
* Header mirrors the queue: the plain `categoryLabel` is the operator-facing
|
|
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 primarily the plain
|
|
18
|
-
* `cause` (which names the offending object) + the `Next step` — the diagnosis
|
|
19
|
-
* and the fix. The raw `message`/evidence stays in the queue + MCP where the
|
|
20
|
-
* locator detail is wanted; inline it mostly restated the cause or the category,
|
|
21
|
-
* so it's omitted to keep the card scannable. (`message` is the body fallback
|
|
22
|
-
* only for categories that don't yet emit a `cause`.)
|
|
23
|
-
*/
|
|
24
6
|
export function ResourceIssuesSection({
|
|
25
7
|
issues,
|
|
26
8
|
onResourceClick,
|
|
@@ -29,135 +11,35 @@ export function ResourceIssuesSection({
|
|
|
29
11
|
/** When provided, related resources in a causal link become clickable. */
|
|
30
12
|
onResourceClick?: (ref: IssueResourceRef) => void
|
|
31
13
|
}) {
|
|
32
|
-
|
|
14
|
+
const sorted = useMemo(() => [...(issues ?? [])].sort(compareIssues), [issues])
|
|
15
|
+
const [openId, setOpenId] = useState<string | null>(null)
|
|
16
|
+
|
|
17
|
+
if (sorted.length === 0) return null
|
|
18
|
+
|
|
33
19
|
return (
|
|
34
|
-
<
|
|
35
|
-
<div className="
|
|
36
|
-
|
|
37
|
-
|
|
20
|
+
<section className="space-y-2">
|
|
21
|
+
<div className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
|
|
22
|
+
<AlertTriangle className="h-4 w-4 text-theme-text-tertiary" aria-hidden />
|
|
23
|
+
<span>Operational issues ({sorted.length})</span>
|
|
24
|
+
</div>
|
|
25
|
+
<ol className="flex flex-col gap-1.5">
|
|
26
|
+
{sorted.map((issue) => {
|
|
27
|
+
const key = issueKey(issue)
|
|
38
28
|
return (
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<span className="min-w-0 flex-1 truncate text-xs text-theme-text-tertiary">{issue.reason}</span>
|
|
47
|
-
) : null}
|
|
48
|
-
{issue.count ? (
|
|
49
|
-
<span className="shrink-0 text-xs text-theme-text-tertiary tabular-nums">· {issue.count} affected</span>
|
|
50
|
-
) : null}
|
|
51
|
-
</div>
|
|
52
|
-
{issue.cause ? (
|
|
53
|
-
<p className="text-sm leading-relaxed text-theme-text-secondary">{issue.cause}</p>
|
|
54
|
-
) : issue.message ? (
|
|
55
|
-
<p className="text-sm leading-relaxed text-theme-text-secondary">{issue.message}</p>
|
|
56
|
-
) : null}
|
|
57
|
-
{issue.action ? (
|
|
58
|
-
<p className="mt-1 text-sm leading-relaxed text-theme-text-secondary">
|
|
59
|
-
<span className="font-medium text-theme-text-primary">Next step: </span>
|
|
60
|
-
{issue.action}
|
|
61
|
-
</p>
|
|
62
|
-
) : null}
|
|
63
|
-
{issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
|
|
64
|
-
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
65
|
-
Suggested fix: create namespace{' '}
|
|
66
|
-
<code className="rounded bg-theme-elevated px-1 font-mono">{issue.remediation_target}</code> — apply it from the GitOps detail page.
|
|
67
|
-
</p>
|
|
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}
|
|
87
|
-
<CausalContext issue={issue} onResourceClick={onResourceClick} />
|
|
88
|
-
</div>
|
|
29
|
+
<IssueRow
|
|
30
|
+
key={key}
|
|
31
|
+
issue={issue}
|
|
32
|
+
open={openId === key}
|
|
33
|
+
onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
|
|
34
|
+
onResourceClick={onResourceClick}
|
|
35
|
+
/>
|
|
89
36
|
)
|
|
90
37
|
})}
|
|
91
|
-
</
|
|
92
|
-
</
|
|
38
|
+
</ol>
|
|
39
|
+
</section>
|
|
93
40
|
)
|
|
94
41
|
}
|
|
95
42
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
* cross-subject causal links (DiagnosticContext). Shows only the linking facts
|
|
99
|
-
* (those carrying a confidence tier or related issues) — the queue's IssuesView
|
|
100
|
-
* renders the fuller context with clickable resource navigation; here the related
|
|
101
|
-
* resources are shown as plain identifiers to keep the resource panel scannable.
|
|
102
|
-
*/
|
|
103
|
-
function CausalContext({ issue, onResourceClick }: { issue: Issue; onResourceClick?: (ref: IssueResourceRef) => void }) {
|
|
104
|
-
const ctx = issue.diagnostic_context
|
|
105
|
-
const links = ctx?.facts?.filter((f) => f.confidence || (f.related_issues && f.related_issues.length > 0)) ?? []
|
|
106
|
-
if (!ctx || links.length === 0) return null
|
|
107
|
-
return (
|
|
108
|
-
<div className="mt-2 border-t border-theme-border/60 pt-2">
|
|
109
|
-
<div className="mb-1 flex items-center gap-2">
|
|
110
|
-
<span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</span>
|
|
111
|
-
</div>
|
|
112
|
-
<ul className="space-y-1.5">
|
|
113
|
-
{links.map((fact, idx) => (
|
|
114
|
-
<li key={`${fact.type}-${idx}`} className="text-xs">
|
|
115
|
-
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
116
|
-
<span className="font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
|
|
117
|
-
{fact.confidence ? (
|
|
118
|
-
<span className="badge-sm text-[10px] text-theme-text-tertiary" title={confidenceTitle(fact.confidence)}>
|
|
119
|
-
{fact.confidence} confidence
|
|
120
|
-
</span>
|
|
121
|
-
) : null}
|
|
122
|
-
{fact.message ? <span className="text-theme-text-tertiary">{fact.message}</span> : null}
|
|
123
|
-
</div>
|
|
124
|
-
{fact.related_issues && fact.related_issues.length > 0 ? (
|
|
125
|
-
<ul className="mt-0.5 space-y-0.5 pl-3">
|
|
126
|
-
{fact.related_issues.map((rel, ri) => {
|
|
127
|
-
const label = (
|
|
128
|
-
<>
|
|
129
|
-
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{rel.ref.kind}</span>{' '}
|
|
130
|
-
<span className="font-mono">
|
|
131
|
-
{rel.ref.namespace ? `${rel.ref.namespace} / ` : ''}
|
|
132
|
-
{rel.ref.name}
|
|
133
|
-
</span>
|
|
134
|
-
{rel.count && rel.count > 1 ? (
|
|
135
|
-
<span className="ml-1 tabular-nums" title={`${rel.count} affected resources grouped under this issue`}>· {rel.count} affected</span>
|
|
136
|
-
) : null}
|
|
137
|
-
</>
|
|
138
|
-
)
|
|
139
|
-
return (
|
|
140
|
-
<li key={ri} className="text-theme-text-tertiary">
|
|
141
|
-
{onResourceClick ? (
|
|
142
|
-
<button
|
|
143
|
-
type="button"
|
|
144
|
-
onClick={() => onResourceClick(rel.ref)}
|
|
145
|
-
className="group inline-flex items-center gap-1 text-left hover:text-theme-text-secondary"
|
|
146
|
-
>
|
|
147
|
-
{label}
|
|
148
|
-
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100" />
|
|
149
|
-
</button>
|
|
150
|
-
) : (
|
|
151
|
-
label
|
|
152
|
-
)}
|
|
153
|
-
</li>
|
|
154
|
-
)
|
|
155
|
-
})}
|
|
156
|
-
</ul>
|
|
157
|
-
) : null}
|
|
158
|
-
</li>
|
|
159
|
-
))}
|
|
160
|
-
</ul>
|
|
161
|
-
</div>
|
|
162
|
-
)
|
|
43
|
+
function issueKey(issue: Issue): string {
|
|
44
|
+
return `${issue.cluster_id ?? ''}:${issue.id}`
|
|
163
45
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export { IssueRow, IssuesView } from './IssuesView';
|
|
6
6
|
export type { IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
|
|
7
7
|
export { ResourceIssuesSection } from './ResourceIssuesSection';
|
|
8
|
+
export { issueTiming } from './issue-timing';
|
|
9
|
+
export type { IssueTimingDisplay, IssueTimingDisplayKind } from './issue-timing';
|
|
8
10
|
export {
|
|
9
11
|
ISSUE_SEVERITIES,
|
|
10
12
|
ISSUE_SEVERITY_RANK,
|
|
@@ -17,6 +19,7 @@ export {
|
|
|
17
19
|
ISSUE_SEVERITY_LABEL,
|
|
18
20
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
19
21
|
ISSUE_SEVERITY_TEXT_CLASS,
|
|
22
|
+
ISSUE_SEVERITY_FILL_CLASS,
|
|
20
23
|
ISSUE_SEVERITY_RAIL_CLASS,
|
|
21
24
|
categoryLabel,
|
|
22
25
|
groupLabel,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { formatRelativeAgeTime } from '../../utils/format';
|
|
2
|
+
import { isDeploymentLikeWorkloadKind } from '../../types';
|
|
3
|
+
import type { Issue } from './types';
|
|
4
|
+
|
|
5
|
+
export type IssueTimingDisplayKind = 'creation' | 'regression';
|
|
6
|
+
|
|
7
|
+
export interface IssueTimingDisplay {
|
|
8
|
+
kind: IssueTimingDisplayKind;
|
|
9
|
+
chip: string;
|
|
10
|
+
meta: string;
|
|
11
|
+
tooltip: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isDeploymentLikeCreation(issue: Issue): boolean {
|
|
15
|
+
const group = issue.group ?? '';
|
|
16
|
+
if (issue.kind === 'Pod') {
|
|
17
|
+
return issue.issue_timing_basis === 'pod_creation'
|
|
18
|
+
|| issue.issue_timing_basis === 'owner_condition';
|
|
19
|
+
}
|
|
20
|
+
return isDeploymentLikeWorkloadKind(issue.kind, group);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function issueTiming(issue: Issue): IssueTimingDisplay | null {
|
|
24
|
+
switch (issue.issue_timing) {
|
|
25
|
+
case 'started_after_resource_was_healthy': {
|
|
26
|
+
const started = issue.first_seen ? `started ${formatRelativeAgeTime(issue.first_seen)} after being healthy` : 'started after being healthy';
|
|
27
|
+
return {
|
|
28
|
+
kind: 'regression',
|
|
29
|
+
chip: 'after healthy',
|
|
30
|
+
meta: started,
|
|
31
|
+
tooltip: 'Previously healthy before this failing signal.',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
case 'started_at_resource_creation': {
|
|
35
|
+
if (isDeploymentLikeCreation(issue)) {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'creation',
|
|
38
|
+
chip: 'since deploy',
|
|
39
|
+
meta: 'present since deployment or first reconciliation',
|
|
40
|
+
tooltip: 'Failing signal began during deployment or first reconciliation.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
kind: 'creation',
|
|
45
|
+
chip: 'since creation',
|
|
46
|
+
meta: 'present since creation or first reconciliation',
|
|
47
|
+
tooltip: 'Failing signal began during resource creation or first reconciliation.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|