@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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsView.tsx +2 -0
  3. package/src/components/audit/AuditAlerts.tsx +4 -0
  4. package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
  5. package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
  6. package/src/components/audit/AuditFindingsTable.tsx +4 -0
  7. package/src/components/audit/index.ts +1 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
  9. package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
  10. package/src/components/gitops/GitOpsTableView.tsx +3 -1
  11. package/src/components/issues/IssuesView.tsx +9 -36
  12. package/src/components/issues/ResourceIssuesSection.tsx +142 -0
  13. package/src/components/issues/diagnostic.ts +64 -0
  14. package/src/components/issues/index.ts +2 -1
  15. package/src/components/issues/severity.ts +10 -9
  16. package/src/components/issues/types.ts +5 -0
  17. package/src/components/resources/ResourcesView.tsx +38 -2
  18. package/src/components/resources/cron-to-human.test.ts +41 -0
  19. package/src/components/resources/get-pod-problems.test.ts +18 -0
  20. package/src/components/resources/health-golden.test.ts +66 -0
  21. package/src/components/resources/renderers/JobRenderer.tsx +6 -2
  22. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  24. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  25. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  26. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  28. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  29. package/src/components/resources/resource-utils-argo.ts +5 -1
  30. package/src/components/resources/resource-utils-keda.ts +12 -8
  31. package/src/components/resources/resource-utils.ts +34 -14
  32. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  33. package/src/components/timeline/shared.tsx +15 -4
  34. package/src/components/topology/K8sResourceNode.tsx +28 -1
  35. package/src/components/topology/layout.ts +11 -5
  36. package/src/components/ui/PaneLoader.tsx +24 -6
  37. package/src/components/ui/drawer-components.test.tsx +35 -0
  38. package/src/components/ui/drawer-components.tsx +13 -1
  39. package/src/components/workload/WorkloadView.tsx +35 -5
  40. package/src/types/core.ts +100 -3
  41. package/src/utils/applications.test.ts +55 -1
  42. package/src/utils/applications.ts +28 -7
  43. 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
- const deleteTime = new Date(pod.metadata.deletionTimestamp).getTime()
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 readyContainers = containerStatuses.filter((c: any) => c.ready).length
492
+ const unsettled = containerStatuses.filter((c: any) => !containerSettledOk(c)).length
490
493
  const totalContainers = containerStatuses.length
491
- if (totalContainers > 0 && readyContainers < totalContainers) {
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
- if (desired === 0) return { text: '0 nodes', color: healthColors.unknown, level: 'unknown' }
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
- return { text: 'Complete', color: healthColors.healthy, level: 'healthy' }
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
- if (minute !== '*' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
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
- if (minute.startsWith('*/')) {
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
- if (dayOfWeek === '1-5' || dayOfWeek === 'MON-FRI') {
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
- let currentHealth = 'unknown'
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 !== 'unknown') {
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 !== 'unknown') {
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
- const HEALTH_PRIORITY: Record<HealthStatus, number> = { unhealthy: 0, degraded: 1, unknown: 2, healthy: 3 }
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 = 3
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
- let worstPriority = 3
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. Animated radar icon stacked
9
- // above a label so swapping the label across the loading chain doesn't
10
- // shift the icon horizontally. Pin to the parent's fill via `className`
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 flex-col items-center justify-center gap-3 ${className}`}>
22
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
23
- <span className="text-sm text-theme-text-tertiary">{label}</span>
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
- <FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
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-32" />
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