@skyhook-io/k8s-ui 1.8.5 → 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 (72) 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-phase-display.test.ts +115 -3
  20. package/src/components/resources/get-pod-problems.test.ts +18 -0
  21. package/src/components/resources/health-golden.test.ts +66 -0
  22. package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
  23. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
  24. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
  25. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
  26. package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
  27. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
  28. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
  29. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
  30. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
  31. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
  32. package/src/components/resources/renderers/JobRenderer.tsx +12 -7
  33. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  34. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
  35. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  36. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  37. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  39. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
  40. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
  41. package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
  42. package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
  43. package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
  44. package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
  45. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
  46. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  47. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
  48. package/src/components/resources/renderers/contour-cells.tsx +6 -5
  49. package/src/components/resources/renderers/index.ts +4 -0
  50. package/src/components/resources/renderers/istio-cells.tsx +16 -25
  51. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  52. package/src/components/resources/resource-utils-argo.ts +5 -1
  53. package/src/components/resources/resource-utils-cnpg.ts +1 -1
  54. package/src/components/resources/resource-utils-keda.ts +17 -7
  55. package/src/components/resources/resource-utils.ts +206 -71
  56. package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
  57. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  58. package/src/components/timeline/shared.tsx +15 -4
  59. package/src/components/topology/K8sResourceNode.tsx +28 -1
  60. package/src/components/topology/TopologyControls.tsx +41 -9
  61. package/src/components/topology/layout.ts +11 -5
  62. package/src/components/ui/Badge.tsx +66 -3
  63. package/src/components/ui/ClusterName.tsx +12 -4
  64. package/src/components/ui/PaneLoader.tsx +30 -7
  65. package/src/components/ui/drawer-components.test.tsx +35 -0
  66. package/src/components/ui/drawer-components.tsx +13 -1
  67. package/src/components/workload/WorkloadView.tsx +35 -5
  68. package/src/types/core.ts +136 -2
  69. package/src/utils/applications.test.ts +55 -1
  70. package/src/utils/applications.ts +28 -7
  71. package/src/utils/asset-url.ts +13 -0
  72. package/src/utils/badge-colors.ts +7 -0
@@ -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
@@ -9,11 +9,31 @@ import type { ReactNode } from 'react'
9
9
  export type BadgeSeverity = 'success' | 'warning' | 'alert' | 'error' | 'info' | 'neutral'
10
10
  export type BadgeSize = 'sm' | 'default'
11
11
 
12
+ // Intent tokens beyond severity/kind. Named by PURPOSE, not hue: two tokens may
13
+ // share a color value today yet stay separate names so a future retune moves
14
+ // exactly the right ones. See DESIGN.md "Badge decision tree".
15
+ //
16
+ // - protocol: transport/scheme tags (HTTP/TCP/UDP/…). Its OWN family because it
17
+ // is a recurring, meaningful axis and must NOT collide with severity hues
18
+ // (e.g. a green "HTTPS" must not read as "success").
19
+ // - note: neutral attention/FYI markers (cross-namespace, wildcard, default,
20
+ // immutable). Distinct from severity-info — it flags "noteworthy", not a status.
21
+ // - accent1/2/3: LOCAL categorical distinction (rw/ro, spot/on-demand,
22
+ // control-plane/worker) where the hue carries no cross-screen meaning — just
23
+ // "tell these sibling options apart". The only place sharing is intended.
24
+ // - structural: ports/paths/hosts/weights/names — neutral data fragments, not a
25
+ // status. Its own name so it can diverge from severity-neutral later.
26
+ export type BadgeTone = 'note' | 'accent1' | 'accent2' | 'accent3' | 'structural'
27
+
12
28
  interface BadgeProps {
13
29
  /** Severity-based coloring (status badges) */
14
30
  severity?: BadgeSeverity
15
31
  /** K8s resource kind coloring (kind badges) */
16
32
  kind?: string
33
+ /** Transport protocol/scheme tag (http/https/tls/tcp/udp/grpc/h2) */
34
+ protocol?: string
35
+ /** Categorical/accent intent tone (see BadgeTone) */
36
+ tone?: BadgeTone
17
37
  /** Explicit color class override (bypasses severity/kind lookup) */
18
38
  colorClass?: string
19
39
  /** Size variant */
@@ -135,12 +155,49 @@ const KIND: Record<string, string> = {
135
155
 
136
156
  const DEFAULT_KIND_COLOR = 'bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-400 dark:border-fuchsia-800/40'
137
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // PROTOCOL COLORS — transport/scheme tags. Deliberately avoid severity hues
160
+ // (emerald/amber/orange/red/sky) so a protocol never reads as a status.
161
+ // ---------------------------------------------------------------------------
162
+ const PROTOCOL: Record<string, string> = {
163
+ http: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
164
+ http2: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
165
+ h2: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
166
+ h2c: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
167
+ https: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
168
+ tls: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
169
+ tcp: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
170
+ udp: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
171
+ grpc: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
172
+ 'grpc-web': 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
173
+ }
174
+ const DEFAULT_PROTOCOL_COLOR = 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-950/50 dark:text-slate-400 dark:border-slate-700/40'
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // TONE COLORS — note / local-categorical accents / structural.
178
+ // ---------------------------------------------------------------------------
179
+ const TONE: Record<BadgeTone, string> = {
180
+ // attention/FYI — distinct from severity so it doesn't read as warning/info
181
+ note: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
182
+ // three visually-distinct accents for local "tell siblings apart" use
183
+ accent1: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
184
+ accent2: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
185
+ accent3: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
186
+ // neutral data fragment (ports/paths/hosts/names)
187
+ structural: 'bg-theme-elevated text-theme-text-secondary border-theme-border',
188
+ }
189
+
138
190
  // Structure classes
139
191
  const SIZE_CLASSES: Record<BadgeSize, string> = {
140
192
  default: 'badge',
141
193
  sm: 'badge-sm',
142
194
  }
143
195
 
196
+ /** Resolve a protocol/scheme label (case-insensitive) to its color class. */
197
+ export function getProtocolColorClass(protocol: string): string {
198
+ return PROTOCOL[protocol.toLowerCase().trim()] ?? DEFAULT_PROTOCOL_COLOR
199
+ }
200
+
144
201
  /** Resolve kind color with fuzzy matching for plural/lowercase forms */
145
202
  export function getKindColorClass(kind: string): string {
146
203
  // Direct match
@@ -185,8 +242,14 @@ export function getSeverityColorClass(severity: BadgeSeverity): string {
185
242
  * Badge component — the ONE source of truth for badge rendering.
186
243
  * Use severity for status badges, kind for resource type badges, or colorClass for custom.
187
244
  */
188
- export function Badge({ severity, kind, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
189
- const color = colorClass ?? (severity ? SEVERITY[severity] : kind ? getKindColorClass(kind) : '')
245
+ export function Badge({ severity, kind, protocol, tone, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
246
+ const color =
247
+ colorClass ??
248
+ (severity ? SEVERITY[severity]
249
+ : protocol ? getProtocolColorClass(protocol)
250
+ : tone ? TONE[tone]
251
+ : kind ? getKindColorClass(kind)
252
+ : '')
190
253
  const cls = clsx(SIZE_CLASSES[size], color, className)
191
254
 
192
255
  if (onClick) {
@@ -196,4 +259,4 @@ export function Badge({ severity, kind, colorClass, size = 'default', className,
196
259
  }
197
260
 
198
261
  // Re-export the raw color maps for backwards compat (used by badge-colors.ts consumers)
199
- export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS }
262
+ export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS, PROTOCOL as BADGE_PROTOCOL_COLORS, TONE as BADGE_TONE_COLORS }
@@ -3,10 +3,18 @@ import { MiddleEllipsis } from './MiddleEllipsis'
3
3
  import { Tooltip } from './Tooltip'
4
4
  import { parseContextName } from '../../utils/context-name'
5
5
  import type { ParsedContextName } from '../../utils/context-name'
6
- import awsLogo from './provider-logos/aws.png'
7
- import awsLogoDark from './provider-logos/aws-dark.png'
8
- import gcpLogo from './provider-logos/gcp.png'
9
- import azureLogo from './provider-logos/azure.svg'
6
+ import { assetUrl } from '../../utils/asset-url'
7
+ import awsLogoAsset from './provider-logos/aws.png'
8
+ import awsLogoDarkAsset from './provider-logos/aws-dark.png'
9
+ import gcpLogoAsset from './provider-logos/gcp.png'
10
+ import azureLogoAsset from './provider-logos/azure.svg'
11
+
12
+ // assetUrl normalizes the bundler-specific asset-import type (string under Vite,
13
+ // StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
14
+ const awsLogo = assetUrl(awsLogoAsset)
15
+ const awsLogoDark = assetUrl(awsLogoDarkAsset)
16
+ const gcpLogo = assetUrl(gcpLogoAsset)
17
+ const azureLogo = assetUrl(azureLogoAsset)
10
18
 
11
19
  // ClusterName renders a kubectl context string with the meaningful
12
20
  // cluster identity surfaced as primary text and provider/region pushed
@@ -1,8 +1,18 @@
1
- import radarLoadingIcon from '../../assets/radar/radar-icon-loading.svg'
1
+ import { assetUrl } from '../../utils/asset-url'
2
+ import radarLoadingIconAsset from '../../assets/radar/radar-icon-loading.svg'
2
3
 
3
- // PaneLoader — center-of-pane loading state. Animated radar icon stacked
4
- // above a label so swapping the label across the loading chain doesn't
5
- // shift the icon horizontally. Pin to the parent's fill via `className`
4
+ // assetUrl normalizes the bundler-specific asset-import type (string under Vite,
5
+ // StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
6
+ const radarLoadingIcon = assetUrl(radarLoadingIconAsset)
7
+
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`
6
16
  // (`flex-1`, `h-full`, `h-32`, `absolute inset-0`, etc.). The SVG self-
7
17
  // animates (sweep arm + blips, `prefers-reduced-motion` honored).
8
18
  export function PaneLoader({
@@ -12,10 +22,23 @@ export function PaneLoader({
12
22
  label?: string
13
23
  className?: string
14
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.
15
28
  return (
16
- <div className={`flex flex-col items-center justify-center gap-3 ${className}`}>
17
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
18
- <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>
19
42
  </div>
20
43
  )
21
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
 
@@ -554,8 +554,10 @@ export interface HelmRelease {
554
554
  status: string
555
555
  revision: number
556
556
  updated: string // ISO date string
557
+ lastOperation?: HelmOperation
558
+ operations?: HelmOperation[]
557
559
  // Health summary from owned resources
558
- resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
560
+ resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
559
561
  healthIssue?: string // Primary issue if unhealthy (e.g., "OOMKilled")
560
562
  healthSummary?: string // Brief summary like "2/3 pods ready"
561
563
  // When set, this release was installed by Flux's helm-controller — the
@@ -574,6 +576,27 @@ export interface HelmRevision {
574
576
  updated: string // ISO date string
575
577
  }
576
578
 
579
+ export type HelmOperationKind = 'release_failed' | 'upgrade_failed' | 'upgrade_rolled_back' | 'rollback' | 'pending'
580
+ export type HelmOperationStatus = 'failed' | 'rolled_back' | 'completed' | 'stuck_pending'
581
+ export type HelmOperationConfidence = 'high' | 'medium' | 'low'
582
+ export type HelmOperationSource = 'helm_status' | 'helm_history'
583
+
584
+ export interface HelmOperation {
585
+ kind: HelmOperationKind
586
+ status: HelmOperationStatus
587
+ source: HelmOperationSource
588
+ confidence: HelmOperationConfidence
589
+ message: string
590
+ evidence?: string
591
+ failureDescription?: string
592
+ revision?: number
593
+ failedRevision?: number
594
+ rollbackRevision?: number
595
+ targetRevision?: number
596
+ pendingStatus?: string
597
+ updated?: string
598
+ }
599
+
577
600
  export interface HelmReleaseDetail {
578
601
  name: string
579
602
  namespace: string
@@ -589,9 +612,15 @@ export interface HelmReleaseDetail {
589
612
  notes: string
590
613
  history: HelmRevision[]
591
614
  resources: HelmOwnedResource[]
615
+ resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown'
616
+ healthIssue?: string
617
+ healthSummary?: string
592
618
  hooks?: HelmHook[]
619
+ hookDiagnostics?: HookDiagnostic[]
593
620
  readme?: string
594
621
  dependencies?: ChartDependency[]
622
+ lastOperation?: HelmOperation
623
+ operations?: HelmOperation[]
595
624
  // When set, this release was installed by Flux's helm-controller — see
596
625
  // HelmRelease.managedByFluxHelmRelease for context. Format: "namespace/name".
597
626
  managedByFluxHelmRelease?: string
@@ -599,10 +628,78 @@ export interface HelmReleaseDetail {
599
628
 
600
629
  export interface HelmHook {
601
630
  name: string
631
+ namespace?: string
602
632
  kind: string
633
+ path?: string
603
634
  events: string[]
604
635
  weight: number
605
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
606
703
  }
607
704
 
608
705
  export interface ChartDependency {
@@ -630,12 +727,40 @@ export interface HelmValues {
630
727
  computed?: Record<string, unknown>
631
728
  }
632
729
 
730
+ export interface ValuesDiff {
731
+ revision1: number
732
+ revision2: number
733
+ allValues: boolean
734
+ diff: string
735
+ }
736
+
633
737
  export interface ManifestDiff {
634
738
  revision1: number
635
739
  revision2: number
636
740
  diff: string
637
741
  }
638
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
+
639
764
  // Selected Helm release (for drawer state)
640
765
  export interface SelectedHelmRelease {
641
766
  namespace: string
@@ -649,7 +774,16 @@ export interface UpgradeInfo {
649
774
  latestVersion?: string
650
775
  updateAvailable: boolean
651
776
  repositoryName?: string
777
+ // 'repository' for classic HTTP-repo matches, 'oci' when discovered via a
778
+ // registered OCI chart source. Absent when the source couldn't be determined.
779
+ sourceType?: 'repository' | 'oci'
780
+ // oci:// chart reference an OCI-sourced upgrade lives at (display only).
781
+ chartRef?: string
652
782
  error?: string
783
+ // True only when the error is a genuinely untracked source (registering a
784
+ // chart source could fix it) — NOT for repo-side errors like a stale index or
785
+ // classic ambiguity. Gates the "track source" affordance.
786
+ untracked?: boolean
653
787
  }
654
788
 
655
789
  // Batch upgrade info keyed by "storageNamespace/name".