@skyhook-io/k8s-ui 1.8.6 → 1.8.7
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/ApplicationsView.tsx +2 -0
- package/src/components/audit/AuditAlerts.tsx +4 -0
- package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
- package/src/components/audit/AuditFindingsTable.tsx +4 -0
- package/src/components/audit/index.ts +1 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
- package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
- package/src/components/gitops/GitOpsTableView.tsx +3 -1
- package/src/components/issues/IssuesView.tsx +9 -36
- package/src/components/issues/ResourceIssuesSection.tsx +142 -0
- package/src/components/issues/diagnostic.ts +64 -0
- package/src/components/issues/index.ts +2 -1
- package/src/components/issues/severity.ts +10 -9
- package/src/components/issues/types.ts +5 -0
- package/src/components/resources/ResourcesView.tsx +38 -2
- package/src/components/resources/cron-to-human.test.ts +41 -0
- package/src/components/resources/get-pod-problems.test.ts +18 -0
- package/src/components/resources/health-golden.test.ts +66 -0
- package/src/components/resources/renderers/JobRenderer.tsx +6 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
- package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
- package/src/components/resources/renderers/PodRenderer.tsx +28 -9
- package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
- package/src/components/resources/resource-utils-argo.test.ts +23 -0
- package/src/components/resources/resource-utils-argo.ts +5 -1
- package/src/components/resources/resource-utils-keda.ts +12 -8
- package/src/components/resources/resource-utils.ts +34 -14
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
- package/src/components/timeline/shared.tsx +15 -4
- package/src/components/topology/K8sResourceNode.tsx +28 -1
- package/src/components/topology/layout.ts +11 -5
- package/src/components/ui/PaneLoader.tsx +24 -6
- package/src/components/ui/drawer-components.test.tsx +35 -0
- package/src/components/ui/drawer-components.tsx +13 -1
- package/src/components/workload/WorkloadView.tsx +35 -5
- package/src/types/core.ts +100 -3
- package/src/utils/applications.test.ts +55 -1
- package/src/utils/applications.ts +28 -7
- package/src/utils/badge-colors.ts +7 -0
|
@@ -475,20 +475,23 @@ export function getPodProblems(pod: any): PodProblem[] {
|
|
|
475
475
|
problems.push({ severity: 'high', message: 'Evicted', detail: podStatusMessage })
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
-
// Stuck terminating (zombie pod)
|
|
478
|
+
// Stuck terminating (zombie pod). Use the same threshold as the badge
|
|
479
|
+
// (TERMINATING_STUCK_MINUTES) so the drawer problem and the table badge flip
|
|
480
|
+
// together — firing at 60s while the badge stayed calm to 10m was a mismatch.
|
|
479
481
|
if (pod.metadata?.deletionTimestamp) {
|
|
480
|
-
|
|
481
|
-
const ageSeconds = (Date.now() - deleteTime) / 1000
|
|
482
|
-
if (ageSeconds > 60) {
|
|
482
|
+
if (minutesSince(pod.metadata.deletionTimestamp) >= TERMINATING_STUCK_MINUTES) {
|
|
483
483
|
problems.push({ severity: 'medium', message: 'Stuck Terminating' })
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
// Not ready (Running but containers not ready)
|
|
487
|
+
// Not ready (Running but containers not ready). Use the same containerSettledOk
|
|
488
|
+
// gate as getPodStatus so a completing Job pod (Running, container terminated
|
|
489
|
+
// exit 0, Ready=false) doesn't raise a drawer problem while the table badge
|
|
490
|
+
// stays calm — a settled/completed container is not "Not Ready".
|
|
488
491
|
if (phase === 'Running') {
|
|
489
|
-
const
|
|
492
|
+
const unsettled = containerStatuses.filter((c: any) => !containerSettledOk(c)).length
|
|
490
493
|
const totalContainers = containerStatuses.length
|
|
491
|
-
if (totalContainers > 0 &&
|
|
494
|
+
if (totalContainers > 0 && unsettled > 0) {
|
|
492
495
|
// Only add if we haven't already flagged a more specific issue
|
|
493
496
|
const hasSpecificIssue = problems.some(p =>
|
|
494
497
|
p.message.includes('Probe') || p.message.includes('CrashLoop') || p.message.includes('OOM')
|
|
@@ -646,7 +649,9 @@ export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
|
|
|
646
649
|
const ready = status.numberReady || 0
|
|
647
650
|
const updated = status.updatedNumberScheduled || 0
|
|
648
651
|
|
|
649
|
-
|
|
652
|
+
// 0 desired = the node selector matches no nodes — intentional/idle, not a
|
|
653
|
+
// fault and not "unknown" (matches pkg/health.Workload). Sky.
|
|
654
|
+
if (desired === 0) return { text: '0 nodes', color: healthColors.neutral, level: 'neutral' }
|
|
650
655
|
if (ready === desired && updated === desired) {
|
|
651
656
|
return { text: `${ready}/${desired}`, color: healthColors.healthy, level: 'healthy' }
|
|
652
657
|
}
|
|
@@ -1008,7 +1013,9 @@ export function getJobStatus(job: any): StatusBadge {
|
|
|
1008
1013
|
|
|
1009
1014
|
const completeCond = conditions.find((c: any) => c.type === 'Complete' && c.status === 'True')
|
|
1010
1015
|
if (completeCond) {
|
|
1011
|
-
|
|
1016
|
+
// A completed Job is done by design — neutral/idle (sky), not the green of a
|
|
1017
|
+
// serving workload (matches pkg/health.Workload).
|
|
1018
|
+
return { text: 'Complete', color: healthColors.neutral, level: 'neutral' }
|
|
1012
1019
|
}
|
|
1013
1020
|
|
|
1014
1021
|
if (job.spec?.suspend) {
|
|
@@ -1244,17 +1251,30 @@ export function cronToHuman(cron: string): string {
|
|
|
1244
1251
|
if (minute === '0' && hour !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1245
1252
|
return `Daily at ${hour}:00`
|
|
1246
1253
|
}
|
|
1247
|
-
|
|
1254
|
+
// Exclude step-minute ("*/N") here so it falls through to the interval branch
|
|
1255
|
+
// below — otherwise "*/5 * * * *" rendered as "Every hour at :*/5" instead of
|
|
1256
|
+
// "Every 5 minutes". A literal minute like "30" still reads "Every hour at :30".
|
|
1257
|
+
if (minute !== '*' && !minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1248
1258
|
return `Every hour at :${minute.padStart(2, '0')}`
|
|
1249
1259
|
}
|
|
1250
1260
|
if (minute === '*' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1251
1261
|
return 'Every minute'
|
|
1252
1262
|
}
|
|
1253
|
-
|
|
1263
|
+
// Only claim a plain "Every N minutes" when nothing else constrains the window;
|
|
1264
|
+
// otherwise "*/5 9 * * *" (only at 09:xx) or "*/5 * * * 1-5" (weekdays only)
|
|
1265
|
+
// would read as an unconstrained interval. Constrained shapes fall through to
|
|
1266
|
+
// the raw cron rather than assert something misleading.
|
|
1267
|
+
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1254
1268
|
const interval = minute.slice(2)
|
|
1255
|
-
return `Every ${interval} minutes`
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1269
|
+
return interval === '1' ? 'Every minute' : `Every ${interval} minutes`
|
|
1270
|
+
}
|
|
1271
|
+
// Weekday phrasing needs a literal hour:minute — a wildcard/step in either field
|
|
1272
|
+
// (e.g. "*/5 * * * 1-5") would render "Weekdays at *:*/5", so let it fall to raw.
|
|
1273
|
+
if (
|
|
1274
|
+
(dayOfWeek === '1-5' || dayOfWeek === 'MON-FRI') &&
|
|
1275
|
+
hour !== '*' && !hour.startsWith('*/') &&
|
|
1276
|
+
minute !== '*' && !minute.startsWith('*/')
|
|
1277
|
+
) {
|
|
1258
1278
|
return `Weekdays at ${hour}:${minute.padStart(2, '0')}`
|
|
1259
1279
|
}
|
|
1260
1280
|
|
|
@@ -573,6 +573,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
573
573
|
<HealthBarLegendItem color="bg-blue-500/60 dark:bg-blue-500/60" label="rolling" description="Expected degradation during deployment rollout" />
|
|
574
574
|
<HealthBarLegendItem color="bg-amber-500/60 dark:bg-[#b8861e]" label="degraded" description="Unexpected partial availability" />
|
|
575
575
|
<HealthBarLegendItem color="bg-red-500/60 dark:bg-red-500/60" label="unhealthy" description="Resource is failing or not ready" />
|
|
576
|
+
<HealthBarLegendItem color="bg-sky-500/60 dark:bg-sky-500/60" label="idle" description="Intentionally off/resting (completed, suspended, scaled to zero)" />
|
|
576
577
|
</div>
|
|
577
578
|
</div>
|
|
578
579
|
|
|
@@ -138,6 +138,10 @@ export function HealthSpanLegend() {
|
|
|
138
138
|
<span className="w-4 h-2 rounded-sm bg-red-500/60 dark:bg-red-500/60" />
|
|
139
139
|
<span>Unhealthy</span>
|
|
140
140
|
</span>
|
|
141
|
+
<span className="flex items-center gap-1">
|
|
142
|
+
<span className="w-4 h-2 rounded-sm bg-sky-500/60 dark:bg-sky-500/60" />
|
|
143
|
+
<span>Idle</span>
|
|
144
|
+
</span>
|
|
141
145
|
</div>
|
|
142
146
|
)
|
|
143
147
|
}
|
|
@@ -387,7 +391,7 @@ export function TimeAxis({ startTime, endTime, tickCount = 8, labelColumnClass =
|
|
|
387
391
|
* Health span bar showing a health state over a time range.
|
|
388
392
|
*/
|
|
389
393
|
interface HealthSpanProps {
|
|
390
|
-
health: 'healthy' | 'degraded' | 'unhealthy' | string
|
|
394
|
+
health: 'healthy' | 'rolling' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown' | string
|
|
391
395
|
left: number // percentage
|
|
392
396
|
width: number // percentage
|
|
393
397
|
title?: string
|
|
@@ -408,6 +412,9 @@ export function HealthSpan({ health, left, width, title, createdBefore }: Health
|
|
|
408
412
|
return 'bg-amber-500/60 dark:bg-[#b8861e]'
|
|
409
413
|
case 'unhealthy':
|
|
410
414
|
return 'bg-red-500/60 dark:bg-red-500/60'
|
|
415
|
+
case 'neutral':
|
|
416
|
+
// intentional/idle (suspended, scaled-to-0) — sky, calm
|
|
417
|
+
return 'bg-sky-500/60 dark:bg-sky-500/60'
|
|
411
418
|
default:
|
|
412
419
|
// Unknown or other states
|
|
413
420
|
return 'bg-gray-400/40'
|
|
@@ -569,7 +576,11 @@ export function buildHealthSpans(
|
|
|
569
576
|
const existsUntil = deleteEvent ? new Date(deleteEvent.timestamp).getTime() : now
|
|
570
577
|
|
|
571
578
|
const spans: { start: number; end: number; health: string }[] = []
|
|
572
|
-
|
|
579
|
+
// `null` is the "no health observed yet" sentinel — kept distinct from the
|
|
580
|
+
// real 'unknown' health value (emitted for node-lost pods). Overloading
|
|
581
|
+
// 'unknown' as both swallowed genuine-unknown spans and then false-greened
|
|
582
|
+
// them via the empty-spans fallback below.
|
|
583
|
+
let currentHealth: string | null = null
|
|
573
584
|
let spanStart = Math.max(existsFrom, startTime)
|
|
574
585
|
|
|
575
586
|
for (const evt of sorted) {
|
|
@@ -586,7 +597,7 @@ export function buildHealthSpans(
|
|
|
586
597
|
continue
|
|
587
598
|
}
|
|
588
599
|
|
|
589
|
-
if (newHealth !== currentHealth && currentHealth !==
|
|
600
|
+
if (newHealth !== currentHealth && currentHealth !== null) {
|
|
590
601
|
spans.push({ start: spanStart, end: ts, health: currentHealth })
|
|
591
602
|
spanStart = ts
|
|
592
603
|
}
|
|
@@ -594,7 +605,7 @@ export function buildHealthSpans(
|
|
|
594
605
|
}
|
|
595
606
|
|
|
596
607
|
// Close final span (only up to when resource existed)
|
|
597
|
-
if (currentHealth !==
|
|
608
|
+
if (currentHealth !== null) {
|
|
598
609
|
spans.push({ start: spanStart, end: Math.min(existsUntil, now), health: currentHealth })
|
|
599
610
|
}
|
|
600
611
|
|
|
@@ -3,15 +3,17 @@ import { Handle, Position } from '@xyflow/react'
|
|
|
3
3
|
import {
|
|
4
4
|
ChevronDown,
|
|
5
5
|
ChevronUp,
|
|
6
|
+
TriangleAlert,
|
|
6
7
|
} from 'lucide-react'
|
|
7
8
|
import { clsx } from 'clsx'
|
|
8
9
|
import type { NodeKind, HealthStatus, PodSummary } from '../../types'
|
|
9
10
|
import { displayKind } from '../../types'
|
|
10
|
-
import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
|
|
11
|
+
import { healthToSeverity, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
11
12
|
import { workloadHue } from '../../utils/workload-colors'
|
|
12
13
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
13
14
|
import { midTruncate } from '../../utils/format'
|
|
14
15
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
|
+
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
|
|
15
17
|
|
|
16
18
|
// Get actionable tooltip content for health issues
|
|
17
19
|
function getIssueTooltip(issue: string | undefined): React.ReactNode {
|
|
@@ -215,6 +217,12 @@ const STATUS_STYLES: Record<HealthStatus, React.CSSProperties> = {
|
|
|
215
217
|
border: '2px solid rgb(239 68 68 / 0.7)',
|
|
216
218
|
backgroundColor: 'rgb(248 113 113 / 0.15)',
|
|
217
219
|
},
|
|
220
|
+
// neutral = intentional/idle (suspended, scaled-to-0) — sky outline, calm.
|
|
221
|
+
// Distinct from `unknown`/`healthy` which carry no outline.
|
|
222
|
+
neutral: {
|
|
223
|
+
border: '2px solid rgb(56 189 248 / 0.55)',
|
|
224
|
+
backgroundColor: 'rgb(56 189 248 / 0.1)',
|
|
225
|
+
},
|
|
218
226
|
healthy: {},
|
|
219
227
|
unknown: {},
|
|
220
228
|
}
|
|
@@ -376,6 +384,15 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
376
384
|
id,
|
|
377
385
|
}: K8sResourceNodeProps) {
|
|
378
386
|
const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded } = data
|
|
387
|
+
// Cluster Audit findings joined onto this node by the host (web/ enriches each
|
|
388
|
+
// node's data by auditKey). The host only counts "badge-worthy" findings —
|
|
389
|
+
// reference-integrity / lifecycle, "this resource is actually broken" — not the
|
|
390
|
+
// posture/best-practice nags that fire near-universally, so the indicator stays
|
|
391
|
+
// a signal. Colored by worst severity (danger red, else warning amber).
|
|
392
|
+
const auditDanger = typeof nodeData.auditDanger === 'number' ? nodeData.auditDanger : 0
|
|
393
|
+
const auditWarning = typeof nodeData.auditWarning === 'number' ? nodeData.auditWarning : 0
|
|
394
|
+
const auditTotal = auditDanger + auditWarning
|
|
395
|
+
const auditMessages = Array.isArray(nodeData.auditMessages) ? (nodeData.auditMessages as AuditBadgeMessage[]) : []
|
|
379
396
|
// Workload tint (application graph): a node owned by exactly one workload
|
|
380
397
|
// carries that workload's hue. Only on healthy/unknown cards — degraded/
|
|
381
398
|
// unhealthy already own the card background for health, which must win.
|
|
@@ -506,6 +523,16 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
506
523
|
<ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
|
|
507
524
|
</button>
|
|
508
525
|
)}
|
|
526
|
+
{auditTotal > 0 && (
|
|
527
|
+
<Tooltip
|
|
528
|
+
content={auditMessages.length > 0
|
|
529
|
+
? <AuditBadgeTooltip messages={auditMessages} clickHint={false} />
|
|
530
|
+
: `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${auditDanger > 0 ? ` · ${auditDanger} danger` : ''}`}
|
|
531
|
+
position="right"
|
|
532
|
+
>
|
|
533
|
+
<TriangleAlert className={clsx('w-3 h-3 cursor-help', auditDanger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
|
|
534
|
+
</Tooltip>
|
|
535
|
+
)}
|
|
509
536
|
{issueTooltip ? (
|
|
510
537
|
<Tooltip content={issueTooltip} position="right">
|
|
511
538
|
<span className={clsx('w-1.5 h-1.5 rounded-full cursor-help', getStatusDotColor(status))} />
|
|
@@ -731,11 +731,14 @@ export function buildHierarchicalElkGraph(
|
|
|
731
731
|
}
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
-
// Lower number = higher severity
|
|
735
|
-
|
|
734
|
+
// Lower number = higher severity. `neutral` (intentional/idle) is the
|
|
735
|
+
// most-benign tier, so a group whose every member is idle rolls up to neutral,
|
|
736
|
+
// while a mixed healthy+idle group still reads healthy (healthy out-ranks
|
|
737
|
+
// neutral). An empty/unresolved group defaults to healthy.
|
|
738
|
+
const HEALTH_PRIORITY: Record<HealthStatus, number> = { unhealthy: 0, degraded: 1, unknown: 2, healthy: 3, neutral: 4 }
|
|
736
739
|
|
|
737
740
|
function computeGroupHealth(memberIds: string[], nodeMap: Map<string, TopologyNode>): { worstStatus: HealthStatus; unhealthyCount: number } {
|
|
738
|
-
let worstPriority =
|
|
741
|
+
let worstPriority = Infinity
|
|
739
742
|
let worstStatus: HealthStatus = 'healthy'
|
|
740
743
|
let unhealthyCount = 0
|
|
741
744
|
for (const id of memberIds) {
|
|
@@ -804,8 +807,11 @@ function computeWorkloadCards(
|
|
|
804
807
|
})
|
|
805
808
|
const primary = sorted[0]
|
|
806
809
|
|
|
807
|
-
// Compute worst health
|
|
808
|
-
|
|
810
|
+
// Compute worst health. Seed with Infinity (not 3) so an all-neutral
|
|
811
|
+
// component can surface neutral — `neutral` is priority 4 (most benign), so a
|
|
812
|
+
// `< 3` seed would never accept it and the card would stay green. Matches
|
|
813
|
+
// computeGroupHealth.
|
|
814
|
+
let worstPriority = Infinity
|
|
809
815
|
let worstStatus: HealthStatus = 'healthy'
|
|
810
816
|
for (const node of comp) {
|
|
811
817
|
const p = HEALTH_PRIORITY[node.status] ?? 2
|
|
@@ -5,9 +5,14 @@ import radarLoadingIconAsset from '../../assets/radar/radar-icon-loading.svg'
|
|
|
5
5
|
// StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
|
|
6
6
|
const radarLoadingIcon = assetUrl(radarLoadingIconAsset)
|
|
7
7
|
|
|
8
|
-
// PaneLoader — center-of-pane loading state.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// PaneLoader — center-of-pane loading state. The animated radar icon is
|
|
9
|
+
// pinned to the pane's exact center; the label hangs at a fixed offset
|
|
10
|
+
// BELOW it, absolutely positioned (not a flex sibling), so the label never
|
|
11
|
+
// affects where the icon sits. The icon therefore holds a single position
|
|
12
|
+
// while only the text under it appears/changes — and it lands at the same
|
|
13
|
+
// point as the host/connect splash surfaces (which center the icon at 50%
|
|
14
|
+
// with the label decoupled below), so a splash → PaneLoader hand-off no
|
|
15
|
+
// longer makes the logo jump. Pin to the parent's fill via `className`
|
|
11
16
|
// (`flex-1`, `h-full`, `h-32`, `absolute inset-0`, etc.). The SVG self-
|
|
12
17
|
// animates (sweep arm + blips, `prefers-reduced-motion` honored).
|
|
13
18
|
export function PaneLoader({
|
|
@@ -17,10 +22,23 @@ export function PaneLoader({
|
|
|
17
22
|
label?: string
|
|
18
23
|
className?: string
|
|
19
24
|
}) {
|
|
25
|
+
// No `relative` on the root: the label anchors to the inner `relative` span
|
|
26
|
+
// below, and callers may pass a positioning class (e.g. `absolute inset-0`,
|
|
27
|
+
// for topology panes) — a root `relative` would conflict with it.
|
|
20
28
|
return (
|
|
21
|
-
<div className={`flex
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
<div className={`flex items-center justify-center ${className}`} aria-live="polite">
|
|
30
|
+
{/* The icon is the only in-flow child, so it centers in the pane. The
|
|
31
|
+
label is absolutely positioned below the icon and so never shifts it. */}
|
|
32
|
+
<span className="relative">
|
|
33
|
+
<img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
|
|
34
|
+
{/* Label style matches the splash surfaces (17px semibold tracking-tight,
|
|
35
|
+
primary) so the whole loading family — boot splash, connect splash,
|
|
36
|
+
PaneLoader — reads as one continuous state, not a font change at the
|
|
37
|
+
hand-off. */}
|
|
38
|
+
<span className="absolute left-1/2 top-full mt-3 -translate-x-1/2 whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
39
|
+
{label}
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
24
42
|
</div>
|
|
25
43
|
)
|
|
26
44
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { ProblemAlerts, OperationalIssuesShownContext } from './drawer-components'
|
|
4
|
+
|
|
5
|
+
const problems = [
|
|
6
|
+
{ color: 'red' as const, message: 'Application is Degraded' },
|
|
7
|
+
{ color: 'yellow' as const, message: 'Application is OutOfSync' },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
describe('ProblemAlerts', () => {
|
|
11
|
+
it('renders every problem', () => {
|
|
12
|
+
const html = renderToString(<ProblemAlerts problems={problems} />)
|
|
13
|
+
expect(html).toContain('Application is Degraded')
|
|
14
|
+
expect(html).toContain('Application is OutOfSync')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders nothing when there are no problems', () => {
|
|
18
|
+
expect(renderToString(<ProblemAlerts problems={[]} />)).toBe('')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Regression guard: ProblemAlerts is used only by GitOps renderers, whose
|
|
22
|
+
// problems the live-Issues pipeline does not comprehensively emit. It must NOT
|
|
23
|
+
// suppress itself under the Operational-Issues context — doing so hid real
|
|
24
|
+
// GitOps warnings (e.g. a manual Argo app's OutOfSync). Pod/Workload renderers
|
|
25
|
+
// self-gate their own arrays; this component never should.
|
|
26
|
+
it('still renders under OperationalIssuesShownContext (does not self-suppress)', () => {
|
|
27
|
+
const html = renderToString(
|
|
28
|
+
<OperationalIssuesShownContext.Provider value={true}>
|
|
29
|
+
<ProblemAlerts problems={problems} />
|
|
30
|
+
</OperationalIssuesShownContext.Provider>
|
|
31
|
+
)
|
|
32
|
+
expect(html).toContain('Application is Degraded')
|
|
33
|
+
expect(html).toContain('Application is OutOfSync')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useState, createContext, useContext } from 'react'
|
|
2
2
|
import { ChevronRight, Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { formatAge, formatDuration, formatResources } from '../resources/resource-utils'
|
|
@@ -446,6 +446,18 @@ export interface Problem {
|
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
/** Displays a list of problem alerts (warnings and errors) */
|
|
449
|
+
// True when the resource detail is already rendering a dedicated, authoritative
|
|
450
|
+
// "Operational Issues" section (the Issues pipeline — richer cause/action). Only
|
|
451
|
+
// renderers whose problems the pipeline COMPREHENSIVELY covers read this and drop
|
|
452
|
+
// their own problem array so the same failure isn't shown twice — today that's
|
|
453
|
+
// PodRenderer and WorkloadRenderer (workload + pod runtime). It is deliberately
|
|
454
|
+
// NOT wired into ProblemAlerts: that component is used only by GitOps renderers
|
|
455
|
+
// (Argo/Flux), whose Degraded/OutOfSync/revision-mismatch banners the pipeline
|
|
456
|
+
// does not fully emit (e.g. OutOfSync only for automated Argo apps) — suppressing
|
|
457
|
+
// them would hide real problems, which is worse than an occasional duplicate.
|
|
458
|
+
export const OperationalIssuesShownContext = createContext(false)
|
|
459
|
+
export const useOperationalIssuesShown = () => useContext(OperationalIssuesShownContext)
|
|
460
|
+
|
|
449
461
|
export function ProblemAlerts({ problems }: { problems: Problem[] }) {
|
|
450
462
|
if (problems.length === 0) return null
|
|
451
463
|
|
|
@@ -52,7 +52,7 @@ import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } f
|
|
|
52
52
|
import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
|
|
53
53
|
import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
|
|
54
54
|
import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
|
|
55
|
-
import { getKindColorOutline, displayKindName } from '../ui/drawer-components'
|
|
55
|
+
import { getKindColorOutline, displayKindName, OperationalIssuesShownContext } from '../ui/drawer-components'
|
|
56
56
|
import { midTruncate } from '../../utils/format'
|
|
57
57
|
|
|
58
58
|
export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
@@ -211,6 +211,17 @@ interface WorkloadViewProps {
|
|
|
211
211
|
isMetricsAvailable?: (kind: string, resource: any) => boolean
|
|
212
212
|
/** Render extra content at the bottom of the overview tab (e.g. audit findings) */
|
|
213
213
|
renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
214
|
+
/** Render content at the TOP of the overview tab, above the renderer (e.g. live
|
|
215
|
+
* Operational Issues). Optional + additive — consumers that don't pass it are
|
|
216
|
+
* unaffected. Only rendered when `hasOperationalIssues` is true: the lead
|
|
217
|
+
* component returns null when empty, but its padded wrapper can't tell, so
|
|
218
|
+
* gating on the flag avoids an empty top gap on healthy resources. */
|
|
219
|
+
renderOverviewLead?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
220
|
+
/** When true, renderers suppress their own status-derived problem displays
|
|
221
|
+
* because a dedicated Operational Issues section is shown (the host fetched
|
|
222
|
+
* live issues for this resource). Avoids showing the same failure twice.
|
|
223
|
+
* Also gates the `renderOverviewLead` wrapper (see above). */
|
|
224
|
+
hasOperationalIssues?: boolean
|
|
214
225
|
|
|
215
226
|
// ── Duplicate ────────────────────────────────────────────────────────────
|
|
216
227
|
/** Duplicate handler — opens create dialog with this resource's YAML */
|
|
@@ -282,6 +293,8 @@ export function WorkloadView({
|
|
|
282
293
|
onDuplicate,
|
|
283
294
|
onDownload,
|
|
284
295
|
renderOverviewExtra,
|
|
296
|
+
renderOverviewLead,
|
|
297
|
+
hasOperationalIssues,
|
|
285
298
|
// Actions bar
|
|
286
299
|
actionsBarProps,
|
|
287
300
|
// Renderer overrides
|
|
@@ -636,7 +649,9 @@ export function WorkloadView({
|
|
|
636
649
|
{/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
|
|
637
650
|
<div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
|
|
638
651
|
{!resource ? (
|
|
639
|
-
|
|
652
|
+
// Fill the drawer body so the loading logo centers in it, not in a
|
|
653
|
+
// 128px box pinned to the top (matches the splash/PaneLoader centering).
|
|
654
|
+
<FetchResult loading={resourceLoading} error={resourceError} className="h-full" />
|
|
640
655
|
) : showYaml ? (
|
|
641
656
|
<EditableYamlView
|
|
642
657
|
resource={selectedResource}
|
|
@@ -652,7 +667,12 @@ export function WorkloadView({
|
|
|
652
667
|
onDownload={onDownload}
|
|
653
668
|
/>
|
|
654
669
|
) : (
|
|
655
|
-
|
|
670
|
+
<OperationalIssuesShownContext.Provider value={!!hasOperationalIssues}>
|
|
671
|
+
{renderOverviewLead && hasOperationalIssues && (
|
|
672
|
+
<div className="px-4 pt-4">
|
|
673
|
+
{renderOverviewLead({ kind, namespace, name })}
|
|
674
|
+
</div>
|
|
675
|
+
)}
|
|
656
676
|
<ResourceRendererDispatch
|
|
657
677
|
resource={selectedResource}
|
|
658
678
|
data={resource}
|
|
@@ -679,7 +699,7 @@ export function WorkloadView({
|
|
|
679
699
|
{renderOverviewExtra({ kind, namespace, name })}
|
|
680
700
|
</div>
|
|
681
701
|
)}
|
|
682
|
-
|
|
702
|
+
</OperationalIssuesShownContext.Provider>
|
|
683
703
|
)}
|
|
684
704
|
</div>
|
|
685
705
|
</div>
|
|
@@ -688,6 +708,7 @@ export function WorkloadView({
|
|
|
688
708
|
|
|
689
709
|
// ── Expanded (full) mode ─────────────────────────────────────────────────
|
|
690
710
|
return (
|
|
711
|
+
<OperationalIssuesShownContext.Provider value={!!hasOperationalIssues}>
|
|
691
712
|
<DetailShell
|
|
692
713
|
breadcrumb={breadcrumb}
|
|
693
714
|
nav={
|
|
@@ -802,6 +823,7 @@ export function WorkloadView({
|
|
|
802
823
|
eventsError={overviewEventsError}
|
|
803
824
|
updatesError={resourceFocusedUpdatesError}
|
|
804
825
|
extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
826
|
+
leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
|
|
805
827
|
/>
|
|
806
828
|
)}
|
|
807
829
|
{effectiveTab === 'topology' && (
|
|
@@ -881,7 +903,7 @@ export function WorkloadView({
|
|
|
881
903
|
{yamlObject && !yamlObject.primary && renderRelatedYaml ? (
|
|
882
904
|
renderRelatedYaml(yamlObject)
|
|
883
905
|
) : !resource ? (
|
|
884
|
-
<FetchResult loading={resourceLoading} error={resourceError} className="h-
|
|
906
|
+
<FetchResult loading={resourceLoading} error={resourceError} className="h-full" />
|
|
885
907
|
) : (
|
|
886
908
|
<EditableYamlView
|
|
887
909
|
resource={selectedResource}
|
|
@@ -901,6 +923,7 @@ export function WorkloadView({
|
|
|
901
923
|
</div>
|
|
902
924
|
)}
|
|
903
925
|
</DetailShell>
|
|
926
|
+
</OperationalIssuesShownContext.Provider>
|
|
904
927
|
)
|
|
905
928
|
}
|
|
906
929
|
|
|
@@ -1335,6 +1358,7 @@ function InfoTab({
|
|
|
1335
1358
|
eventsError,
|
|
1336
1359
|
updatesError,
|
|
1337
1360
|
extraContent,
|
|
1361
|
+
leadContent,
|
|
1338
1362
|
}: {
|
|
1339
1363
|
resource: any
|
|
1340
1364
|
selectedResource: SelectedResource
|
|
@@ -1358,6 +1382,7 @@ function InfoTab({
|
|
|
1358
1382
|
eventsError?: Error | null
|
|
1359
1383
|
updatesError?: Error | null
|
|
1360
1384
|
extraContent?: ReactNode
|
|
1385
|
+
leadContent?: ReactNode
|
|
1361
1386
|
}) {
|
|
1362
1387
|
if (!resource) {
|
|
1363
1388
|
return <FetchResult loading={isLoading} error={error} className="h-full" />
|
|
@@ -1365,6 +1390,11 @@ function InfoTab({
|
|
|
1365
1390
|
|
|
1366
1391
|
return (
|
|
1367
1392
|
<div className="h-full overflow-auto">
|
|
1393
|
+
{leadContent && (
|
|
1394
|
+
<div className="px-4 pt-4">
|
|
1395
|
+
{leadContent}
|
|
1396
|
+
</div>
|
|
1397
|
+
)}
|
|
1368
1398
|
<ResourceRendererDispatch
|
|
1369
1399
|
resource={selectedResource}
|
|
1370
1400
|
data={resource}
|
package/src/types/core.ts
CHANGED
|
@@ -193,7 +193,7 @@ export function displayKind(kind: string): string {
|
|
|
193
193
|
return shortNames[kind] || kind
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
|
|
196
|
+
export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
|
|
197
197
|
|
|
198
198
|
export type EdgeType = 'routes-to' | 'exposes' | 'manages' | 'uses' | 'configures' | 'protects'
|
|
199
199
|
|
|
@@ -557,7 +557,7 @@ export interface HelmRelease {
|
|
|
557
557
|
lastOperation?: HelmOperation
|
|
558
558
|
operations?: HelmOperation[]
|
|
559
559
|
// Health summary from owned resources
|
|
560
|
-
resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
|
|
560
|
+
resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
|
|
561
561
|
healthIssue?: string // Primary issue if unhealthy (e.g., "OOMKilled")
|
|
562
562
|
healthSummary?: string // Brief summary like "2/3 pods ready"
|
|
563
563
|
// When set, this release was installed by Flux's helm-controller — the
|
|
@@ -612,10 +612,11 @@ export interface HelmReleaseDetail {
|
|
|
612
612
|
notes: string
|
|
613
613
|
history: HelmRevision[]
|
|
614
614
|
resources: HelmOwnedResource[]
|
|
615
|
-
resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
|
|
615
|
+
resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
|
|
616
616
|
healthIssue?: string
|
|
617
617
|
healthSummary?: string
|
|
618
618
|
hooks?: HelmHook[]
|
|
619
|
+
hookDiagnostics?: HookDiagnostic[]
|
|
619
620
|
readme?: string
|
|
620
621
|
dependencies?: ChartDependency[]
|
|
621
622
|
lastOperation?: HelmOperation
|
|
@@ -627,10 +628,78 @@ export interface HelmReleaseDetail {
|
|
|
627
628
|
|
|
628
629
|
export interface HelmHook {
|
|
629
630
|
name: string
|
|
631
|
+
namespace?: string
|
|
630
632
|
kind: string
|
|
633
|
+
path?: string
|
|
631
634
|
events: string[]
|
|
632
635
|
weight: number
|
|
633
636
|
status?: string
|
|
637
|
+
startedAt?: string
|
|
638
|
+
completedAt?: string
|
|
639
|
+
deletePolicies?: string[]
|
|
640
|
+
outputLogPolicies?: string[]
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export interface HookDiagnostic {
|
|
644
|
+
name: string
|
|
645
|
+
namespace?: string
|
|
646
|
+
kind: string
|
|
647
|
+
events?: string[]
|
|
648
|
+
phase: string
|
|
649
|
+
message: string
|
|
650
|
+
evidence?: HookEvidence
|
|
651
|
+
evidenceUnavailable?: boolean
|
|
652
|
+
evidenceUnavailableReason?: string
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface HookEvidence {
|
|
656
|
+
summary?: string
|
|
657
|
+
jobs?: HookJobEvidence[]
|
|
658
|
+
pods?: HookPodEvidence[]
|
|
659
|
+
events?: HookEventEvidence[]
|
|
660
|
+
logs?: HookLogEvidence[]
|
|
661
|
+
errors?: string[]
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export interface HookJobEvidence {
|
|
665
|
+
name: string
|
|
666
|
+
namespace?: string
|
|
667
|
+
status?: string
|
|
668
|
+
active?: number
|
|
669
|
+
succeeded?: number
|
|
670
|
+
failed?: number
|
|
671
|
+
conditions?: string[]
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export interface HookPodEvidence {
|
|
675
|
+
name: string
|
|
676
|
+
namespace?: string
|
|
677
|
+
phase?: string
|
|
678
|
+
ready?: string
|
|
679
|
+
restartCount?: number
|
|
680
|
+
reason?: string
|
|
681
|
+
message?: string
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export interface HookEventEvidence {
|
|
685
|
+
involvedKind: string
|
|
686
|
+
involvedName: string
|
|
687
|
+
type?: string
|
|
688
|
+
reason?: string
|
|
689
|
+
message?: string
|
|
690
|
+
count?: number
|
|
691
|
+
lastSeen?: string
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export interface HookLogEvidence {
|
|
695
|
+
pod: string
|
|
696
|
+
container: string
|
|
697
|
+
previous?: boolean
|
|
698
|
+
lines?: string[]
|
|
699
|
+
totalLines?: number
|
|
700
|
+
matchedLines?: number
|
|
701
|
+
fallback?: boolean
|
|
702
|
+
error?: string
|
|
634
703
|
}
|
|
635
704
|
|
|
636
705
|
export interface ChartDependency {
|
|
@@ -658,12 +727,40 @@ export interface HelmValues {
|
|
|
658
727
|
computed?: Record<string, unknown>
|
|
659
728
|
}
|
|
660
729
|
|
|
730
|
+
export interface ValuesDiff {
|
|
731
|
+
revision1: number
|
|
732
|
+
revision2: number
|
|
733
|
+
allValues: boolean
|
|
734
|
+
diff: string
|
|
735
|
+
}
|
|
736
|
+
|
|
661
737
|
export interface ManifestDiff {
|
|
662
738
|
revision1: number
|
|
663
739
|
revision2: number
|
|
664
740
|
diff: string
|
|
665
741
|
}
|
|
666
742
|
|
|
743
|
+
export interface NotesDiff {
|
|
744
|
+
revision1: number
|
|
745
|
+
revision2: number
|
|
746
|
+
diff: string
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export interface HelmResourceRef {
|
|
750
|
+
kind: string
|
|
751
|
+
apiVersion?: string
|
|
752
|
+
name: string
|
|
753
|
+
namespace: string
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export interface ResourceDiff {
|
|
757
|
+
revision1: number
|
|
758
|
+
revision2: number
|
|
759
|
+
added: HelmResourceRef[]
|
|
760
|
+
removed: HelmResourceRef[]
|
|
761
|
+
unchanged: HelmResourceRef[]
|
|
762
|
+
}
|
|
763
|
+
|
|
667
764
|
// Selected Helm release (for drawer state)
|
|
668
765
|
export interface SelectedHelmRelease {
|
|
669
766
|
namespace: string
|